Original notes for JavaScript360.org — project concept inspired by Eloquent JavaScript (4th ed.) by Marijn Haverbeke, built here with an original level design, entities, and code. See the full attribution note.
Chapter 16 · Project: A Platform Game
Estimated time: ~40 minutes · Chapter type: Guided project
We're going to build the core engine behind a small side-scrolling platform game — not the full game (that would take a book of its own), but the actual architecture: how a level becomes data, how the game loop works, and how collisions get detected. These same ideas scale directly up to real game engines.
Step 1: a level as plain text
Instead of hand-writing entity positions in code, we describe a level as a grid of characters — easy to sketch, easy to edit, and completely decoupled from the code that runs it:
const levelPlan = [
' ',
' x ',
' x o o ',
' x ######### ',
' x # # x ',
' #############x ',
].join('\n');
// Legend: '#' = ground, 'x' = wall, 'o' = coin, ' ' = empty space, '@' = player start (added below)
Step 2: parsing the plan into entities
A Level class turns the character grid into two things: a grid of static background tiles, and a list of dynamic actors (things that move or can be interacted with):
const actorChars = { '@': 'Player', 'o': 'Coin' };
class Level {
constructor(plan) {
const rows = plan.trim().split('\n').map((row) => [...row]);
this.height = rows.length;
this.width = rows[0].length;
this.actors = [];
this.rows = rows.map((row, y) => {
return row.map((ch, x) => {
const actorType = actorChars[ch];
if (actorType) {
this.actors.push({ type: actorType, x, y, collected: false });
return ' '; // the background tile under an actor is just empty space
}
return ch === 'x' || ch === '#' ? ch : ' ';
});
});
}
tileAt(x, y) {
if (x < 0 || x >= this.width || y < 0 || y >= this.height) return 'x'; // treat out-of-bounds as solid
return this.rows[y][x];
}
}
Separating static background (walls, ground — things you collide with but never change) from actors (things with independent state — position, whether a coin's been collected) is the single most important design decision in this whole project. It's what lets the collision and update logic below stay simple.
Step 3: the game loop
A game "runs" by repeatedly: reading input, updating state based on elapsed time, and drawing the result — dozens of times per second, driven by requestAnimationFrame, which asks the browser to call your function right before the next repaint:
function runGameLoop(update, draw) {
let lastTime = null;
function frame(time) {
if (lastTime !== null) {
const deltaSeconds = (time - lastTime) / 1000;
update(deltaSeconds);
draw();
}
lastTime = time;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
Using deltaSeconds (the actual elapsed time since the last frame) rather than assuming a fixed frame rate is what keeps movement speed consistent across different devices and refresh rates — a player should move at the same real-world speed whether the game runs at 60fps or 144fps.
Step 4: moving the player and detecting collisions
Position and velocity are simple numbers; each frame, we compute where the player would move, then check whether that new position overlaps a solid tile before committing to it:
const GRAVITY = 30;
const JUMP_SPEED = 12;
const MOVE_SPEED = 6;
function updatePlayer(player, level, keys, deltaSeconds) {
player.vy += GRAVITY * deltaSeconds; // gravity constantly pulls downward
let newX = player.x + (keys.right - keys.left) * MOVE_SPEED * deltaSeconds;
if (!isBlocked(level, newX, player.y)) player.x = newX;
let newY = player.y + player.vy * deltaSeconds;
if (isBlocked(level, player.x, newY)) {
player.vy = 0; // hit the ground (or ceiling) — stop falling/rising
player.onGround = newY > player.y;
} else {
player.y = newY;
player.onGround = false;
}
if (keys.jump && player.onGround) {
player.vy = -JUMP_SPEED;
}
}
function isBlocked(level, x, y) {
return level.tileAt(Math.floor(x), Math.floor(y)) === '#' || level.tileAt(Math.floor(x), Math.floor(y)) === 'x';
}
Checking the X and Y movement separately (rather than as one diagonal step) is a deliberate simplification: it means a player sliding into a wall while falling still falls correctly instead of getting stuck, because the vertical check doesn't care whether the horizontal one succeeded.
Step 5: collecting coins
Actor-vs-actor collision (the player touching a coin) is simpler than actor-vs-terrain — a basic axis-aligned bounding box (AABB) overlap check is enough:
function overlaps(a, b, size = 1) {
return a.x < b.x + size && a.x + size > b.x && a.y < b.y + size && a.y + size > b.y;
}
function checkCoinPickups(player, level) {
for (const actor of level.actors) {
if (actor.type === 'Coin' && !actor.collected && overlaps(player, actor)) {
actor.collected = true;
}
}
}
Step 6: drawing
Drawing is the one place we reach ahead to Chapter 17's canvas API — for now, the important part is that draw only reads state, it never changes it. Keeping "update" (changes state) and "draw" (reads state, produces pixels) strictly separate is what makes a game loop debuggable — you can always reason about the two halves independently.
function draw(ctx, level, player) {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
const TILE = 20;
for (let y = 0; y < level.height; y++) {
for (let x = 0; x < level.width; x++) {
if (level.tileAt(x, y) !== ' ') {
ctx.fillStyle = '#555';
ctx.fillRect(x * TILE, y * TILE, TILE, TILE);
}
}
}
for (const actor of level.actors) {
if (actor.type === 'Coin' && !actor.collected) {
ctx.fillStyle = 'gold';
ctx.fillRect(actor.x * TILE + 4, actor.y * TILE + 4, TILE - 8, TILE - 8);
}
}
ctx.fillStyle = 'crimson';
ctx.fillRect(player.x * TILE, player.y * TILE, TILE, TILE);
}
What this project actually taught you
- Representing a level as data (a grid + a list of actors) rather than hard-coded drawing calls
- A frame-rate-independent game loop using elapsed time (
deltaSeconds) - Basic collision detection: axis-separated for terrain, AABB overlap for actor-to-actor
- Strictly separating update (mutate state) from draw (render state) — a pattern that generalizes far beyond games, to any interactive UI
Extend it yourself
- Add a second enemy actor type that moves back and forth and ends the level (or resets the player) on contact.
- Track and display a coin counter, incrementing it in
checkCoinPickupsinstead of just flaggingcollected. - Add a simple camera: only draw the portion of the level near the player, so levels can be wider than the visible canvas.