Skip to main content
Adapted, not copied

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 checkCoinPickups instead of just flagging collected.
  • Add a simple camera: only draw the portion of the level near the player, so levels can be wider than the visible canvas.
 

🌟 Join the JavaScript360 community

Connect with fellow JS developers, and get free interview prep, React challenges, and DSA guides.

Join on WhatsApp →