A platformer with an AI agent

Canvas, the game loop, and directing an agent

Before we start…

Sign up for the JS mid-term:

JS mid-term sign-up form

What we’re building

The finished game · arrows to move, Space to jump
<canvas width="960" height="540"></canvas>

Press Run to see the result

Console

How often does it redraw?

How often does the game redraw the whole screen?

Today

  1. By hand: a box that moves
  2. Player and jump
  3. Level and collision
  4. Sprites and animation
  5. Camera
  6. Your vote: two features
  7. Playtest and bug hunt

What you need

Check your setup

bash
git --version
node --version
python --version        # on Windows also try: py --version

Open index.html with Live Server (Go Live in the status bar). You and the agent’s browser see the same page at the same address.

Canvas

Pixels, not elements

DOM

  • <div class="box"> stays an element
  • You can find it, restyle it, listen to it
  • The browser redraws it for you

Canvas

  • fillRect paints pixels, then forgets
  • There is no box to find or click
  • To move it: clear, then paint again

Coordinates

(0, 0)xyxywhctx.fillRect(x, y, w, h)

First drawing

Rectangle, circle, text
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");

// x, y, width, height
ctx.fillStyle = "tomato";
ctx.fillRect(40, 60, 120, 80);

// centre x, centre y, radius, full turn
ctx.fillStyle = "steelblue";
ctx.beginPath();
ctx.arc(300, 100, 50, 0, Math.PI * 2);
ctx.fill();

ctx.fillStyle = "black";
ctx.font = "24px sans-serif";
ctx.fillText("Hello, canvas", 40, 220);

Press Run to see the result

Console

Predict

Every frame we move the box 2 px to the right and paint it again, but we never clear the canvas. What do we see?

requestAnimationFrame

js
function gameLoop() {
  update();
  draw();
  requestAnimationFrame(gameLoop); // call me again before the next repaint
}

requestAnimationFrame(gameLoop);
  • Runs once per screen refresh: 60 times a second on a 60 Hz screen, 120 on a 120 Hz one
  • Pauses while the tab is hidden
  • Never while (true): the page would freeze, because the browser never gets to repaint

Predict

Each frame runs box.x += 2. On a 120 Hz monitor, compared with 60 Hz, the box moves…

Delta time

js
const now = performance.now();
const dt = now - last;          // ms since the previous frame
last = now;

box.x += box.vx * dt;           // vx in px/ms: 0.3 px/ms = 300 px/s

60 Hz: dt ≈ 16.7 ms. 120 Hz: dt ≈ 8.3 ms. Both move the box 300 px per second.

if (dt > 100) return; After a hidden tab, dt can be several seconds. Skip that frame, or the player teleports through the floor.

Per frame vs per millisecond

Tick 'slow frames' and watch the red box
<label><input type="checkbox" id="slow"> Slow frames (every 3rd frame only)</label>
<button id="reset">Line up</button>
<canvas width="640" height="180"></canvas>

Press Run to see the result

Console

The game loop

The game loop

Inputread the keys objectupdate(dt)move, collide, scoredraw()clear, paint the stateWaitfor the next frame

One lap is one frame: about 16.7 ms at 60 Hz.

In our code

js
function update(dt) {   // input, physics, collisions
  // changes state.player, state.camera, ...
}

function draw() {       // clear, then paint the state
  // reads state, never changes it
}

function gameLoop() {
  const now = performance.now();
  update(now - last);
  last = now;
  draw();
  requestAnimationFrame(gameLoop);
}

Stage 0 · By hand, no agent

  1. Draw one box with fillRect
  2. Add gameLoop() with requestAnimationFrame; move the box by vx * dt
  3. Run it without clearRect, then add clearRect at the top of draw()
  4. Commit: stage 0: game loop

Works when: the box crosses the screen, wraps around, leaves no smear, and moves at the same speed on any monitor.

Where does it go?

The camera follows the player. Which function changes camera.x?

Working with an AI agent

What a language model does

  • It continues text: given everything so far, it predicts the next small piece, then the next
  • It learned from a huge amount of code and writing, so common patterns come out well
  • It cannot run your code by itself. An agent can, through tools
  • It sounds just as sure when it is wrong

