Ugrás a tartalomra

Practice 6 · 2026-10-12 · 10 perc olvasás · Forráskód →

Canvas Platformer with an AI Agent

TartalomMutasd
  1. Introduction
  2. Project setup
  3. 1. Canvas basics
  4. 2. Stage 0: the game loop by hand
  5. 3. Working with the agent
  6. 4. Stage 1: player and jump
  7. 5. Stage 2: level and collision
  8. 6. Stage 3: sprites and animation
  9. 7. Stage 4: camera
  10. 8. Complete stage 4 script.js
  11. 9. Experiments: continue with the agent
  12. Common mistakes
  13. Self-check
  14. Task

Introduction

Practice 6 was an experiment. We built a pixel-art platformer on a <canvas>. We typed the first 20 lines by hand, and from then on an AI coding agent (Antigravity) wrote the code while we decided what it should build. Canvas and agents are not exam material. The two goals were to understand that every game is a loop that updates and draws, and to see what directing an agent well looks like.

Both groups typed the moving box by hand, then took the agent through four stages: a player that jumps, a level with collision, sprites, and a scrolling camera. This article explains each stage with code from the classroom files, points out where Monday’s and Tuesday’s agents solved the same problem differently, and ends with the complete stage 4 script.js. Coins, enemies and the other features we did not reach are listed at the end as experiments to try yourself.

Every agent stage followed the same steps:

text
Predict → prompt → read the plan → run → read the diff → commit

The slides from class are at /en/slides/canvas-platformer. They include live demos for most sections below.

Project setup

The starter folder contains:

  • index.html: one <canvas width="960" height="540"> and a plain <script src="script.js">
  • script.js: only the two lines that select the canvas and its 2D context
  • AGENTS.md: the rules the agent reads in every conversation
  • assets/: three Kenney sprite sheets (CC0) and ASSETS.md, which lists every sprite index we use

Open index.html with Live Server. To follow along with an agent you also need Git, Node.js and Python 3. Python is not used as the web server; agents run small helper scripts with it. Put the folder under Git before the first prompt and commit after every stage that works.

1. Canvas basics

A canvas is pixels, not elements. fillRect paints a rectangle and then forgets it: there is no box to querySelector, restyle or click afterwards. To move something, clear the canvas and paint it again in its new place.

js
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d"); // the "pen": every drawing call goes through it

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

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

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

The origin (0, 0) is the top-left corner, and y grows downward. That is why gravity later adds to the vertical speed.

2. Stage 0: the game loop by hand

Both groups typed this without the agent. Everything the agent added later went inside update and draw.

js
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");

// All game data lives in one object. The canvas does not remember the box.
const state = {
    box: { x: 0, y: 250, w: 40, h: 40, vx: 0.3 } // vx in px/ms = 300 px per second
};

// update(dt) changes the state and never draws
function update(dt) {
    if (dt > 100) return; // tab was hidden; skip the jump
    state.box.x += state.box.vx * dt;
    if (state.box.x > canvas.width) state.box.x = -state.box.w;
}

// draw() reads the state and never changes it
function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = "tomato";
    ctx.fillRect(state.box.x, state.box.y, state.box.w, state.box.h);
}

let last = performance.now();

// One frame: measure the time, update, draw, ask for the next frame
function gameLoop() {
    const now = performance.now();
    const dt = now - last; // ms since the previous frame
    last = now;
    update(dt);
    draw();
    requestAnimationFrame(gameLoop);
}

requestAnimationFrame(gameLoop);

Four ideas came out of these lines:

  • requestAnimationFrame asks the browser to call gameLoop again just before the next screen refresh. It is not a while (true) loop: that would freeze the page, because the browser would never get a chance to repaint. It also pauses while the tab is hidden.
  • Delta time. Our first version moved the box with box.x += 2. A 120 Hz monitor calls the loop twice as often as a 60 Hz one, so the box moved twice as fast. Measuring dt and storing the speed in px/ms fixes this: at 60 Hz dt is about 16.7 ms, at 120 Hz about 8.3 ms, and both move the box 300 px per second.
  • The dt > 100 guard. After you switch back to the tab, dt can be several seconds long. Skipping that one frame stops the box (and later the player) from teleporting, possibly straight through the floor.
  • clearRect. We ran the loop without it first, and the box smeared into a long bar. The old pixels stay until something paints over them.

3. Working with the agent

AGENTS.md

