Tutorial 4 · Worlds — Connecting Rooms
One screen isn't a world. Jet Set Willy, Bruce Lee, Robin of the Wood — the games this engine is built for are flick-screen: walk off the edge of one room and the screen snaps to the next. Here's what we're building — two rooms, one doorway, four coins:
Everything from tutorials 1–3 carries over unchanged. The one new import is World.
A world is rooms + exits
A room is what you've already been building — tile rows and an entity list — plus a name and an exits object. A world is a plain object of rooms, keyed however you like:
import { World } from '../../engine/world.js';
const ROOMS = {
yard: {
name: 'The Yard',
rows: yard(), // the same tile strings as ever
exits: { right: 'cellar' }, // walk off the right edge → cellar
entities: [
{ type: 'patroller', x: 160, y: 164, axis: 'x', mode: 'range', min: 80, max: 280, speed: 0.7 },
{ type: 'item', x: 80, y: 145 },
{ type: 'item', x: 144, y: 121 },
],
},
cellar: {
name: 'The Cellar',
rows: cellar(),
exits: { left: 'yard' }, // and back again
entities: [ /* … */ ],
},
};
Entities become data here rather than constructor calls — the World builds them itself every time a room is entered. exits takes any of left, right, up, down; an edge without an exit is sealed (a wall at the sides; sky above and the void below, platformer rules). Exits don't have to be symmetrical — a one-way drop is just a down with no up coming back.
Boot it and step it
The World replaces your hand-rolled entity loop. Create it, attach the player, and call update() after the player moves:
const world = new World({ rooms: ROOMS, start: 'yard', legend: LEGEND });
const player = new Player(null, PROFILE, SPAWN); // no map — the World supplies it per room
world.attach(player, SPAWN);
const loop = createLoop({
update() {
input.tick();
player.update(input);
world.update(); // entities, pickups, deaths, and the edge flick
camera.follow(player.x + player.w / 2, player.y + player.h / 2);
},
render() {
video.clear(C64.BLUE);
world.draw(video, camera.x, camera.y, tick);
player.draw(video, camera.x, camera.y);
},
});
Two things happen on every room entry, and they're the heart of the system: the room rebuilds (guardians reset, crumbled floors regrow), but collected items stay collected, tracked world-wide. world.collectedCount, world.totalItems and world.room.name drive your HUD for free, and the World takes over player.onDeath so dying returns you to where you entered the room.
A world also announces its big moments as they happen — you listen with one line each:
world.on('room-enter', ({ room }) => { /* retune the camera, flash the name */ });
world.on('collect', ({ remaining }) => { /* ding! */ });
world.on('complete', ({ deaths }) => { /* the win screen */ });
(Two more entity types live at this level: locked gates that demand a collect count before they rise — that's The Manor's vault, announced by gate-open — and the Exit, the level ender: a doorway that wins the run when stepped through, barred by the same demand vocabulary until you've earned it. A world with an exit completes only there; see Tutorial 7.)
Going overhead — the Robin of the Wood school
Everything above assumed gravity. One profile line removes it:
const PROFILE = { movement: 'topdown', inertia: false, maxSpeed: 1.4, accel: 0.3, friction: 0.3 };
movement: 'topdown' is the Robin of the Wood school: 8-way running on the floor plane (diagonals included, speed-normalised so cornering isn't a cheat), no jumping, no falling. Walls block, hazards kill, and the same four exits now genuinely point north, south, east and west — sealed top and bottom edges become walls instead of sky and void. Same World, same tiles, same entities; the map is a maze now, not a mountain:
The guards here aren't an engine class — they're twenty lines of game code passed in through the World's spawnEntity factory. Anything with an update(player), a hits(player) and a draw() can live in a room:
const world = new World({
rooms: ROOMS, start: 'glade', legend: LEGEND,
spawnEntity: (def, map) => (def.type === 'guard' ? new Guard(map, def) : null),
});
The action button
Notice the sword. Movement profiles decide what jumps (jumpKeys, default fire and up) and what acts (actionKeys, default the X key — plus fire whenever fire isn't spent on jumping, which in top-down is always). Pressing an action key makes the player emit an action event with its stance:
player.on('action', (a) => {
// a.moving, a.onGround, a.climbing, a.facing, a.dir
swing = { t: 8, dir: a.facing, reach: a.moving ? 22 : 14 };
});
The engine reports the press and the situation; the sword — its reach, its flash, what it fells — is entirely game code, exactly like the particle bursts in the Danger Room. Standing swings are short, running swings are long: that's a.moving deciding, and it's the same distinction a Bruce Lee-style game uses for standing punch versus flying kick.
The three sibling layers: Outcomes, Bubbles, Effects
Every World owns three self-running presentation layers, and the split between them is worth learning because it never varies: Outcomes (engine/outcomes.js) play departures — any mob that dies gets its animation (mob.outcome names the style). Bubbles (engine/bubbles.js) speak — floating text per event, configured through world.bubbleFor. And Effects (engine/effects.js) own everything that shimmers without being a death or a word, in three families: ambient room weather (give any room def an ambience: { kind: 'fireflies', density: 6, wind: 0 } — stars, fireflies, rain, snow, embers, motes, mist, plus storm — rain with deterministic lightning strikes — and dry lightning alone; the World option ambience: sets a default for rooms that don't choose), attached effects that follow a body while a state holds (the shield aura, speed trails, jump shimmer — auto-wired to the corresponding item effects, blinking through their last second), and bursts for happy moments (collect sparkle, gate shimmer, the Power bomb's ring). Every auto-wire runs through world.effectFor — override an entry to restyle it, null it to silence it — and it's all deterministic: a particle's position is a pure function of the tick, so a lockstep replay gets the same sky. Items add one more dial of their own: glow: true puts a breathing halo under any pickup — twinkle says takeable, glow says special.
No code at all
Everything on this page can be built visually: the Map Editor does rooms, exits (with two-way linking), entity tuning and the Robin of the Wood preset, and its Export JSON is the same structure you just read. Draw a world there, export it, and you're one World constructor away from running it anywhere.
What you've learned
Rooms are data, worlds are rooms plus exits, and the flick is free. Persistence (collected items, opened gates) lives in the World; everything that resets (guardians, crumble, projectiles) lives in the room build. And with one profile line the whole thing tips ninety degrees into an overhead maze game.
That's the end of the build-a-game path — from here the module deep dives (tutorials 5–9) go through the engine one file at a time: maps, the player, entities, the machine layer, and the rules.