Same prompt, different code

  • Each piece is picked with some randomness, so one prompt can produce many different programs
  • Your game will not match mine line by line, or your neighbour’s
  • Judge it by what it does: does the player land, stop at walls, pick up coins?
  • If a name or structure matters to you, say so in the prompt or the rules file

Predict

Two of you send the exact same prompt to the same model, in the same project. The code you get back is…

It only knows its context

  • Context: your prompt, the files it opened, the rules file, the conversation so far
  • Whatever is missing gets guessed: “a platformer” may become Phaser, “the coin sprite” may get the wrong coordinates
  • That is why the project has AGENTS.md and assets/ASSETS.md
  • Context has a size limit, and long conversations drift: start a new conversation for each stage

Chat vs agent

  • Chat: you paste code in, copy answers out, and run everything yourself
  • Agent: works inside your project folder. It reads files, edits them, runs commands and opens the page
  • You: decide what to build, approve the plan and the commands, read the diff

An agent is a loop too

Planread files, write stepsActedit files, run commandsCheckread output, open the page

It goes round until the task is done or it gets stuck.

Antigravity, the parts we use

  • Planning mode: it writes a plan first and edits after (Fast mode skips the plan)
  • Implementation plan: what it intends to change; comment on any line before it starts
  • Walkthrough: what it changed and how it checked, with screenshots
  • Permissions: terminal commands wait for your approval
  • Browser: it can open and play the game; allowed on Live Server’s 127.0.0.1 only

AGENTS.md: rules it reads every time

markdown
## Stack
- Vanilla JavaScript, HTML and CSS only. No libraries, frameworks or game
  engines (no Phaser, PixiJS, Kaboom), no npm, no bundler, no TypeScript.

## Code style
- Keep all game data in one `state` object.
- Logic goes in update(dt), rendering in draw(). The loop is gameLoop().

## Working style
- Make the smallest change that does what was asked.

Changed the file? Start a new conversation so it is read again.

A prompt that works

Vague

add enemies

The agent has to guess how many, where, how they move, what touching one does, and which sprite.

Specific

“Add a patrolling enemy as ‘e’ in the level strings. It walks back and forth and turns around at walls and at platform edges. Touching it from the side restarts the level; landing on its head removes it and bounces the player up.”

It says where, how it moves, and what touching it does.

Every stage, the same steps

  1. Predict: what should happen when it works?
  2. Prompt: say what, where, and what not to touch
  3. Read the plan before approving it
  4. Run the game
  5. Read the diff: find the two or three lines that matter
  6. Commit

Git is the undo button

bash
git add -A
git commit -m "stage 2: level and collision"

git diff          # what changed since the last commit
git restore .     # throw away every uncommitted change
  • Commit every time the game works
  • Two fix attempts failed? Restore, then ask again more precisely

Bug reports that get fixed

What I did, what I expected, what happened.

“When I hold right against a wall in mid-air, the player sticks to it instead of sliding down. Explain why, then fix it.”

  • “Collision is broken” makes it guess which collision and how
  • “Explain why, then fix it” makes it find the cause before it changes code

Stay in charge

  • Read a terminal command before you approve it
  • Never paste passwords, tokens or API keys into a prompt
  • No new packages or downloads: everything we need is in the project
  • Read the diff before you commit; you are the one who hands it in
  • If it rewrites the whole file for a small change, reject and ask again

Build it, stage by stage

Stage 1 · Keys

Click here, then hold some keys
const keys = {};
const out = document.querySelector("#out");

window.addEventListener("keydown", (event) => {
  keys[event.code] = true;
});
window.addEventListener("keyup", (event) => {
  keys[event.code] = false;
});

// Every frame, like update(dt): read the keys
function show() {
  const held = Object.keys(keys)
    .filter((code) => keys[code]);
  out.textContent = "Held: " + held.join(", ");
  requestAnimationFrame(show);
}

show();

Press Run to see the result

Console

Stage 1 · Tell the agent