An agent knows only what is in its context: your prompt, the files it opens, the rules file and the conversation so far. AGENTS.md is a rules file the agent reads in every conversation, so we don’t have to repeat our expectations in each prompt. An excerpt:

text
## Stack
- Vanilla JavaScript, HTML and CSS only. No libraries, frameworks or game
  engines (no Phaser, PixiJS, Kaboom), no npm, no bundler, no TypeScript.
- Do not download or install anything. All assets already exist in assets/;
  file names, frame sizes and tile indices are in assets/ASSETS.md.

## Code style
- Keep all game data in one `state` object.
- Logic goes in update(dt), rendering in draw(). The loop is gameLoop(),
  driven by requestAnimationFrame, with dt in milliseconds from
  performance.now(). Keep `if (dt > 100) return;` at the top of update.

## Working style
- Make the smallest change that does what was asked. Don't add features
  nobody requested, and don't rewrite script.js from scratch.

Both classroom files follow these rules: one state object, UPPER_CASE constants, update/draw/gameLoop, and a one-line comment above each function. Without the file, each prompt would have been a gamble on a framework, a code style or a rewrite. If you edit the file, start a new conversation so the agent reads it again.

Prompts, bug reports and Git

A vague prompt like add enemies makes the agent guess how many enemies there are, where they go, how they move, what touching one does, and which sprite to use. A specific prompt answers those questions itself. Each stage below shows the prompt we used.

When something breaks, write a bug report that says what you did, what you expected, and what happened:

text
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” leaves the agent guessing which collision and how. “Explain why, then fix it” makes it find the cause before it changes code.

Git is the undo button. Commit every time the game works. If two fix attempts fail, throw away the changes with git restore . and ask again more precisely.

4. Stage 1: player and jump

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.

The listeners don’t move the player. They only record which keys are held:

js
window.addEventListener("keydown", (e) => {
    if (["Space", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.code)) {
        e.preventDefault();
    }
    state.keys[e.code] = true;
});

window.addEventListener("keyup", (e) => {
    state.keys[e.code] = false;
});

update(dt) reads that object every frame. Moving the player inside keydown would stutter: after the first press there is a pause before key repeat starts, and holding two keys at once gets messy.

Jumping sets an upward (negative) speed, and gravity pulls the speed back down every frame:

js
if (state.player.onGround && (state.keys["ArrowUp"] || state.keys["Space"])) {
    state.player.vy = JUMP_SPEED; // -0.65 px/ms: negative is up
    state.player.onGround = false;
}

state.player.vy += GRAVITY * dt; // 0.0015 px/ms², so the speed changes by dt too

The onGround flag is what stops infinite jumping. Collision sets it in the next stage.

5. Stage 2: level and collision

We designed the level together as an array of strings, one character per tile:

js
const level = [
    "......................................................",
    "...P..................................................",
    "...###...........................####.................",
    // ...
    "######################################################"
];

# is solid ground, . is empty and P is the player’s start. It is the same grid idea as pixels[row][col] in the Pixel Art Editor. Each 18 px tile is drawn three times bigger, so TILE_SIZE is 54, and Math.floor(x / TILE_SIZE) turns a pixel position into a column index.

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.

The important words are “resolve horizontal and vertical movement separately”. Move along x and push the player out of any wall, then move along y and push it out of any floor or ceiling. If you move along both axes at once and the player ends up inside a corner tile, there is no way to tell whether it hit a wall or a floor. Tuesday’s agent wrote one function per axis:

js
// Move vertically and resolve floor/ceiling collisions
function movePlayerY(dt) {
    state.player.y += state.player.vy * dt;

    const startCol = Math.floor(state.player.x / TILE_SIZE);
    const endCol = Math.floor((state.player.x + PLAYER_WIDTH - 0.001) / TILE_SIZE);

    state.player.onGround = false;

    if (state.player.vy > 0) {
        // Moving down (falling): check the row where player's feet are
        const bottomRow = Math.floor((state.player.y + PLAYER_HEIGHT - 0.001) / TILE_SIZE);
        for (let c = startCol; c <= endCol; c++) {
            if (isSolid(c, bottomRow)) {
                state.player.y = bottomRow * TILE_SIZE - PLAYER_HEIGHT;
                state.player.vy = 0;
                state.player.onGround = true;
                break;
            }
        }
    } else if (state.player.vy < 0) {
        // ... the same for the head, pushing the player down below the tile
    }
}

