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-enterlistener: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).