text
Replace the moving box with a player: a 36x48 rectangle standing on
a flat floor at the bottom of the canvas. Left/right arrow keys move
it, Space or Up jumps, and gravity pulls it back down. Track pressed
keys in a `keys` object updated by keydown/keyup listeners, and read
that object inside update(dt). Only allow jumping while the player is
on the ground. Use dt for all movement.

Works when: arrows move it smoothly, Space jumps only from the ground, and the page does not scroll.

Stage 2 · The level is a grid

js
const level = [
  "..........",
  "...###....",
  ".P......c.",
  "##########"
];
const TILE = 54;                      // an 18 px tile drawn 3 times bigger

level[row][col];                      // "#" ground, "." empty
const col = Math.floor(x / TILE);     // pixel position to tile index
  • The same idea as pixels[row][col] in the Pixel Art Editor
  • Move along x and fix x, then move along y and fix y. Do both at once and a corner can’t tell a wall from a floor

Stage 2 · Do two boxes overlap?

Move the mouse over the canvas
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");
const wall = { x: 180, y: 70, w: 120, h: 120 };
const box = { x: 40, y: 100, w: 60, h: 60 };

// Overlap on x AND overlap on y
function overlaps(a, b) {
  return a.x < b.x + b.w &&
    a.x + a.w > b.x &&
    a.y < b.y + b.h &&
    a.y + a.h > b.y;
}

function draw() {
  ctx.clearRect(0, 0, 420, 260);
  ctx.fillStyle = "#94a3b8";
  ctx.fillRect(wall.x, wall.y, wall.w, wall.h);
  ctx.fillStyle = overlaps(box, wall) ? "crimson" : "seagreen";
  ctx.fillRect(box.x, box.y, box.w, box.h);
}

canvas.addEventListener("mousemove", (event) => {
  box.x = event.offsetX - box.w / 2;
  box.y = event.offsetY - box.h / 2;
  draw();
});

draw();

Press Run to see the result

Console

Stage 2 · Tell the agent

text
Add a level stored as an array of strings in a `level` constant, one
character per tile: '#' is solid ground, '.' is empty, 'P' is the
player's start. Tiles are 54x54 px on screen. Draw solid tiles as
brown rectangles for now. Make the player collide with solid tiles on
every side: resolve horizontal and vertical movement separately so
the player can land on platforms, bump its head, and stop at walls.

Paste the level we design together under the prompt. Still rectangles: sprites come next.

Works when: it lands on platforms, bumps its head, stops at walls, and does not stick to a wall in mid-air.

Stage 3 · Sprite sheets

Art: Pixel Platformer by Kenney, CC0: free to use for anything. We credit it anyway.

Kenney Pixel Platformer character sheet: 9 by 3 frames of 24 by 24 pixels

One image, 27 frames of 24×24 px. Frames 0 and 1: our player.

Frames 0 and 1, every 150 ms
<canvas width="400" height="200"></canvas>

Press Run to see the result

Console

Stage 3 · Tiles come from a sheet too

Kenney Pixel Platformer tile sheet: 20 by 9 tiles of 18 by 18 pixels
  • 20 × 9 tiles of 18×18 px, numbered left to right, top to bottom
  • assets/ASSETS.md lists the index of every tile we use: grass 21–23, dirt 121–123, coin 151
  • Agents guess sprite coordinates badly; this file removes the guessing

Stage 3 · drawImage with 9 arguments

js
ctx.drawImage(
  sheet,             // the whole sprite sheet
  sx, sy, 24, 24,    // source: which 24×24 frame to cut out
  x, y, 72, 72       // destination: where, and how big, on the canvas
);

const sx = (index % cols) * 24;             // index 1 on a 9-column sheet: sx = 24
const sy = Math.floor(index / cols) * 24;   //                                sy = 0
  • The sprites face left. To face right: save, translate, scale(-1, 1), draw, restore
  • Images load asynchronously: start the loop only after every sheet has loaded

Stage 3 · Why is it blurry?

The same frame, drawn 6 times bigger
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");
const sheet = new Image();