The - 0.001 matters. A player whose right edge sits exactly at x = 108 touches tile column 2 but is not inside it, and Math.floor(108 / 54) is already 2. Subtracting a tiny amount keeps an edge that only touches a tile from counting as inside it.

Monday’s agent did both axes inline in update. After each move it loops over every tile the player overlaps and pushes the player back based on the direction of travel. The structure differs, but both resolve x first and then y, and both work.

Collision with tiles uses the grid. For collision between two moving things, like the player and a coin, you need the general AABB test (axis-aligned bounding boxes): two rectangles overlap when they overlap on x and on y.

js
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;
}

You will need it in the coin and enemy experiments.

6. Stage 3: sprites and animation

A sprite sheet is one image with many frames side by side. The character sheet has 9 × 3 frames of 24×24 px; the tile sheet has 20 × 9 tiles of 18×18 px. assets/ASSETS.md lists the index of every frame we use, because agents guess sprite coordinates badly. Indices count left to right, top to bottom, from 0:

js
const sx = (index % cols) * size;            // column × frame size
const sy = Math.floor(index / cols) * size;  // row × frame size
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.

The 9-argument drawImage cuts a rectangle out of the sheet and draws it somewhere else, at any size:

js
ctx.drawImage(
    images.tiles,
    sx, sy, TILE_FRAME_SIZE, TILE_FRAME_SIZE,           // source: which 18×18 tile
    col * TILE_SIZE, row * TILE_SIZE, TILE_SIZE, TILE_SIZE  // destination: where, at 54×54
);

Four details made the difference between “works” and “looks right”:

  • Images load asynchronously. If the loop started first, it would draw nothing, without an error. Both agents counted onload events and started the loop only after both sheets had loaded.

  • Blurry art. The browser smooths scaled images by default, which suits photos but blurs pixel art. ctx.imageSmoothingEnabled = false keeps the pixels sharp.

  • The sprites face left. To face right, both agents moved the origin to the sprite’s right edge and mirrored the x axis inside save/restore:

    js
    ctx.save();
    ctx.translate(drawX + CHAR_SIZE, drawY);
    ctx.scale(-1, 1);
    ctx.drawImage(images.characters, sx, sy, CHAR_FRAME_SIZE, CHAR_FRAME_SIZE, 0, 0, CHAR_SIZE, CHAR_SIZE);
    ctx.restore();
  • The hitbox is smaller than the sprite. Physics uses a 36×48 box, but the frame is 72×72 on screen. Drawing it at x - 18, y - 24 centres the sprite on the box and puts its feet on the ground.

Both agents chose each ground tile from its neighbours: a grass top where there is no tile above, dirt below it, and left and right end pieces where a neighbour is missing. That is getTileIndex() in the reference code.

The walk cycle has two frames. Tuesday’s agent keeps an animTimer that grows by dt and computes the frame from it: Math.floor(animTimer / 150) % 2. Monday’s agent switches the frame every time the timer passes 150 ms. Both are timed with dt; switching once per frame would make the legs race on a 120 Hz screen.

7. Stage 4: camera

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.

The camera is one number: how far the view is from the level’s left edge. It is state, so update changes it:

js
// Center camera horizontally on the player and clamp to level edges
function updateCamera() {
    const levelWidth = level[0].length * TILE_SIZE;
    const targetX = state.player.x + PLAYER_WIDTH / 2 - canvas.width / 2;
    const maxX = Math.max(0, levelWidth - canvas.width);
    state.camera.x = Math.max(0, Math.min(targetX, maxX));
}

draw shifts the whole coordinate system instead of moving each tile:

js
ctx.save();
ctx.translate(-Math.round(state.camera.x), 0);
// draw tiles and the player in world coordinates
ctx.restore();
// anything drawn here stays fixed on the screen

The player’s x stays a world coordinate. The camera never moves the player, only the view. Math.round keeps the pixel art on whole pixels, so the tiles don’t shimmer while scrolling. Both agents also skipped the tile columns outside the view.

8. Complete stage 4 script.js

This is Tuesday’s classroom file, the more structured of the two: one function per collision axis, e.code with preventDefault(), and an image-load counter. It is the code the agent wrote in class, unchanged apart from the removal of some notes at the end. It runs with the starter’s index.html and assets/.

js
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");

ctx.imageSmoothingEnabled = false;

