Subchapter 178.7
references/techniques.mdMarkdown27 KBView on GitHub
A comprehensive reference covering essential techniques for building web-based games, compiled from MDN Web Docs.
Async compilation allows JavaScript engines to compile asm.js code off the main thread during game loading and cache the generated machine code. This prevents recompilation on subsequent loads and gives the browser maximum flexibility to optimize the compilation process.
When a script is loaded asynchronously, the browser can compile it on a background thread while the main thread continues handling rendering and user interaction. The compiled code is cached so future visits skip recompilation entirely.
HTML attribute approach:
<script async src="file.js"></script>JavaScript dynamic creation (defaults to async):
const script = document.createElement("script");
script.src = "file.js";
document.body.appendChild(script);Important: Inline scripts are never async, even with the async attribute. They compile and run immediately:
<!-- This is NOT async despite the attribute -->
<script async>
// Inline JavaScript code
</script>Using Blob URLs for async compilation of string-based code:
const blob = new Blob([codeString]);
const script = document.createElement("script");
const url = URL.createObjectURL(blob);
script.onload = script.onerror = () => URL.revokeObjectURL(url);
script.src = url;
document.body.appendChild(script);The key insight is that setting src (rather than innerHTML or textContent) triggers async compilation.
A collection of strategies for improving how quickly web applications and games start up and become responsive, preventing the app, browser, or device from appearing frozen to users.
The core principle is avoiding blocking the main thread during startup. Work is offloaded to background threads (Web Workers), startup code is broken into small micro-tasks, and the main thread is kept free for user events and rendering. The event loop must keep cycling continuously.
1. Script Loading with defer and async
Prevent blocking HTML parsing:
<script defer src="app.js"></script>
<script async src="helper.js"></script>2. Web Workers for Heavy Processing
Move data fetching, decoding, and calculations to workers. This frees the main thread for UI and user events.
3. Data Processing
4. Resource Loading
preconnect, preload.5. Code Size and Compression
6. Perceived Performance
7. Emscripten Main Loop Blockers (for ported apps)
emscripten_push_main_loop_blocker();
// Establish functions to execute before main thread continues
// Create queue of functions called in sequence| Metric | Target |
|---|---|
| Initial content appearance | 1-2 seconds |
| User-perceptible delay | 50ms or less |
| Sluggish threshold | Greater than 200ms |
Users on older or slower devices experience longer delays than developers – always optimize accordingly.
WebRTC data channels let you send text or binary data over an active connection to a peer. In the context of games, this enables players to send data to each other for text chat or game state synchronization, without routing through a central server.
WebRTC establishes a peer-to-peer connection between two browsers. Once established, a data channel can be opened on that connection. Data channels come in two flavors:
Reliable Channels:
Unreliable Channels:
Audio provides feedback and atmosphere in web games. This technique covers implementing audio across desktop and mobile platforms, addressing browser differences and optimization strategies.
Two primary APIs are available:
<audio> element for basic audio playback.<audio> elements for simple, linear playback (background music without complex control).Combines multiple audio clips into a single file, playing specific sections by timestamp, borrowed from the CSS sprites concept.
HTML:
<audio id="myAudio" src="mysprite.mp3"></audio>
<button data-start="18" data-stop="19">0</button>
<button data-start="16" data-stop="17">1</button>
<button data-start="14" data-stop="15">2</button>
<button data-start="12" data-stop="13">3</button>
<button data-start="10" data-stop="11">4</button>
<button data-start="8" data-stop="9">5</button>
<button data-start="6" data-stop="7">6</button>
<button data-start="4" data-stop="5">7</button>
<button data-start="2" data-stop="3">8</button>
<button data-start="0" data-stop="1">9</button>JavaScript:
const myAudio = document.getElementById("myAudio");
const buttons = document.getElementsByTagName("button");
let stopTime = 0;
for (const button of buttons) {
button.addEventListener("click", () => {
myAudio.currentTime = button.dataset.start;
stopTime = Number(button.dataset.stop);
myAudio.play();
});
}
myAudio.addEventListener("timeupdate", () => {
if (myAudio.currentTime > stopTime) {
myAudio.pause();
}
});Priming audio for mobile (trigger on first user interaction):
const myAudio = document.createElement("audio");
myAudio.src = "my-sprite.mp3";
myAudio.play();
myAudio.pause();Load and synchronize separate audio tracks with precise timing.
Create audio context and load files:
const audioCtx = new AudioContext();
async function getFile(filepath) {
const response = await fetch(filepath);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
return audioBuffer;
}Track playback with synchronization:
let offset = 0;
function playTrack(audioBuffer) {
const trackSource = audioCtx.createBufferSource();
trackSource.buffer = audioBuffer;
trackSource.connect(audioCtx.destination);
if (offset === 0) {
trackSource.start();
offset = audioCtx.currentTime;
} else {
trackSource.start(0, audioCtx.currentTime - offset);
}
return trackSource;
}Handle autoplay policy in playback handlers:
playButton.addEventListener("click", () => {
if (audioCtx.state === "suspended") {
audioCtx.resume();
}
playTrack(track);
playButton.dataset.playing = true;
});For seamless transitions, sync new tracks to beat boundaries:
const tempo = 3.074074076; // Time in seconds of your beat/bar
if (offset === 0) {
source.start();
offset = context.currentTime;
} else {
const relativeTime = context.currentTime - offset;
const beats = relativeTime / tempo;
const remainder = beats - Math.floor(beats);
const delay = tempo - remainder * tempo;
source.start(context.currentTime + delay, relativeTime + delay);
}Use the PannerNode to position audio in 3D space:
Particularly useful for WebGL 3D games to tie audio to visual objects and the player’s viewpoint.
| Technique | Use When | Pros | Cons |
|---|---|---|---|
| Audio Sprites | Many short sounds, mobile | Reduces HTTP requests, mobile-friendly | Seeking accuracy reduced at low bitrates |
Basic <audio> | Simple linear playback | Broad support | Limited control, autoplay restrictions |
| Web Audio API | Dynamic music, 3D positioning, precise timing | Full control, real-time manipulation, sync | More complex code |
| Positional Audio | 3D immersive games | Realism, player immersion | Requires WebGL context awareness |
2D collision detection algorithms determine when game entities overlap or intersect based on their shape types (rectangle-to-rectangle, rectangle-to-circle, circle-to-circle, etc.). Rather than pixel-perfect detection, games typically use simple generic shapes called “hitboxes” that cover entities, balancing visual accuracy with performance.
Each algorithm checks the geometric relationship between two shapes. If any overlap is detected, a collision is reported. The approach varies by shape type.
Collision detection between two axis-aligned rectangles (no rotation). Detects collision by ensuring there is no gap between any of the 4 sides of the rectangles.
class BoxEntity extends BaseEntity {
width = 20;
height = 20;
isCollidingWith(other) {
return (
this.position.x < other.position.x + other.width &&
this.position.x + this.width > other.position.x &&
this.position.y < other.position.y + other.height &&
this.position.y + this.height > other.position.y
);
}
}Collision detection between two circles. Takes the center points of two circles and checks whether the distance between them is less than the sum of their radii.
class CircleEntity extends BaseEntity {
radius = 10;
isCollidingWith(other) {
const dx =
this.position.x + this.radius - (other.position.x + other.radius);
const dy =
this.position.y + this.radius - (other.position.y + other.radius);
const distance = Math.sqrt(dx * dx + dy * dy);
return distance < this.radius + other.radius;
}
}Note: The circle’s x and y coordinates refer to their top-left corner, so you must add the radius to compare their actual centers.
A collision algorithm that detects collisions between any two convex polygons. It works by projecting each polygon onto every possible axis and checking for overlap. If any axis shows a gap, the polygons are not colliding.
SAT is more complex to implement but handles arbitrary convex polygon shapes.
Testing every entity against every other entity is computationally expensive (O(n^2)). Games split collision detection into two phases:
Broad Phase – Uses spatial data structures to quickly identify which entities could be colliding:
Narrow Phase – Applies precise collision algorithms (AABB, Circle, SAT) only to the small list of candidates from the broad phase.
CSS for collision visualization:
.entity {
display: inline-block;
position: absolute;
height: 20px;
width: 20px;
background-color: blue;
}
.movable {
left: 50px;
top: 50px;
background-color: red;
}
.collision-state {
background-color: green !important;
}JavaScript collision checker and entity system:
const collider = {
moveableEntity: null,
staticEntities: [],
checkCollision() {
const isColliding = this.staticEntities.some((staticEntity) =>
this.moveableEntity.isCollidingWith(staticEntity),
);
this.moveableEntity.setCollisionState(isColliding);
},
};
const container = document.getElementById("container");
class BaseEntity {
ref;
position;
constructor(position) {
this.position = position;
this.ref = document.createElement("div");
this.ref.classList.add("entity");
this.ref.style.left = `${this.position.x}px`;
this.ref.style.top = `${this.position.y}px`;
container.appendChild(this.ref);
}
shiftPosition(dx, dy) {
this.position.x += dx;
this.position.y += dy;
this.redraw();
}
redraw() {
this.ref.style.left = `${this.position.x}px`;
this.ref.style.top = `${this.position.y}px`;
}
setCollisionState(isColliding) {
if (isColliding && !this.ref.classList.contains("collision-state")) {
this.ref.classList.add("collision-state");
} else if (!isColliding) {
this.ref.classList.remove("collision-state");
}
}
isCollidingWith(other) {
throw new Error("isCollidingWith must be implemented in subclasses");
}
}
document.addEventListener("keydown", (e) => {
e.preventDefault();
switch (e.key) {
case "ArrowLeft":
collider.moveableEntity.shiftPosition(-5, 0);
break;
case "ArrowUp":
collider.moveableEntity.shiftPosition(0, -5);
break;
case "ArrowRight":
collider.moveableEntity.shiftPosition(5, 0);
break;
case "ArrowDown":
collider.moveableEntity.shiftPosition(0, 5);
break;
}
collider.checkCollision();
});Tilemaps are a fundamental technique in 2D game development that constructs game worlds using small, regular-shaped images called tiles. Instead of storing large monolithic level images, the game world is assembled from a grid of reusable tile graphics, providing significant performance and memory benefits.
Core structure:
Special values (negative numbers, 0, or null) represent empty tiles.
For maps fitting entirely on screen:
for (let column = 0; column < map.columns; column++) {
for (let row = 0; row < map.rows; row++) {
const tile = map.getTile(column, row);
const x = column * map.tileSize;
const y = row * map.tileSize;
drawTile(tile, x, y);
}
}Convert between world coordinates (level position) and screen coordinates (rendered position):
// These functions assume camera points to top-left corner
function worldToScreen(x, y) {
return { x: x - camera.x, y: y - camera.y };
}
function screenToWorld(x, y) {
return { x: x + camera.x, y: y + camera.y };
}Key principle: Only render visible tiles to optimize performance. Apply the camera offset transformation during rendering.
Square Tiles (most common):
Isometric Tilemaps:
Multiple visual layers enable:
Example: A rock tile rendered on a separate layer over grass, sand, or brick backgrounds.
A separate grid for non-visual game logic:
The Gamepad API provides an interface for detecting and using gamepad controllers in web browsers without plugins. It exposes button presses and axis changes through JavaScript, allowing console-like control of browser-based games.
Two fundamental events handle the controller lifecycle:
gamepadconnected – fired when a gamepad is connected.gamepaddisconnected – fired when disconnected (physically or due to inactivity).Security note: User interaction with the controller is required while the page is visible for the event to fire (prevents fingerprinting).
Gamepad object properties:
| Property | Description |
|---|---|
id | String containing controller information |
index | Unique identifier for the connected device |
connected | Boolean indicating connection status |
mapping | Layout type (“standard” is the common option) |
axes | Array of floats (-1 to 1) representing analog stick positions |
buttons | Array of GamepadButton objects with pressed and value properties |
Basic setup structure:
const gamepadAPI = {
controller: {},
turbo: false,
connect() {},
disconnect() {},
update() {},
buttonPressed() {},
buttons: [],
buttonsCache: [],
buttonsStatus: [],
axesStatus: [],
};Button layout (Xbox 360):
const gamepadAPI = {
buttons: [
"DPad-Up", "DPad-Down", "DPad-Left", "DPad-Right",
"Start", "Back", "Axis-Left", "Axis-Right",
"LB", "RB", "Power", "A", "B", "X", "Y",
],
};Event listeners:
window.addEventListener("gamepadconnected", gamepadAPI.connect);
window.addEventListener("gamepaddisconnected", gamepadAPI.disconnect);Connection and disconnection handlers:
connect(evt) {
gamepadAPI.controller = evt.gamepad;
gamepadAPI.turbo = true;
console.log("Gamepad connected.");
},
disconnect(evt) {
gamepadAPI.turbo = false;
delete gamepadAPI.controller;
console.log("Gamepad disconnected.");
},Update method (called every frame):
update() {
// Clear the buttons cache
gamepadAPI.buttonsCache = [];
// Move the buttons status from the previous frame to the cache
for (let k = 0; k < gamepadAPI.buttonsStatus.length; k++) {
gamepadAPI.buttonsCache[k] = gamepadAPI.buttonsStatus[k];
}
// Clear the buttons status
gamepadAPI.buttonsStatus = [];
// Get the gamepad object
const c = gamepadAPI.controller || {};
// Loop through buttons and push the pressed ones to the array
const pressed = [];
if (c.buttons) {
for (let b = 0; b < c.buttons.length; b++) {
if (c.buttons[b].pressed) {
pressed.push(gamepadAPI.buttons[b]);
}
}
}
// Loop through axes and push their values to the array
const axes = [];
if (c.axes) {
for (const ax of c.axes) {
axes.push(ax.toFixed(2));
}
}
// Assign received values
gamepadAPI.axesStatus = axes;
gamepadAPI.buttonsStatus = pressed;
return pressed;
},Button detection with hold support:
buttonPressed(button, hold) {
let newPress = false;
if (gamepadAPI.buttonsStatus.includes(button)) {
newPress = true;
}
if (!hold && gamepadAPI.buttonsCache.includes(button)) {
newPress = false;
}
return newPress;
},Parameters:
button – the button name to listen for.hold – if true, holding the button counts as continuous action; if false, only new presses register.Usage in a game loop:
if (gamepadAPI.turbo) {
if (gamepadAPI.buttonPressed("A", "hold")) {
this.turbo_fire();
}
if (gamepadAPI.buttonPressed("B")) {
this.managePause();
}
}Analog stick input with threshold (prevent stick drift):
if (gamepadAPI.axesStatus[0].x > 0.5) {
this.player.angle += 3;
this.turret.angle += 3;
}Getting all connected gamepads:
const gamepads = navigator.getGamepads();
// Returns an array where unavailable/disconnected slots contain null
// Example with one device at index 1: [null, [object Gamepad]]A technique for rendering pixel art without blurriness on high-resolution displays by mapping individual image pixels to blocks of screen pixels without smoothing interpolation. Retro pixel art requires preserving hard edges during scaling, but modern browsers default to smoothing algorithms that blend colors and create blur.
The CSS image-rendering property controls how browsers scale images. Setting it to pixelated enforces nearest-neighbor scaling, which preserves the crisp, blocky look of pixel art instead of applying bilinear or bicubic smoothing.
Key CSS values:
pixelated – preserves crisp edges for pixel art.crisp-edges – alternative supported on some browsers.<img
src="character.png"
alt="pixel art character, upscaled with CSS, appearing crisp" />img {
width: 48px;
height: 136px;
image-rendering: pixelated;
}Set the canvas width/height attributes to the original pixel art resolution, then use CSS width/height for scaling (e.g., 4x scale: 128 pixels to 512px CSS width).
<canvas id="game" width="128" height="128">A cat</canvas>canvas {
width: 512px;
height: 512px;
image-rendering: pixelated;
}const ctx = document.getElementById("game").getContext("2d");
const image = new Image();
image.onload = () => {
ctx.drawImage(image, 0, 0);
};
image.src = "cat.png";For non-integer scale factors, image pixels must align to canvas pixels at integer multiples:
const ctx = document.getElementById("game").getContext("2d");
ctx.scale(0.8, 0.8);
const image = new Image();
image.onload = () => {
// Correct formula: dWidth = sWidth / xScale * n (where n is an integer)
ctx.drawImage(image, 0, 0, 128, 128, 0, 0, 128 / 0.8, 128 / 0.8);
};
image.src = "cat.png";When using drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight):
dWidth must equal sWidth / xScale * ndHeight must equal sHeight / yScale * mn and m are positive integers (1, 2, 3, etc.)devicePixelRatio misalignment: When devicePixelRatio is not an integer (e.g., at 110% browser zoom), pixels may render unevenly because CSS pixels cannot perfectly map to device pixels. This creates a non-uniform appearance without an easy solution.
aria-label attributes on canvas elements for accessibility.This file