sheet.onload = () => {
  ctx.font = "16px monospace";
  ctx.imageSmoothingEnabled = true;
  ctx.drawImage(sheet, 0, 0, 24, 24, 10, 30, 144, 144);
  ctx.fillText("smoothing on", 10, 20);

  ctx.imageSmoothingEnabled = false;
  ctx.drawImage(sheet, 0, 0, 24, 24, 190, 30, 144, 144);
  ctx.fillText("smoothing off", 190, 20);
};
sheet.src = "/slides/canvas-platformer/tilemap-characters_packed.png";

Press Run to see the result

Console

Stage 3 · Tell the agent

text
Replace the rectangles with sprites from assets/, using the file
names, frame size and tile indices listed in assets/ASSETS.md. Draw
tiles and the player with the 9-argument drawImage and a 3x scale.
Wait until every image has loaded before starting the game loop.
Animate the walk cycle while the player moves, timing frames with dt,
and flip the sprite horizontally when moving right.

Works when: the art is crisp, the player faces the way it walks, the walk cycle is not racing, and its feet touch the ground.

Stage 4 · Camera

level, about three screens widecanvascamera.x
  • ctx.translate(-camera.x, 0) shifts everything drawn after it
  • The player’s x stays a world coordinate. The camera never moves the player, only the view

Predict

The score must stay in the top-left corner while the level scrolls. Where do we draw it?

Stage 4 · draw() with a camera

js
function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  drawBackground();                      // screen coordinates

  ctx.save();
  ctx.translate(-Math.round(state.camera.x), 0);
  drawLevel();                           // world coordinates
  drawCoins();
  drawPlayer();
  ctx.restore();

  drawScore();                           // screen coordinates again
}

Stage 4 · Tell the agent

text
Make the level about three screens wide and add a camera that follows
the player horizontally. Implement it with ctx.translate inside
save/restore in draw(), clamp it so it never shows past the level's
edges, and only draw tiles that are currently on screen.

Works when: walking right scrolls smoothly, the camera stops at both ends of the level, and collision still works everywhere.

Stage 5 · Your vote

Which feature should the agent add next? The top two win.

Stage 5 · Coins and a score

text
Add coins as 'c' characters in the level strings, drawn with the coin
sprite from assets/ASSETS.md. When the player overlaps a coin, remove
it and add 1 to state.score. Draw the score in the top-left corner
with fillText so it stays fixed on screen when the camera moves.

Works when: each coin counts once, and the score stays in the corner while the level scrolls.

Stage 5 · A patrolling enemy

text
Add a patrolling enemy as 'e' in the level strings, drawn with the
crawler frames from assets/ASSETS.md. It walks back and forth and
turns around at walls and at platform edges. Touching it from the
side restarts the level; landing on its head removes it and bounces
the player up.

Works when: it never walks off a platform, a stomp removes it, and a side hit restarts the level.

Stage 5 · Double jump

text
Let the player jump once more while in the air. The extra jump is
available again after landing. Keep reading input from the existing
keys object, and make one key press give at most one jump.

Works when: two jumps work in the air, a third does not, and holding Space does not jump twice.

Stage 5 · A moving platform

text
Add a moving platform as '=' in the level strings, drawn with the
one-tile-thick platform tiles from assets/ASSETS.md. It moves left
and right and turns around at walls. A player standing on it moves
with it.

Works when: the player rides it without sliding off or sinking in, and can still jump off it.

Stage 5 · Title and game-over screens

text
Add a title screen that waits for Enter, and a game-over screen when
the player falls into a pit, which waits for Enter to restart. Keep
the current screen in state.screen, and check it in update(dt) and
draw().

Works when: the game starts only after Enter, a fall shows game over, and Enter restarts a clean level.

Stage 6 · Playtest and bug hunt

  • Play it on the board; call out anything that looks wrong
  • Turn each callout into a bug report: what I did, what I expected, what happened
  • One fix per prompt, then commit

Then let the agent play it:

text
Open http://127.0.0.1:5500, play through the level, and report
anything that looks wrong, with screenshots.

Who decided what?

The level, the features, the bug reports and the numbers were ours. The typing was the agent’s.

Everything we added today lives inside update and draw.