// Tunable physics and display constants
const SCALE = 3;
const TILE_FRAME_SIZE = 18;
const TILE_SHEET_COLS = 20;
const TILE_SIZE = TILE_FRAME_SIZE * SCALE; // 54 px

const CHAR_FRAME_SIZE = 24;
const CHAR_SHEET_COLS = 9;
const CHAR_SIZE = CHAR_FRAME_SIZE * SCALE; // 72 px

const PLAYER_WIDTH = 36;
const PLAYER_HEIGHT = 48;
const PLAYER_SPEED = 0.3; // px/ms
const JUMP_SPEED = -0.65; // px/ms
const GRAVITY = 0.0015; // px/ms^2
const WALK_FRAME_DURATION = 150; // ms per frame

// Preload spritesheet images
const images = {
    tiles: new Image(),
    characters: new Image()
};

let loadedImagesCount = 0;
const TOTAL_IMAGES = 2;

// Level map: 54 tiles wide (~3 screens of 960 px) x 10 tiles high
// '#' = solid ground, '.' = empty space, 'P' = player start
const level = [
    "......................................................",
    "...P..................................................",
    "...###...........................####.................",
    "...............................##.....................",
    ".....####.............####...##..........######.......",
    "......................................................",
    "........###.......####............................###.",
    ".........#.....###......................##.......#....",
    "######################################################",
    "######################################################"
];

// Find player start position from 'P' in the level
function getPlayerStart() {
    for (let r = 0; r < level.length; r++) {
        for (let c = 0; c < level[r].length; c++) {
            if (level[r][c] === "P") {
                return {
                    x: c * TILE_SIZE + (TILE_SIZE - PLAYER_WIDTH) / 2,
                    y: (r + 1) * TILE_SIZE - PLAYER_HEIGHT
                };
            }
        }
    }
    return { x: 100, y: 100 };
}

const startPos = getPlayerStart();

// All game state in one object
const state = {
    player: {
        x: startPos.x,
        y: startPos.y,
        vx: 0,
        vy: 0,
        onGround: true,
        facing: "left",
        animTimer: 0
    },
    camera: {
        x: 0
    },
    keys: {}
};

// Track pressed keys
window.addEventListener("keydown", (e) => {
    if (["Space", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.code)) {
        e.preventDefault();
    }
    state.keys[e.code] = true;
});

// Track released keys
window.addEventListener("keyup", (e) => {
    state.keys[e.code] = false;
});

// Check if a tile at (col, row) is solid
function isSolid(col, row) {
    if (row < 0 || row >= level.length || col < 0 || col >= level[row].length) {
        return false;
    }
    return level[row][col] === "#";
}

// Select tile sheet index based on surrounding neighbor tiles
function getTileIndex(c, r) {
    const above = isSolid(c, r - 1);
    const below = isSolid(c, r + 1);
    const left = isSolid(c - 1, r);
    const right = isSolid(c + 1, r);

    // One-tile-thick floating platform
    if (!above && !below) {
        if (!left && !right) return 0;
        if (!left) return 1;
        if (!right) return 3;
        return 2;
    }

    // Top surface of ground
    if (!above) {
        if (!left && !right) return 20;
        if (!left) return 21;
        if (!right) return 23;
        return 22;
    }

    // Dirt underground
    if (!left && !right) return 120;
    if (!left) return 121;
    if (!right) return 123;
    return 122;
}

// Move horizontally and resolve wall collisions
function movePlayerX(dt) {
    state.player.x += state.player.vx * dt;

    const startRow = Math.floor(state.player.y / TILE_SIZE);
    const endRow = Math.floor((state.player.y + PLAYER_HEIGHT - 0.001) / TILE_SIZE);

    if (state.player.vx > 0) {
        // Moving right: check the column where player's right edge is
        const rightCol = Math.floor((state.player.x + PLAYER_WIDTH - 0.001) / TILE_SIZE);
        for (let r = startRow; r <= endRow; r++) {
            if (isSolid(rightCol, r)) {
                state.player.x = rightCol * TILE_SIZE - PLAYER_WIDTH;
                state.player.vx = 0;
                break;
            }
        }
    } else if (state.player.vx < 0) {
        // Moving left: check the column where player's left edge is
        const leftCol = Math.floor(state.player.x / TILE_SIZE);
        for (let r = startRow; r <= endRow; r++) {
            if (isSolid(leftCol, r)) {
                state.player.x = (leftCol + 1) * TILE_SIZE;
                state.player.vx = 0;
                break;
            }
        }
    }

    // Keep player inside level horizontal borders
    const levelWidth = level[0].length * TILE_SIZE;
    if (state.player.x < 0) {
        state.player.x = 0;
    }
    if (state.player.x + PLAYER_WIDTH > levelWidth) {
        state.player.x = levelWidth - PLAYER_WIDTH;
    }
}

