loading...

Inventing your own HTML Elements to build a DOM game

eerk profile image eerk ・4 min read

Building a DOM game with HTML Custom Elements

This is a small experiment showing how to invent your own HTML Elements to build a game in the DOM.

What are these custom elements?

A custom element is an HTML Element that allows you to add your own properties and methods. For example, the basic HTMLElement has a style property and a click() method. By extending the HTML Element we get all this existing functionality and we can add our own.

In this experiment we have an element Car that has an x and y property and an update() method:

class Car extends HTMLElement {

    public x: number;
    public y: number;

    constructor(){
        super();
        console.log("A car was created!");
    }

    public update(): void {
        console.log("The car's update method was called!");
    }
}

Before we can add our Car element to the DOM, we have to register it, connecting our class to a HTML tag. Note that the html tag needs to contain a hyphen:

window.customElements.define("car-component", Car);

Now you can add cars to the dom by placing tags:

<body>
    <car-component></car-component>
</body>

In our game we prefer to add cars by code. We can create a new instance of Car and add it to the DOM in one line:

document.body.appendChild(new Car());

This will result in a <car-component></car-component> being added to your HTML structure, and the message A car was created! will appear in the console. This HTML Element will have an x and y property and an update() method.

DOM manipulation

You can query your HTML document for car components, and use the new for of loop to call their update() method.

let cars : NodeListOf<Car> = document.getElementsByTagName("car-component") as NodeListOf<Car>;

for(let c of cars){
    c.update();
} 

Lifecycle

A custom element has lifecycle hooks: these methods get called automatically when the Car is added to, or removed from, the DOM.

class Car extends HTMLElement {

    public connectedCallback(): void {
        console.log("A car was added to the DOM");
    }

    public disconnectedCallback():void{
        console.log("hey! someone removed me from the DOM!");
    }
}

Game Loop

Our game class will create a player element and start the game loop. A game loop updates our game elements 60 times per second using requestAnimationFrame.

  • The game loop will add a new Car() to the DOM every second by using the modulo operator.
  • The game loop will find all <car-component> tags and call their update method.

The game is instantiated with new Game() after window.load. Note that the Game class itself doesn't need to extend HTMLElement.

class Game {

    private counter:number = 0;

    constructor() {
        document.body.appendChild(new Player());
        requestAnimationFrame(() => this.gameLoop());
    }

    private gameLoop(){
        this.counter++;
        if(this.counter%60 == 0) {
            document.body.appendChild(new Car());
        }

        let cars : NodeListOf<Car> = document.getElementsByTagName("car-component") as NodeListOf<Car>;

        for(let c of cars){
            c.update();
        } 

        requestAnimationFrame(() => this.gameLoop());
    }
}

window.addEventListener("load", function() {
    new Game();
});

Removing cars

When a car leaves the screen or hits the player, we can easily remove it. Since the car extends from HTMLElement we can use the remove() method, which removes it from the DOM. The game loop won't call the update method anymore, and if we want, we could use the disconnectedCallback() to execute some final code before the car is completely removed.

public disconnectedCallback():void{
    console.log("the car is removed from the game!");
}

public update(): void {
    this.x += this.speed;

    if (this.x > window.innerWidth) {
        this.remove();
    }
}

Styling and animation

Note that the styling of our custom elements is entirely done in CSS. First we declare that ALL document elements are going to use position:absolute, and then we declare a size and a background image for each individual element:

body * {
    position: absolute;
    display: block;
    margin:0px; padding:0px;
    box-sizing: border-box;
    background-repeat: no-repeat;
}

car-component {
    width:168px; height:108px;
    background-image: url('../images/car.png');
}

We position our elements using css transform, so that we can use the GPU for smooth animation.

this.style.transform = `translate(${this.x}px, ${this.y}px)`;

Extending other elements

This example only uses extends HTMLElement, but we could also extend a HTMLDivElement, HTMLButtonElement, etc. In this example, all our elements are treated as <div> by simply setting display:block in the CSS.

Typescript

This experiment is built with Typescript, but you can easily rebuild it in pure Javascript by removing the type information. You can check the main.js file to see the Javascript equivalent.

To compile this project you can install Typescript with npm install -g typescript, and then type tsc -p in the terminal in the project folder.

Browser support

The above experiment works in Safari and Chrome. Extending specific elements such as HTMLButtonElement doesn't work in any browser yet. Use the polyfill to get support in all browsers.

For of loop in NodeList

The for of loop is not yet supported for NodeList and HTMLCollection in Safari and Firefox, because they are technically not arrays. Enable it with:

NodeList.prototype[Symbol.iterator] = Array.prototype[Symbol.iterator];
HTMLCollection.prototype[Symbol.iterator] = Array.prototype[Symbol.iterator];

Download the working project at:

https://github.com/KokoDoko/game-custom-elements

Posted on by:

eerk profile

eerk

@eerk

Creative technologist, lecturer at University for Applied sciences. Interests: Typescript, Machine Learning, React, Arduino, Front-end, CSS

Discussion

markdown guide
 

You can actually leave off a lot of typing information, and TS will infer it for you. For example:

class Person {
  name = 'defaultName' // no `: string` annotation
  age = 42 // no `: number` annotation

  // no `: string`
  info() {
    return `My name is ${this.name} and I am ${this.age} years old`
  }

  // no `: void`
  speak() {
    alert(this.info())
  }
}

// no `: Person`
const test = new Person()

This is really great, because writing this way a lot of TS will end up just looking like normal JS in the end, while still reaping in all of the benefits of TS.

General rule I use is to turn on noImplicitAny (or the strict rule introduced in 2.2) in the tsconfig.json, then annotate anything that doesn't bypass the implicit any rule, usually function arguments.

That aside, great article! Really interesting way of doing things I never thought about.

 

Thanks! I added strict to tsconfig :) I still like to manually declare the type, it's more readable to me. It also tells other devs what your intention is with this variable.

const test:Person;

Now we know that sooner or later test will be used to store a Person.

 

Definitely helps readability, yeah. But any good modern editor will still tell you the type of the variable/function/method on hover as well.

Plus, const variables need an initial value anyway, but even if you use let instead, having an initial value helps to avoid those nasty undefined errors as well 😊

 

Wow, very cool! Also really nice TypeScript examples.

 

Well... not yet working on Firefox or Safari. Must be polyfilled first.

 

It does work in Safari 10.1.1 but I'll add the polyfill to the article!