Writing a Game in Typescript

Daniel Schulz - Jun 14 '22 - - Dev Community

I know people make all kinds of stuff with <canvas> but that's all black magic to me. Time to change that. This is how I built Attacke!, a simple 2D fighting game in JavaScript.

Before we dig in further, I should clarify that most of my code snippets in this article are truncated to the relevant lines. The entire code can be found on GitHub.

I want it to be a top-down fighting game. Top-down specifically for avoiding jumps and gravity. I started a proof-of-concept as a side-scroller, but that was the point where I went for an easier approach. For now, it's gonna be two players on the same device, but I want my code to be flexible enough to expand on the player number and input options in the future.

I also want the graphics of the game to be easily interchangeable. The first release will only have one theme, but I plan to include more in the future.

The Project Setup

I want this to be very simple, and use as few build steps and dependencies as possible. But I also want some goodies, like hot reloading and types.

This project is going to be very light on the CSS side of things, so I'm sticking to native CSS and spare me the build step.

Having types defaults to use TypeScript. That means I also need a build tool to compile JavaScript out of that. I ended up with ESBuild. I heard that Vite is really fast. Vite is built on top of ESBuild, so using it without Vite should be even faster, right?

#!/usr/bin/env node

const watchFlag = process.argv.indexOf("--watch") > -1;

require("esbuild")
    .build({
        entryPoints: ["src/ts/main.ts", "src/ts/sw.ts"],
        bundle: true,
        outdir: "public",
        watch: watchFlag,
    })
    .catch(() => process.exit(1));
Enter fullscreen mode Exit fullscreen mode
➜  attacke git:(main) yarn build
yarn run v1.22.10
$ ./esbuild.js
✨  Done in 0.47s.
Enter fullscreen mode Exit fullscreen mode

Awesome!

The HTML Foundation

The website that provides the <canvas> is nothing special. The single most important element is the canvas itself. It's not focusable on its own and needs a tabindex to be accessible by keyboard. Hitting up and down will move the page up and down. I need to avoid that while the canvas has focus, or else the page jumps up and down with character movements. The width and height are also fixed. The canvas may not be displayed at FullHD, but its dimensions are the endpoints of the canvases coordinate system and are needed to calculate positions within it.
I also added a loading indicator for a more streamlined boot-up experience.

<div class="loader">
    <progress value="0" max="100"></progress>
</div>
<canvas tabindex="0" id="canvas" width="1920" height="1080"></canvas>
Enter fullscreen mode Exit fullscreen mode

Game Loop

A real-time game in JavaScript requires a game loop: a recursive function calling itself for every frame. That means we have a performance budget of 16ms to render one frame if we want to keep it at 60fps or 33ms for a 30fps target. There's no game logic inside the loop itself. Instead, I send out a tick event for every frame. All other parts of the game can listen to that and perform their logic whenever needed.

My first try failed at that.

export class Renderer {
    ctx: CanvasRenderingContext2D;
    ticker: number;

    constructor(ctx: CanvasRenderingContext2D) {
        this.ctx = ctx;
        this.ticker = setInterval(() => {
            const tick = new Event("tick", {
                bubbles: true,
                cancelable: true,
                composed: false,
            });
            ctx.canvas.dispatchEvent(tick);
        }, 1000 / 60); // aim for 60fps
    }
}
Enter fullscreen mode Exit fullscreen mode

I used an Interval Timer to call the game loop. That worked okay on Chrome but fell apart on Firefox and Safari. Firefox performs poorly with drawImage(), which I'll use to draw my sprites. Fair enough, that's a straightforward reason. Safari, though, is very capable of rendering 60fps even when drawing huge images every frame, but sometimes decides not to. Apparently, Macbooks have a battery saver mode enabled by default, which limits Safari to 30fps whenever the power cable is not connected. Took me a while to find that out.

The workaround for both issues is to use requestAnimationFrame instead of setInterval.

A diagram detailing how the game loop works. At the top is a box labeled

constructor(ctx: CanvasRenderingContext2D, theme: Theme) {
    this.ctx = ctx;
    this.theme = theme;
    this.fps = 60; // aim for 60fps
    this.counter = 0;
    this.initTicker();
}

private initTicker() {
    window.requestAnimationFrame(() => {
        this.tick();
        this.initTicker();
    });
}
Enter fullscreen mode Exit fullscreen mode

Now the game runs fluently in every browser, but the game speed still varies, because I'm calculating and executing every action (like moving, attacking, etc.) on every frame. A 30fps browser will run the game at half speed. I'm going to counter that by measuring the time between frames and inject the number of skipped frames into the calculations.