// Move vertically and resolve floor/ceiling collisions
function movePlayerY(dt) {
    state.player.y += state.player.vy * dt;

    const startCol = Math.floor(state.player.x / TILE_SIZE);
    const endCol = Math.floor((state.player.x + PLAYER_WIDTH - 0.001) / TILE_SIZE);

    state.player.onGround = false;

    if (state.player.vy > 0) {
        // Moving down (falling): check the row where player's feet are
        const bottomRow = Math.floor((state.player.y + PLAYER_HEIGHT - 0.001) / TILE_SIZE);
        for (let c = startCol; c <= endCol; c++) {
            if (isSolid(c, bottomRow)) {
                state.player.y = bottomRow * TILE_SIZE - PLAYER_HEIGHT;
                state.player.vy = 0;
                state.player.onGround = true;
                break;
            }
        }
    } else if (state.player.vy < 0) {
        // Moving up (jumping): check the row where player's head is
        const topRow = Math.floor(state.player.y / TILE_SIZE);
        for (let c = startCol; c <= endCol; c++) {
            if (isSolid(c, topRow)) {
                state.player.y = (topRow + 1) * TILE_SIZE;
                state.player.vy = 0;
                break;
            }
        }
    }
}

// Center camera horizontally on the player and clamp to level edges
function updateCamera() {
    const levelWidth = level[0].length * TILE_SIZE;
    const targetX = state.player.x + PLAYER_WIDTH / 2 - canvas.width / 2;
    const maxX = Math.max(0, levelWidth - canvas.width);
    state.camera.x = Math.max(0, Math.min(targetX, maxX));
}

// Update game physics, camera, animation timer, and positions
function update(dt) {
    if (dt > 100) {
        return;
    }

    // Horizontal movement and facing direction
    state.player.vx = 0;
    if (state.keys["ArrowLeft"]) {
        state.player.vx -= PLAYER_SPEED;
        state.player.facing = "left";
    }
    if (state.keys["ArrowRight"]) {
        state.player.vx += PLAYER_SPEED;
        state.player.facing = "right";
    }

    // Move horizontally and resolve wall collisions
    movePlayerX(dt);

    // Jump only if standing on solid ground
    if (state.player.onGround && (state.keys["ArrowUp"] || state.keys["Space"])) {
        state.player.vy = JUMP_SPEED;
        state.player.onGround = false;
    }

    // Apply gravity
    state.player.vy += GRAVITY * dt;

    // Move vertically and resolve floor/ceiling collisions
    movePlayerY(dt);

    // Update camera to follow player horizontally
    updateCamera();

    // Update walk animation timer while moving on the ground
    if (state.player.vx !== 0 && state.player.onGround) {
        state.player.animTimer += dt;
    } else {
        state.player.animTimer = 0;
    }
}

// Draw a single tile from tilemap_packed.png using 9-argument drawImage
function drawTile(tileIndex, col, row) {
    const sx = (tileIndex % TILE_SHEET_COLS) * TILE_FRAME_SIZE;
    const sy = Math.floor(tileIndex / TILE_SHEET_COLS) * TILE_FRAME_SIZE;
    ctx.drawImage(
        images.tiles,
        sx, sy, TILE_FRAME_SIZE, TILE_FRAME_SIZE,
        col * TILE_SIZE, row * TILE_SIZE, TILE_SIZE, TILE_SIZE
    );
}

