DEV Community

eerk
eerk

Posted on

Inventing your own HTML Elements to build a DOM game

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!");
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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

<body>
    <car-component></car-component>
</body>
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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();
} 
Enter fullscreen mode Exit fullscreen mode

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!");
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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)`;
Enter fullscreen mode Exit fullscreen mode

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];
Enter fullscreen mode Exit fullscreen mode

Download the working project at:

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

Top comments (6)

Collapse
 
mapleleaf profile image
MapleLeaf • Edited

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()
Enter fullscreen mode Exit fullscreen mode

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.

Collapse
 
eerk profile image
eerk • Edited

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;
Enter fullscreen mode Exit fullscreen mode

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

Collapse
 
mapleleaf profile image
MapleLeaf

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 😊

Collapse
 
ben profile image
Ben Halpern

Wow, very cool! Also really nice TypeScript examples.

Collapse
 
dascritch profile image
Xavier Mouton-Dubosc

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

Collapse
 
eerk profile image
eerk

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