private tick() {
    const timeStamp = performance.now();
    const secondsPassed = (timeStamp - this.oldTimeStamp) / 1000;
    this.oldTimeStamp = timeStamp;

    // Calculate fps
    const fps = Math.round(1 / secondsPassed);
    const frameSkip = clamp(Math.round((60 - fps) / fps), 0, 30);

    // to allow for animations lasting 1s
    if (this.counter >= this.fps * 2) {
        this.counter = 0;
    }

    const tick: TickEvent = new CustomEvent("tick", {
        bubbles: true,
        cancelable: true,
        composed: false,
        detail: {
            frameCount: this.counter,
            frameSkip: frameSkip,
        },
    });
    this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
    this.ctx.canvas.dispatchEvent(tick);

    this.counter++;
}
Enter fullscreen mode Exit fullscreen mode

Playable Characters

Each playable character gets invoked in their own instance of a character class. It controls the players' movement, actions, appearance, and sounds. You can probably guess it's a handful. I'll try to break it down into manageable sections.

Move fast

As real-world objects, when characters move around, they don't go from zero to top speed instantly. They accelerate and decelerate. When moving, they have a certain velocity. This is reflected in the class by:

class Character {
    position: coordinates;
    orientation: number;
    speed: number;
    maxVelocity: number;
    velocity: coordinates;
    obstacle: Obstacle;
    action: {
        movingX: number;
        movingY: number;
    };
    //...
}
Enter fullscreen mode Exit fullscreen mode

When a movement key is hit, the action.movingX|Y property is set to +-1. When the key is released, the property is set to 0. This serves as an indicator if the player should start or keep moving.

// move left
config.controls[this.player].left.forEach((key: string) => {
    document.addEventListener("keydown", (event: KeyboardEvent) => {
        this.captureEvent(event);
        if (event.code === key && event.repeat === false) {
            this.action.movingX = -1;
        }
    });
    document.addEventListener("keyup", (event: KeyboardEvent) => {
        this.captureEvent(event);
        if (event.code === key) {
            this.action.movingX = 0;
        }
    });
});

// repeat for up, down, and right.
Enter fullscreen mode Exit fullscreen mode

Note that the key mappings are stored inside config.controls as an array with controls for each player.

We can ignore captureEvent for now. That only prevents the page from scrolling when cursor keys are pressed. Now we know when a character should start or stop moving, but nothing's happening yet. That's when I hook into the game loop. Remember how a tick event is sent for every frame? I'm listening to that here. For every frame, I update the position before re-drawing the character.

private move(): void {
    const { position, velocity, action } = this;
    const newX = position.x + action.movingX * this.speed + velocity.x * this.speed;
    const newY = position.y + action.movingY * this.speed + velocity.y * this.speed;

    position.x = newX;
    position.y = newY;

    if (position.x < 0) {
        position.x = 0;
    } else if (newX > this.ctx.canvas.width - this.size) {
        position.x = this.ctx.canvas.width - this.size;
    }

    if (position.y < 0) {
        position.y = 0;
    } else if (newY > this.ctx.canvas.height - this.size) {
        position.y = this.ctx.canvas.height - this.size;
    }

    this.velocity.x = clamp(
        (action.movingX ? this.velocity.x + action.movingX : this.velocity.x * 0.8) * this.speed,
        this.maxVelocity * -1,
        this.maxVelocity
    );
    this.velocity.y = clamp(
        (action.movingY ? this.velocity.y + action.movingY : this.velocity.y * 0.8) * this.speed,
        this.maxVelocity * -1,
        this.maxVelocity
    );
}
Enter fullscreen mode Exit fullscreen mode

This is where velocity comes in. Velocity is a value that keeps incrementing over time as the player holds a movement key, up to maxVelocity, which acts as top speed. When the player releases the movement key, the character doesn't stop abruptly but decelerates until it comes to a halt. The velocity gently reaches 0 again.

But characters don't just move around, they can also turn. In my case, I want them to face each other directly at all times. Since there's only one opponent, for now, players should concentrate on timing a hit on their adversary, instead of turning to their sole target all the time. That also helps keep the game speed high.

private turn(): void {
    const otherPlayer = this.player === 0 ? 1 : 0;
    const orientationTarget: coordinates = this.players[otherPlayer]?.position || { x: 0, y: 0 };
    const angle = Math.atan2(orientationTarget.y - this.position.y, orientationTarget.x - this.position.x);
    this.orientation = angle;
}
Enter fullscreen mode Exit fullscreen mode

My little fighting game is a dancing game now!

Two character prototypes dancing around each other. One is a yellow square, the other is blue. Both have a wide magenta border on one side to indicate their face. They are always facing each other.

Break things

Characters should be able to attack each other. To add more depth to the game, defending against an attack should also be an option. Both are defined to be character actions, and each has a cooldown period to prevent spamming them.