// Draw the player character sprite with walk cycle and horizontal flip
function drawPlayer() {
    // Determine walk frame (0 = idle, 1 = step)
    let frameIndex = 0;
    if (state.player.vx !== 0 && state.player.onGround) {
        frameIndex = Math.floor(state.player.animTimer / WALK_FRAME_DURATION) % 2;
    }

    const sx = (frameIndex % CHAR_SHEET_COLS) * CHAR_FRAME_SIZE;
    const sy = Math.floor(frameIndex / CHAR_SHEET_COLS) * CHAR_FRAME_SIZE;

    // Sprite is 72x72, drawn at x - 18, y - 24 to center on 36x48 hitbox
    const drawX = Math.round(state.player.x - 18);
    const drawY = Math.round(state.player.y - 24);

    if (state.player.facing === "right") {
        ctx.save();
        ctx.translate(drawX + CHAR_SIZE, drawY);
        ctx.scale(-1, 1);
        ctx.drawImage(
            images.characters,
            sx, sy, CHAR_FRAME_SIZE, CHAR_FRAME_SIZE,
            0, 0, CHAR_SIZE, CHAR_SIZE
        );
        ctx.restore();
    } else {
        ctx.drawImage(
            images.characters,
            sx, sy, CHAR_FRAME_SIZE, CHAR_FRAME_SIZE,
            drawX, drawY, CHAR_SIZE, CHAR_SIZE
        );
    }
}

// Render game visuals
function draw() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    ctx.save();
    ctx.translate(-Math.round(state.camera.x), 0);

    // Determine which tile columns are visible on screen
    const startCol = Math.max(0, Math.floor(state.camera.x / TILE_SIZE));
    const endCol = Math.min(
        level[0].length - 1,
        Math.floor((state.camera.x + canvas.width) / TILE_SIZE)
    );

    // Only draw tiles that are currently on screen
    for (let r = 0; r < level.length; r++) {
        for (let c = startCol; c <= endCol; c++) {
            if (level[r][c] === "#") {
                const tileIndex = getTileIndex(c, r);
                drawTile(tileIndex, c, r);
            }
        }
    }

    // Draw the animated player sprite
    drawPlayer();

    ctx.restore();
}

let last = performance.now();
function gameLoop() {
    const now = performance.now();
    const dt = now - last;
    last = now;

    update(dt);
    draw();

    requestAnimationFrame(gameLoop);
}

// Start game loop only after all sprite images have finished loading
function handleImageLoad() {
    loadedImagesCount++;
    if (loadedImagesCount === TOTAL_IMAGES) {
        last = performance.now();
        requestAnimationFrame(gameLoop);
    }
}

images.tiles.onload = handleImageLoad;
images.characters.onload = handleImageLoad;

images.tiles.src = "assets/tilemap_packed.png";
images.characters.src = "assets/tilemap-characters_packed.png";

9. Experiments: continue with the agent

We ran out of time before stage 5. These are the features we would have voted on. Try them with any coding agent. Start a fresh conversation for each one, read the plan before you approve it, and commit between features.

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.

A patrolling enemy

First send only add enemies in planning mode. Read the plan, count every decision the agent had to guess, and cancel it. Then send the specific version:

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.

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. If holding Space spends both jumps at once, write a bug report for it.

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.

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.

Playtest and bug hunt

Play your game and write down everything that looks wrong. Turn each problem into a bug report (what I did, what I expected, what happened) and fix one per prompt. If your agent can control a browser, let it play too:

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

Common mistakes

SymptomWhat to check
The box smears into a long barCall clearRect at the start of draw().
The game is faster on a 120 Hz monitorMultiply every speed by dt and store speeds in px/ms.
The player teleports after a tab switchKeep if (dt > 100) return; at the top of update.
Space or the arrows scroll the pageCall preventDefault() for those keys in keydown.
The player can jump foreverAllow a jump only while onGround is true.
The player sticks to walls or falls through cornersResolve x and y movement separately.
Nothing is drawn, with no errorStart the loop only after every image has loaded.
The pixel art is blurrySet ctx.imageSmoothingEnabled = false.
The player faces the wrong wayThe sheet faces left: mirror the sprite when moving right.
The sprite floats above the groundDraw the 72×72 frame at x - 18, y - 24 around the 36×48 hitbox.
The score scrolls away with the levelDraw it after the camera’s restore().

Self-check

  1. Why does a canvas game clear and redraw everything every frame?
  2. Why is box.x += 2 wrong, and what does dt fix?
  3. Which function changes camera.x, and which one only uses it?
  4. Why do we resolve horizontal and vertical collisions separately?
  5. In the 9-argument drawImage, what do the source and destination rectangles mean?
  6. Why must the loop wait for the images to load?
  7. What is AGENTS.md for, and when does the agent read it again?
  8. What three things does a good bug report contain?

Task

Practice 6 — Canvas Platformer with an AI Agent takes you through the same stages: the game loop by hand, then the player, level, sprites and camera with an agent, and finally coins and an enemy.