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));
➜ attacke git:(main) yarn build
yarn run v1.22.10
$ ./esbuild.js
✨ Done in 0.47s.
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>
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
}
}
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
.
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();
});
}
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++;
}
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;
};
//...
}
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.
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
);
}
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;
}
My little fighting game is a dancing game now!
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;
};
// ...
}
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;
}
});
});
…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();
}
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();
}
}
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
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;
Now we have our first glance at some actual gameplay!
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;
});
}
collide()
now gets called in the game loop together with move()
, turn()
and their friends, so every frame has a poll for collision detection.
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.
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.
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.
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;
};
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);
}
Great, we can now animate things! Getting this to work released a huge chunk of endorphins in me.
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
};
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
);
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.
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.
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];
}
And finally, I'll use this.theme.config.turnSprites
inside the character class to toggle between turn and direction-based themes.
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();
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();
});
}
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",
}
…and invoke them with:
this.audio.play(this.theme.config.collideAudio);
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;
}
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);
}
pressButton
and releaseButton
send custom events, which I can use in the character class and expand my input methods to recognize gamepads.
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.
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);
});
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?
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;
});
}
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]);
});
});
});
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;
}
}
The progress information gets mapped to a <progress>
element. Whenever it reaches 100% I fade in the <canvas>
and start the game.
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>
.
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.
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"
},
...
]
}
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");
});
}
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.
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)];
};
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;
}
});
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" });
});
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.
Top comments (21)
This is awesome Daniel, I will surely contribute a few features soon.
Awesome! Looking forward to it :)
This is cool. I skimmed the article and noticed one thing about the requestAnimationFrame. I think that the fps is contingent upon the refresh rate of the monitor - whatever load you're putting behind it.
So, without a load at all, on a 120Hz monitor, I believe you would get 120 fps.
I'd be interested in knowing if this is incorrect.
It is. My external display maxes out at 75Hz, my Macbook does 120Hz. Browsers won't go higher than 60fps either way (30 on MacOS with energy saver). That is also true for all CSS animations, GPU-acceleratet or not.
Seems that Chrome is an exception to this rule:
As a test, I opened this site testufo.com/framerates-versus using Chrome on my Macbook M1 Pro, in a dual-screen configuration, the other monitor running at an apparent 60 fps.
The tests were measured by taking the window and moving from one screen to the other w/o refresh, while the M1 was plugged in (I didn't measure on battery).
[120 fps] dev-to-uploads.s3.amazonaws.com/up...
[60 fps] dev-to-uploads.s3.amazonaws.com/up...
Safari and Firefox maintained 60 fps for both, even if doing a hard refresh.
I skimmed this post to find the link to begin with: bugs.webkit.org/show_bug.cgi?id=17...
agree !
Freaking brilliant thanks for the great inspiration you actually gave me. I grew m uo to feel alone and youreminded me with this post why i love it
Congratulations and well done!
You deserve a medal 🥇
Also nice diagram 👍
💯 Lighthouse!?
When someone bring me that metrics I've always question how Lighthouse calculate those scores 😆
For the next step maybe you want try using ECS. Here some list
Any ECS library should be fine (except ECSY). Speaking of WebRTC, here an example combining ECS with WebRTC
Lihhthouse scores are easier to get to 100 when you try to not mess up things instead of adding performance features :)
I haven't heard of ECS yet, but I'm no real game dev either. It sounds like I'm already halfway there. It would mean e.g. to put all positioning data into its own structure, right? I can see how that would be beneficial in games with a high number of players, but how would that pattern be batter than what I have now with only a handful of players?
Bullet-hell is one type of game that work best when using ECS even though it's single player. Every type of bullets and obstacles are modeled as ECS components. For example modeling them based on the effect and damage then we can model it as component fire, ice, poison, and physical. Ice will have parameter
slow
down in percent and icedamage
per hit. Poison will haverandom
move chance and poisondamage
per second. And so on. After that you just need to put each logic/implementation in different ECS systems.Even if the game only has small amount of entities, it can still benefit from how easy the data can be serialized. Make it suitable in multiplayer game for states synchronization.
agree !
Wow thats so cool! TypeScript and JavaScript can be used to make serious games. Like Danger Crew on Steam which was made with a JavaScript tech stack.
Cool. I will go read with more calm it tutorial.
Congratulations!
Why setInterval and not RAF? Nice article btw!
I did use
requestAnimationFrame
, which is the whole reason behind the frame skip mechanic.Awesome post Daniel. I can't deny this fact that this post actually made me amaze. Keep going ! You earned a new follower 🤗.
One of the best article on this platform, really well done, congrats!
Cool stuff
That was a really nice to read, have fun with WebRTC ;)