class Character {
    range: number;
    attackDuration: number;
    blockDuration: number;
    cooldownDuration: number;
    action: {
        attacking: boolean;
        blocking: boolean;
        cooldown: boolean;
    };
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Triggering those actions works the same as moving around — by listening for the keyboard event, then setting the action value to true

// attack
config.controls[this.player].attack.forEach((key: string) => {
    document.addEventListener("keydown", (event: KeyboardEvent) => {
        if (
            this.active &&
            event.code === key &&
            event.repeat === false &&
            !this.action.cooldown
        ) {
            this.action.attacking = true;
        }
    });
});

// block
config.controls[this.player].block.forEach((key: string) => {
    document.addEventListener("keydown", (event: KeyboardEvent) => {
        if (
            this.active &&
            event.code === key &&
            event.repeat === false &&
            !this.action.cooldown
        ) {
            this.action.blocking = true;
        }
    });
});
Enter fullscreen mode Exit fullscreen mode

…and finally executing the action in the game loop.

private attack(): void {
    if (!this.active || !this.action.attacking || this.action.cooldown) {
        return;
    }

    this.action.cooldown = true;

    // strike duration
    window.setTimeout(() => {
        this.action.attacking = false;
    }, this.attackDuration);

    // cooldown to next attack/block
    window.setTimeout(() => {
        this.action.cooldown = false;
    }, this.cooldownDuration);

    this.strike();
}
Enter fullscreen mode Exit fullscreen mode

Attacking is only half of the work. The other half makes sure the opponent is hit — that means they mustn't block the attack and the weapon is in range. That's handled in the strike() method.

private strike(): void {
    const otherPlayerId = this.player === 0 ? 1 : 0;
    const otherPlayer: rectangle = this.players[otherPlayerId].obstacle?.getObject();

    const blocked = this.players[otherPlayerId].action.blocking;
    if (blocked) {
        // opponent blocked the attack
        return;
    }

    // attack hits

    const otherPlayerPolygon = new Polygon(new Vector(0, 0), [
        new Vector(otherPlayer.a.x, otherPlayer.a.y),
        new Vector(otherPlayer.b.x, otherPlayer.b.y),
        new Vector(otherPlayer.c.x, otherPlayer.c.y),
        new Vector(otherPlayer.d.x, otherPlayer.d.y),
    ]);

    const weaponPosition = this.getWeaponPosition();
    const weaponPolygon = new Polygon(new Vector(0, 0), [
        new Vector(weaponPosition.a.x, weaponPosition.a.y),
        new Vector(weaponPosition.b.x, weaponPosition.b.y),
        new Vector(weaponPosition.c.x, weaponPosition.c.y),
        new Vector(weaponPosition.d.x, weaponPosition.d.y),
    ]);

    const hit = this.collider.testPolygonPolygon(weaponPolygon, otherPlayerPolygon) as boolean;
    if (hit) {
        // finish this round
        this.finish();
    }
}
Enter fullscreen mode Exit fullscreen mode

This creates a hitbox around the player that reaches out by 150% into the opponent's direction. If the weapon hitbox collides with the opponent's hitbox, the attack lands and the player wins the round.

But what's that about hitboxes? What are Vectors and Polygons? Let's talk about…

Collision Detection

Collision detection isn't as straightforward as I assumed it would be. Starting with two rectangles on a canvas, you can simply compare their x and y coordinates. But that doesn't work anymore once you rotate the rectangles. My next try was to create linear functions from the rectangles' bordering lines and check for intersections. That still left some edge cases unaccounted for. It would also be quite inefficient.

That's when I turned to Google for solutions. I found this Codepen and the matching description on StackOverflow:

That solution is clever, elegant, efficient, and — most importantly — way above my skill level in geometry. It's also the method that Collider2D uses to check for intersections between two objects. That's it! Collision Detection is a solved problem. I neither want nor need to deal with that, at least for now.

yarn add collider2d
Enter fullscreen mode Exit fullscreen mode

I added Collider Polygons as hitboxes around every relevant object, including the playable characters, the borders of the canvas, and possible obstacles in the arena. Those Polygons consist of Vectores that describe their circumference. The character Polygons are stored inside a property inside the character class and updated in the move(), turn() and strike() methods.

// inside character.strike()
const otherPlayerPolygon = new Polygon(new Vector(0, 0), [
    new Vector(otherPlayer.a.x, otherPlayer.a.y),
    new Vector(otherPlayer.b.x, otherPlayer.b.y),
    new Vector(otherPlayer.c.x, otherPlayer.c.y),
    new Vector(otherPlayer.d.x, otherPlayer.d.y),
]);

const weaponPosition = this.getWeaponPosition();
const weaponPolygon = new Polygon(new Vector(0, 0), [
    new Vector(weaponPosition.a.x, weaponPosition.a.y),
    new Vector(weaponPosition.b.x, weaponPosition.b.y),
    new Vector(weaponPosition.c.x, weaponPosition.c.y),
    new Vector(weaponPosition.d.x, weaponPosition.d.y),
]);

const hit = this.collider.testPolygonPolygon(
    weaponPolygon,
    otherPlayerPolygon
) as boolean;
Enter fullscreen mode Exit fullscreen mode

Now we have our first glance at some actual gameplay!

The two character prototypes fighting each other. The blue one tries out his shield, diesplayed in green, then proceedes to hit the yellow one, resulting in a new round.

But the characters can still clip through each other. I want them to bump around instead. Collider2D can return some information about the collision, like its vector and location. That plays very well together with my velocity solution. I can simply direct the existing velocity in the direction of the collision deflection:

private collide(): void {
    const obstacles = this.obstacles.filter((obstacle) => obstacle.getId() !== this.obstacle.getId());
    obstacles.forEach((obstacle) => {
        const collision = this.obstacle.collidesWith(obstacle);
        const friction = 0.8;

        if (!collision) {
            return;
        }

        this.velocity.x = (this.velocity.x + collision.overlapV.x * -1) * friction;
        this.velocity.y = (this.velocity.y + collision.overlapV.y * -1) * friction;
    });
}
Enter fullscreen mode Exit fullscreen mode

collide() now gets called in the game loop together with move(), turn() and their friends, so every frame has a poll for collision detection.

Two character prototypes bumping into each other. One is a yellow square, the other is blue. Both have a wide magenta border on one side to indicate their face.

Graphics

Dancing squares with magenta faces might be functional, but they're not pretty. I want some personality. It's been a while since I worked with Photoshop, especially in the retro pixel art style I was going for. I wanted to invoke some nostalgic GameBoy feelings and went for a greyish-green screen (which I later tinted greyish-blue) and drop-shadows on enlarged pixels.

My characters measure 16x16px. The weapon range is 150%, which makes 40x16px. A photoshop canvas that accommodates all sprites with the character centered in the middle would be 64x64px. I scale them up to a 100x100px character size when exporting the images. 16px characters on a full HD screen would be way too small. I ordered my sprites in grouped layers by direction, since every sprite needs eight variations — one for each compass direction. Then multiply that by the number of frames for animated sprites.

A screenshot of a Photoshop Workspace. The document is a 64x64px image, framed by a circle and ruler lines spanning 16x16px in the middle. The character is centered right in the 16x16 square. Its sword extends in the south-eastern direction to the edge of the circle-frame. The layer window shows folders for each general direction with sprites for each character action, like attacking, blocking, and moving. Color-grading layers manage the different colors for players 1 and 2

I need control over every pixel and anti-aliasing is my enemy since it affects neighboring pixels by definition. I stuck to the pen tool instead of the brush and used the Pixel Repetition mode whenever I needed to transform, scale or rotate something.

Exporting images is a bit of a fight. I need to export 8bit pngs. They have an alpha channel and turned out smaller in byte size than gifs (with a hard g) and even webp. For some reason, Photoshop's batch export doesn't support 8bit png. It also can't auto-crop single layers. So manual export it is.

Two knights with sword and shield, the left with a blue face and the right with a yellow one. They stand before a castle with two large towers, a closed gate, and a white flag in the middle. The image is stylized in pixel art. Colors are reduced to blue-tinted monochromatic ones. The objects draw a shadow as if rendered on a GameBoy LCD screen.

Themeability

For now, I've got only one set of sprites, but I plan to do more in the future. At some point, I want to load a different set every round. That means each need to adhere to a specific set of rules. I need a theme definition.

Diagram: A box labeled

Now I have a bunch of JavaScript and a bunch of pngs. Let's marry them, and while we're at it, reach some secondary goals:

  • All sprites must be able to be animated
  • Everything related to the theme must be interchangeable. I want to be able to switch the whole style out later on.

Animating a sprite within a canvas isn't as simple as loading in a gif. drawImage() will only draw the first frame. There are some techniques to implement a gif viewer inside the canvas, but they are overly complex for my use case. I can simply use an array with the individual frames.

declare type Sprite = {
    name: string;
    images: string[];
    animationSpeed: number; // use next image every N frames, max 60
    offset: coordinates;
};
Enter fullscreen mode Exit fullscreen mode

Then write a wrapper for drawImage() that'll use consolidated sprites and switch out animation steps based on the frame count:

public drawSprite(ctx: CanvasRenderingContext2D, name: string, pos: coordinates, frameCount = 0) {
    const sprite = this.sprites.find((x) => x.name === name);
    if (!sprite) {
        return;
    }

    const spriteFrame = Math.floor((frameCount / sprite.animationSpeed) % sprite.images.length);

    const img = this.images.find((x) => x.src.endsWith(`${sprite.images[spriteFrame].replace("./", "")}`));

    if (!img) {
        return;
    }

    ctx.drawImage(img, pos.x + sprite.offset.x, pos.y + sprite.offset.y);
}
Enter fullscreen mode Exit fullscreen mode

Great, we can now animate things! Getting this to work released a huge chunk of endorphins in me.

A small pixelated knight with sword and shield running on the spot and showing off their movement animations next to a castle with two towers and a white flag waving in the wind.

Interchangeability requires conformity. I'm creating a theme config that defines which sprites will be used, and how.

declare type SpriteSet = {
    n: Sprite; // sprite facing north
    ne: Sprite; // sprite facing north-east
    e: Sprite; // etc
    se: Sprite;
    s: Sprite;
    sw: Sprite;
    w: Sprite;
    nw: Sprite;
};

declare type themeConfig = {
    name: string; // has to match folder name
    scene: Sprite; // scene image, 1920x1080
    obstacles: rectangle[]; // outline obsacles within the scene
    turnSprites?: boolean; // whether to turn sprites with characters
    players: {
        default: SpriteSet; // player when standing still, 100x100
        move: SpriteSet; // player when moving, 100x100
        attack: SpriteSet; // player when attacking, 250x100
        block: SpriteSet; // player when blocking, 100x100
    }[]; // provide sprites for each player, else player 1 sprites will be re-used
};
Enter fullscreen mode Exit fullscreen mode

This config gets fed to all the parts that need to know what theme we're dealing with and select all their assets from it. For example, the character class can now draw a themed asset like this:

this.theme.drawSprite(
    this.ctx,
    "p1_move_s",
    { x: this.size / -2, y: this.size / -2 },
    frameCount
);
Enter fullscreen mode Exit fullscreen mode

Remember when I added that turning part to moving characters? That might be useful for themes that actually allow turning — think of Asteroids for example. But my theme has an up and down. Turning a sprite would just look silly.

The blue knight standing next to the castle. He begins spinning around counterclockwise.

I need a method that assigns sprites to orientation values. I have to map 8 compass directions to a full circle of orientation values. Circle segments are the way to go. Since the start and end points are right in the middle of a direction, I simply assign this overlapping direction twice — as first and last.

A pie diagram: eight parts, labelled clockwise as

private getSprite(): Sprite {
    const directions = ["w", "nw", "n", "ne", "e", "se", "s", "sw", "w"];
    const zones = directions.map((z, i) => ({
        zone: z,
        start: (Math.PI * -1) - (Math.PI / 8) + (i * Math.PI) / 4,
        end: (Math.PI * -1) - (Math.PI / 8) + ((i + 1) * Math.PI) / 4,
    }));

    const direction = zones.find((zone) => this.orientation >= zone.start && this.orientation < zone.end);

    // action refers to moving, attacking, blocking...
    return this.theme.config.players[this.player][action][direction.zone];
}
Enter fullscreen mode Exit fullscreen mode

And finally, I'll use this.theme.config.turnSprites inside the character class to toggle between turn and direction-based themes.

The blue knight turning around. Each time he faces a new general direction a new machting sprite is displayed, generating the illusion of the character actually turning around.

Sounds

Visuals are only one aspect of themes. The other is sounds. I want a specific sound for attacking, blocking, bumping into things, and of course some background music. My theme is 8bit styled, so I went for some chiptune to accompany that. My music production skills are next to non-existent and I've never done anything with chiptune, so I went to itch.io to acquire some sounds made by people who actually know what they're doing.

I thought I could go the easy, straightforward way and use the <audio> element. Whenever a sound is needed, create an element, autoplay it, then remove it.

const audio = new Audio("./sound.mp3");
audio.play();
Enter fullscreen mode Exit fullscreen mode

And that works great, at least in Chrome and Firefox. But Safari… oh Safari… There's always a delay before the sound plays. I tried prefetching the file, I tried caching it in a service worker and I tried creating the audio element beforehand and just setting the player position when needed. Safari simply won't have it. Alright, gotta do it the proper way then.

Like I set up the canvas context for visual things, I'm setting up an AudioContext for sounds. One context that is shared by all other parts of the game to play out whatever's needed.

The Web Audio API is built up like an actual modular synthesizer. We need to connect one device to the next. In this case, we use audio files as an input source, buffer them, connect to a Gain Node to set the volume, and finally play them out.

this.ctx = new (window.AudioContext || window.webkitAudioContext)();

async function play(sound: string): Promise<void> {
    if (this.sounds[this.getAudioUrl(sound)].playing) {
        return;
    }

    this.sounds[this.getAudioUrl(sound)].playing = true;

    const arrayBuffer = await this.getSoundFile(this.getAudioUrl(sound));
    const source = this.ctx.createBufferSource();

    this.ctx.decodeAudioData(arrayBuffer, (audioBuffer) => {
        source.buffer = audioBuffer;
        source.connect(this.vol);
        source.loop = false;
        source.onended = () => {
            this.terminateSound(source);
            this.sounds[this.getAudioUrl(sound)].playing = false;
        };
        source.start();
    });
}
Enter fullscreen mode Exit fullscreen mode

That way I can register sounds with:

// theme config
{
    // ...
    bgAudio: "./assets/bgm.mp3",
    attackAudio: "./assets/attack.mp3",
    blockAudio: "./assets/block.mp3",
    collideAudio: "./assets/bump.mp3",
    winAudio: "./assets/win.mp3",
}
Enter fullscreen mode Exit fullscreen mode

…and invoke them with:

this.audio.play(this.theme.config.collideAudio);
Enter fullscreen mode Exit fullscreen mode

Finally, even Safari submits to having sounds play out when I need them to, not when the browser feels like it.

Using a gamepad

I love using obscure Browser APIs. This is a prime candidate for the Gamepad API, which interfaces with up to four connected Gamepads.

Working with it feels a bit janky, though. Unlike more common input methods like keyboard and mouse, Gamepads don't send events. Instead, it populates a Gamepad object as soon as the site detects a Gamepad Interaction.

interface Gamepad {
    readonly axes: ReadonlyArray<number>;
    readonly buttons: ReadonlyArray<GamepadButton>;
    readonly connected: boolean;
    readonly hapticActuators: ReadonlyArray<GamepadHapticActuator>;
    readonly id: string;
    readonly index: number;
    readonly mapping: GamepadMappingType;
    readonly timestamp: DOMHighResTimeStamp;
}

interface GamepadButton {
    readonly pressed: boolean;
    readonly touched: boolean;
    readonly value: number;
}
Enter fullscreen mode Exit fullscreen mode

Every interaction mutates the object. Since there are no browser-native events being sent, I need to listen for changes on the gamepad object.

if (
    this.gamepads[gamepadIndex]?.buttons &&
    gamepadButton.button.value !==
        this.gamepads[gamepadIndex]?.buttons[gamepadButton.index]?.value &&
    gamepadButton.button.pressed
) {
    // send press event
    this.pressButton(gamepadIndex, b.index, gamepadButton.button);
} else if (
    this.gamepads[gamepadIndex]?.buttons &&
    gamepadButton.button.value !==
        this.gamepads[gamepadIndex]?.buttons[gamepadButton.index]?.value &&
    !gamepadButton.button.pressed
) {
    // send release event
    this.releaseButton(gamepadIndex, b.index, gamepadButton.button);
}
Enter fullscreen mode Exit fullscreen mode

pressButton and releaseButton send custom events, which I can use in the character class and expand my input methods to recognize gamepads.

A gamepad controlling the blue character on a screen behind it. A finger moves the left gamepad stick, the character moves accordingly.

I only have Xbox 360 controllers, so that's what I built and tested this with. As far as I unterstood it, the keymaps work the same for playstation controllers. Stick one and two map to the left and right stick, regardless of their hardware layout. The Xbox' ABXY buttons are mapped the same way as the Playstation's geometric shapes.

An xbox and ps5 controller schema side-by-side. The matching buttons and sticks are colored in matching colors and labeled with

I couldn't get the GamepadHapticActuator (also known as Rumble or Vibration) to work with my 360 controllers. I'm not sure if Chrome and Firefox don't support it just with my controllers or at all. I might have to get my hands on more recent controllers to test that out. I'd also like to test the Gamepad API a bit more with more obscure controllers like ones with the Nintendo 64 layout or the Steam Controller.

Gameplay

Let's bring things into order. I have some bits and pieces of the game, but nothing's working together properly yet.

When we register a hit, nothing's happening as of now. That's a very boring game. I want to give some feedback as to who won and then restart the round. Since it's a pretty fast-paced game with short rounds, a score display would be nice to have.

We can determine the winner of a round in the character.strike() method. Whoever calls the method and registers an actual hit, wins. I'm gonna send an event with that information and trigger the following calls:

