Tutorial 8 · Camera, Video & the Loop

This page explains the machine underneath every game here: the heartbeat, the screen, the keyboard and the camera. The room below is wider than the screen, so you can feel the camera follow you:

The loop: 50Hz, fixed, deterministic

const loop = createLoop({ update() { … }, render(alpha) { … } });
loop.start();   // …and loop.stop() to tear down

update() runs in exact 20ms steps — the PAL C64's frame rate — no matter what the display does; render() runs once per animation frame. Fixed steps are why the physics stays in whole numbers ("pixels per tick") and why jump arcs are deterministic: the same input always produces the same trajectory, which the games we're recreating depended on utterly. A backgrounded tab won't fast-forward on wake (deltas clamp at 250ms), and render receives the fraction of a step elapsed if you ever want interpolation.

Everything else follows from this: profile numbers are per-tick, entity speeds are per-tick, Rules counts 50 ticks to the second.

The screen: a VIC-II with a canvas

const video = new Video(stage, { border: C64.LIGHT_BLUE });   // 320×200
video.clear(C64.BLUE);
video.rect(x, y, w, h, C64.YELLOW);    // ALL drawing is this one call
video.destroy();                        // removes the canvas, disconnects observers

A fixed 320×200 canvas, integer-scaled up to fit its container (fractional scales smear pixels, so it only scales in whole steps, up to 4×), wrapped in a coloured border like the real machine. There are no sprites and no images — every visual in every game on this site is video.rect, which is why the code stays readable.

Colours are palette indices, never hex: engine/palette.js exports C64 (named indices — C64.LIGHT_RED, sixteen of them, the real machine's palette) and PALETTE (index → hex, if DOM elements need to match). Passing indices around is what makes palette-flash effects one-liners.

Input: a one-button joystick, plus one

const input = new Input().attach(target);   // window, or a focusable element
// in update(), FIRST:
input.tick();                               // snapshot — makes pressed() edge-detection work
input.held('left');  input.pressed('fire'); // level vs edge
input.dir;  input.dirY;                     // -1 | 0 | 1 joystick axes
input.detach();                             // in your stop()

Six names — left right up down fire action (+ reset) — mapped from arrows/WASD, Z/Space, X and R. Call tick() exactly once per update, before reading. What the buttons mean (jump? punch?) is decided by the player profile, not here.

One practical pattern from this site: attach(window) grabs the whole page — fine for a page with one game. With several embeds, attach to a focusable wrapper element instead, so only the clicked game hears the keyboard. That's how the examples page works.

The camera: locked and clamped

const camera = new Camera(video.width, video.height, map.pixelWidth, map.pixelHeight);
// in update(), after moving the player:
camera.follow(player.x + player.w / 2, player.y + player.h / 2);
// in render():
map.draw(video, camera.x, camera.y, tick);

follow centres the view on the point and clamps to the world, then rounds to whole pixels (sub-pixel scrolling shimmers at this resolution). No easing — that's how Giana Sisters scrolled. Two consequences worth knowing:

  • Flick-screen rooms are just worlds exactly one screen wide. The clamp pins the camera at 0 and nothing ever moves — The Manor never thinks about its camera at all.
  • Room sizes can differ. Crossing into a bigger or smaller room? Update the bounds in a room-enter listener: camera.worldW = world.map.pixelWidth (and height). The Map Editor's Play mode does exactly this.

What you've learned

Four small machines with one big property each: the loop is fixed (determinism), the video is indexed (palette discipline), the input is snapshotted (edge detection), and the camera is clamped (flick-screen for free).

Tutorial 9 · Rules — Times, Targets & Scores →