  • Show a winner indication
  • Increment the score counter
  • Reset the characters
  • Start a countdown to a new round
declare interface FinishEvent extends Event {
    readonly detail?: {
        winner: number;
    };
}

this.ctx.canvas.addEventListener("countdown", ((e: FinishEvent) => {
    if (typeof e.detail?.winner === "number") {
        this.gui.incrementScore(e.detail.winner);
    }

    this.startCountdown(e.detail?.winner);
    this.togglePlayers(false);
}) as EventListener);

this.ctx.canvas.addEventListener("play", () => {
    this.togglePlayers(true);
});
Enter fullscreen mode Exit fullscreen mode

I could bet better of with a proper state machine at this point, but my event mechanic isn't convoluted enough to annoy me into a refactoring. As long as it fits nicely into a diagram, it can't be so bad, right?

Flow Diagram. The Load Assets Event triggers a download, which starts the game. When a player wins, the game is reset and the counddown starts again.

Loading

When booting up the game and starting the first round, it still shows its laggy side. The sounds and graphics aren't loaded yet and keep popping up as they're landing in the browser cache. I need a loading strategy.

I'm pretty sure of what I need to load before starting the game. I already have the web site's assets, as I'm already executing JavaScript when starting the game. No need to take that into account. But I do need to load all the graphics and sounds. Otherwise, they would come in as needed, introducing loading times.

I can load an image by creating a new Image prototype and providing it with the src. The browser will start fetching it automatically.

private loadImage(src: string): Promise<HTMLImageElement> {
    const url = `./themes/${this.config.name}/${src}`;
    return fetch(url).then(() => {
        const img = new Image();
        img.src = url;
        if (!this.images.includes(img)) {
            this.images.push(img);
        }
        return img;
    });
}
Enter fullscreen mode Exit fullscreen mode

I can now iterate over every image found in the theme config and load everything. The promised images are stored inside an array.

this.config.players.forEach((player) => {
    const spriteSets = ["default", "move", "attack", "block"];
    spriteSets.forEach((spriteSet) => {
        Object.keys(player[spriteSet]).forEach((key: string) => {
            player[spriteSet][key].images.forEach(async (image: string) => {
                const imageResp = await this.loadImage(image);
                if (toLoad.includes(imageResp)) {
                    return;
                }
                imageResp.onload = () => {
                    this.onAssetLoaded(toLoad);
                };
                toLoad.push(imageResp);
            });
            this.sprites.push(player[spriteSet][key]);
        });
    });
});
Enter fullscreen mode Exit fullscreen mode

Every time an image loads, I check if all promises in the array are resolved. If they are, all images are loaded and I can send an event that tells how much of the game assets are already loaded.

private onAssetLoaded(assetList: HTMLImageElement[]) {
    const loadComplete = assetList.every((x) => x.complete);
    const progress = Math.floor(
        ((assetList.length - assetList.filter((x) => !x.complete).length) / assetList.length) * 100
    );
    const loadingEvent: LoadingEvent = new CustomEvent("loadingEvent", { detail: { progress } });
    this.ctx.canvas.dispatchEvent(loadingEvent);

    if (loadComplete) {
        this.assetsLoaded = true;
    }
}
Enter fullscreen mode Exit fullscreen mode

The progress information gets mapped to a <progress> element. Whenever it reaches 100% I fade in the <canvas> and start the game.

A screen capture of the game loading. At the very top there's the header saying

Finishing touches

Strictly speaking, the game is now finished. But it's also still a website and I should do my best to keep it as fast, compatible, and accessible as I can. While I have no way to add a11y-specific features to the gameplay (at least that I know of), I can concentrate on the website part. Let's start with some automated linting.

Lighthouse and Validator

I didn't add a description <meta> tag yet. I had the canvas tabindex set to 1 when it should be 0 (just to have it focusable). I still had an SVG favicon, which still isn't supported by Safari (of course), and while I was at it, I also added an apple-touch-icon. There was also a missing <label> to an <input>.

Lighthouse scores showing all 100's across Performance, Accessibility, Best Practices and SEO.

Progressive Web App

There's one lighthouse category left out: PWA. It even makes sense to add PWA features to this project. Where blogs offer PWAs with questionable benefits at most (why would I want your blog on my home screen?), a game should absolutely bin installable and offline-capable.

Image of a passed Lighhouse PWA check. It shows the PWA Logo, a white checkmark in a green circle, the abbreviation PWA and the text

The first step is a manifest. That doesn't need to do a lot, just include the necessary icons, colors, and title strings to format the home screen icons, splash screen, and Browser UI when installed. And specify that the PWA runs in fullscreen mode and therefore hides all browser UI elements.

{
    "theme_color": "#1e212e",
    "background_color": "#1e212e",
    "display": "fullscreen",
    "scope": "/",
    "start_url": "/",
    "name": "Attacke!",
    "short_name": "Attacke!",
    "icons": [
        {
            "src": "assets/icon-192x192.png",
            "sizes": "192x192",
            "type": "image/png"
        },
        ...
    ]
}
Enter fullscreen mode Exit fullscreen mode

I want my game PWA to be just the game itself. Any additional links like the credits page and the link to the source code should open inside a new browser window as long as it's opened in fullscreen view. While the app is opened inside a regular browser window, I'm a huge fan of letting the user stay in control over how links behave.

This snippet asks the browser if it's in fullscreen mode and opens all links marked with data-link='external' in a new tab if it is:

if (window.matchMedia("(display-mode: fullscreen)").matches) {
    document.querySelectorAll("[data-link='external']").forEach((el) => {
        el.setAttribute("target", "_blank");
        el.setAttribute("rel", "noopener noreferrer");
    });
}
Enter fullscreen mode Exit fullscreen mode

Offline Mode

The next step is the Service Worker. For a valid PWA, it just needs to be registered and provide an answer for offline requests. In this case, I want to create an offline cache with all the game assets inside. That'll be quite a bit of network traffic when installing, but I think that's okay when installing a game. App Stores work the same after all.

Diagram on what the Servcie Worker does. A box labelled

Caching offline requests as they come in is relatively easy, and responding to them is as well. There are tons of articles on how to do that. But because of the chunk of assets coming down the network, I want to cache them only if the user installs the app. Streaming them in when needed is the preferable option otherwise. Since all my themes are going to follow the same schema, I can iterate through them and then return a list of assets:

export const getGameAssets = (): string[] => {
    const assets = [];

    Object.keys(themes).forEach((theme) => {
        const themeConfig = themes[theme] as themeConfig;

        // add player sprites
        ["p1", "p2"].forEach((player, pi) => {
            ["default", "move", "attack", "block"].forEach((action) => {
                const spriteSet = themeConfig.players[pi][action] as SpriteSet;

                ["n", "ne", "e", "se", "s", "sw", "w", "nw"].forEach(
                    (direction) => {
                        const images = spriteSet[direction].images as string[];
                        const paths = images.map(
                            (image) => `/themes/${theme}/${image}`
                        );
                        assets.push(...paths);
                    }
                );
            });
        });

        // add background sprite
        themeConfig.scene.images.forEach((image) => {
            assets.push(`/themes/${theme}/${image}`);
        });

        // add sounds
        [
            "bgAudio",
            "attackAudio",
            "blockAudio",
            "collideAudio",
            "winAudio",
        ].forEach((audio) => {
            assets.push(`/themes/${theme}/${themeConfig[audio]}`);
        });
    });

    // return uniques only
    return [...new Set(assets)];
};
Enter fullscreen mode Exit fullscreen mode

This function gets called in the Service Worker and caches everything that's needed to run the fully-functioning game.

const cacheAssets = () => {
    const assets = [
        "/index.html",
        "/styles.css",
        "/main.js",
        "/assets/PressStart2P.woff2",
        ...getGameAssets(),
    ];

    caches.open(cacheName).then(function (cache) {
        cache.addAll(assets);
    });
};

channel.addEventListener("message", (event) => {
    switch (event.data.message) {
        case "cache-assets":
            cacheAssets();
            break;
    }
});
Enter fullscreen mode Exit fullscreen mode

What's that? A cache-assets message? Where does that come from? Why isn't it the install eventListener?

That's because I don't like the current state of PWA install prompts.

Custom Install Button

Chrome on Android shows you a big ugly install banner. It's not integrated with my design, it's unsolicited and its behavior isn't clear to the user. Chrome on Desktop does the same, but with a popup. Firefox on Android hides it in the browser menu, although at least it's clearly labeled as "Install". The worst offender is Safari (yeah, again). Why would you hide the install button in the share menu?

At least Chrome provides a way to implement my own install UX (Be aware that everything here is off spec. You might not want to do that because of ethical reasons). Their install prompt is triggered by event listeners, and I can hook into them. That lets me hide the prompt altogether and bind its events to a custom button. When the button is clicked, the PWA is installed and all its assets are with it.

window.addEventListener("appinstalled", () => {
    button.setAttribute("hidden", "hidden");
    deferredPrompt = null;
    channel.postMessage({ message: "cache-assets" });
});
Enter fullscreen mode Exit fullscreen mode

No unsolicited install prompt, no spamming the user's device with large downloads without warning, just an old-fashioned install button. Steve Jobs would be happy.

Conclusion

And now there's a game, fully written in typescript and rendered in a <canvas>, even fluently on all major browsers, and packaged inside a PWA. My future plans for it include more themes, more players, and remote multiplayer support, as an excuse to learn some WebRTC.

I had a lot of fun building the game logic and even more drawing the graphics. I definitely need to open Photoshop a lot more. Figuring out all the kinks can be a piece of work (looking at you, Safari) but pays off well in a learning experience.

If you'd like to contribute, here's Attacke! on GitHub.

. . . . . . . . . . . . . . . . . . . . . . . .