Build Log
The engine is being built in the open, and this log is where the experience gets passed on. Newest entries first.
Loading screens, storms, and one honest bug (19 July 2026)
Shipped: 📼 Loading screens — the heady-days ritual, staged faithfully: the blue room, LOAD"MYRTLE",8,1 typed under a blinking cursor, SEARCHING… LOADING, then the border erupts into flashing raster bars while the blocks count, then RUN. READY. — and the overlay lifts onto the splash. And because a loading screen without a loading PICTURE is a tape with no inlay: each big game now carries cover artwork, painted in the engine's own pixel language (its real character painters, blown up large) and revealed row by row as the blocks arrive — the picture builds up, the way pictures built up. Any key skips, and the skip key is spent so it can't accidentally start the game — nostalgia is a gift, never a toll. It's the Designer's one dial that defaults OFF (📼 in 🏷 Game), and the bigger site games — Myrtle, both Invaders, the Undercroft, Giana — wear it as standard.
Also shipped: weather with teeth. The ambience family grew the STORM — rain plus deterministic lightning: once per cycle, at a moment hashed from the cycle index (no two rooms strike in step), the whole room flashes white and a jagged, forked bolt hangs for a few frames, cooling from white to grey. And dry lightning, the strikes alone, which is the eeriest kind. Same replay, same storm — thunder is punctual here, and the twin-skies test proves it frame for frame.
The honest bug: the Game Designer went down for a moment today, and it was mine — the decoration state was declared below the palette code that reads it at boot, a temporal dead zone the syntax checker can't see. The fix took one move; the lesson took a new harness: the Designer now has its own headless boot smoke (DOM shim, real boot()), so a panel that can't build fails a test before it fails a person.
The furniture van, and the easel that scales (19 July 2026)
Shipped: the answer to "can I add a sofa, or a lamppost?" — which is yes, three times over. Literally: the scenery shelf grew a Sofa and a Lamppost, each a one-click stamp. Generally: 🖌 Paint a decoration opens a blank pixel easel at any size from one cell to 4×3 — one cell joins the palette as a tile, bigger pieces become your own stamp of painted member cells (hidden from the palette; the stamp is the face of the piece), with one physics choice for the whole thing: dressing, SOLID furniture you can stand on, or a platform. The engine needed almost nothing new, because a decoration is just tiles plus a stamp — a stamp is a brush, not a new kind of thing, still.
And structurally — the clone→easel round trip: every cloned tile and pickup now carries 🖌 Open in easel, which rasterises its procedural look (through the real tile and Item painters, frozen at tick 0) into pixels you own — so anything you clone, from a built-in crate to an egg out of a cloned copy of Myrtle, is one click from being repaintable. An Edit button reopens any of your designs; cloning a decoration duplicates the whole piece. This closes the loop the Designer has been circling all along: clone a game of an existing type, and everything in it — rooms, creatures, feel, and now every pixel of its dressing — is seamlessly, granularly yours.
The X-ray: Debug mode, pause-step, and honest glass (19 July 2026)
Shipped: engine/debug.js — a Debug overlay any game can wear and the Designer's play bar carries as a 🐞 button. Collision boxes on every body (green player, red mobs, grey machinery, yellow items, white gates), flag markers on every special tile, and a live readout of pose, stance, velocity, energy, powers, and the room's census. The contract is printed on the tin and tested: it reads, never writes — the suite literally snapshots the world before and after a debug draw and asserts nothing moved. Paired with the pause key, N now steps exactly one tick, which the engine's determinism turns into a microscope: freeze the world, advance it a frame at a time, and watch the exact pixel where a jump clips a lip or a spore drifts into a hitbox. Myrtle wears it live — add ?debug to her address.
The Effects layer — everything that shimmers, consolidated (19 July 2026)
Shipped: the engine's third presentation sibling. Outcomes play departures, Bubbles speak, and now Effects (engine/effects.js) owns everything that shimmers without being a death or a word — in three families. Ambient is in-room weather: drifting stars, fireflies, rain, snow, rising embers, dust motes, sliding mist — and it's stateless: every particle's position is a pure function of the tick, its index and a seed, so nothing allocates, nothing drifts, and a lockstep replay gets the same sky twice (the twin-skies test literally deep-compares two independent fields, tick by tick). Attached effects follow a body while a state holds — the shield aura finally makes the Power capsule visible (a pulsing ring that blinks through its last second, the honest warning), speed boosts trail heel-sparks, jump boosts shimmer at the shoulders — and they expire with their ttl, their body, or a detach. Bursts mark the happy moments: a collect sparkle, a gate-open shimmer, the Power bomb's expanding ring.
The granularity is the feature. Every wire is a dial in the Game Designer: ambience has a world default in 🎨 Look and a per-room override in every Room panel (kind, density, wind — a starry summit over a misty cellar in the same game); every auto-wire — aura, trail, shimmer, sparkle, gate, ring — has its own switch in 🏷 Game, all default ON, each refusable alone; and every pickup grew a per-instance Glow halo dial (twinkle says takeable, glow says special). All of it exports, imports, and reads as JSON a designer can learn from — world.effectFor is the same silence-or-restyle pattern bubbleFor taught.
Down-stances, stocked powers, pause, and a Game Over that counts (19 July 2026)
Shipped: ↓ means something now. Every grounded body crouches on one press (two-thirds height, half speed) and crawls on a second (eight pixels — ONE TILE — at a quarter speed), and that last number is the whole design: a crawling body threads a one-tile gap, and the headroom check keeps it under the rock until the ceiling ends — the press to rise is refused, and the refusal is the feature. Ladders outrank stances, jump keys rise before they leap, death stands you back up. It's profile data (crouch: false to refuse it, dials for heights and speeds), on by default in every platform preset, and the new paintPlayer() squashes all fifty-three roster characters down convincingly with zero new art — feet anchored, the honest C64 sprite-squash.
Also shipped: the stocked Player Powers. Instant power-ups act on touch; the stocked kind wait for you. Power bombs bank up to the pocket limit (three, by the classics) and C spends one — every killable enemy in the room dies at once, machines survive, kills score. Power capsules shield for a configurable number of seconds. The World owns the ledger (world.powers), the Designer's Player panel owns the toggles, and the decision layer — hoard the bombs or panic-spend them — is precisely the kind of choice that makes a run worth replaying. Invaders Too showcases the whole system with deterministic drops, a shield aura, and a ✹ rack on the HUD.
And the frame around every game grew up: P pauses — everywhere, mid-frame, under a PAUSED card — and both endings are proper cards now, carrying the run's statistics from the engine's own ledger (world.summary()): score, collected, kills, rooms seen, deaths, bombs spent, time. A Game Over that tells you how far you got and what it cost is an invitation to press R; a status line is a shrug.
The lesson passed on: the ladder test "failed" and the failure was the spec teaching itself. The test crouched a body at a ladder's base and pressed ↓, expecting a climb — the engine grabbed the ladder, found the floor two pixels later, and let go, all in one tick. Down at the bottom of a ladder is a no-op because you cannot climb below the floor, and the honest test presses ↑. When a test fails, sometimes the code is wrong and sometimes the expectation is — knowing which requires reading what actually happened, not what you meant.
The bestiary of yore, and the Designer learns to clone (19 July 2026)
Shipped: nine new creatures in one sitting, every one lifted from the machines that raised us and every one obeying the house law that a telegraph comes before every bite. The Sporecap started it — a mushroom that swells and brightens (that's your notice), then bursts a fountain of drifting spores that cannot be killed, only avoided; two of them now grow in Myrtle's bramble and grotto. Then the rest of the old country moved in: the Snapper in its pit (leaf-tips rustle first, and it politely refuses to erupt while you're standing on the hole), the Wraith that drifts through walls Gauntlet-style, the Bat that's ceiling decor until you walk beneath it, the Riser that hauls itself out of churning earth and can't be stabbed while it's soil (its hitbox is literally zero when buried), the Spider on its silk thread, the Mimic — which draws as any collectible through the real Item shape table but never twinkles, the twinkle rule doing real design work as the tell — the wall-walking spiked Crawler, and Boulder Dash's rock, now spawnable from a JSON def like everyone else. All nine are in the Game Designer's palette with full dials, descriptions and death styles.
Also shipped: the Design panel now clones enemies and hazards, not just tiles and pickups — a cloned bat still dives; you're naming a variant and recolouring it. Cloned pickups got the full look wardrobe (all twenty-two Item shapes). The inspector grew the other half: Clone to palette turns a placed, hand-tuned entity into a new palette entry whose defaults are your numbers — tune the perfect Cave Bat once, paint twenty. And the biggest head-start of all: ⎘ Clone a game drops all sixteen screens of Myrtle the Turtle — or the six rooms of Dreams of Giana — onto the workbench as ordinary Designer data, converted glyph-for-glyph into the editor's vocabulary (tiles the palette doesn't stock travel as custom designs; Myrtle's eggs arrive as a custom pickup so they still look like eggs). A blank grid teaches nothing; a whole game opened up teaches everything.
And a sequel: Invaders Too — the original taken up-screen: a scrolling starfield, enemies streaming down eight lanes, and an escalation ladder that adds a new enemy type every N kills (drone, weaver, spitter, diver, sniper… then the Mothership, with a health bar and a three-way fan). The quiet twist is shots-fired pressure: every 28 rounds fired permanently quickens the spawn cadence, so accuracy is a survival skill, not a score nicety. The whole campaign runs headless — an auto-aim bot climbs all five tiers and kills the Mothership in simulation before the game is allowed to call itself winnable.
The lesson passed on: two real bugs surfaced by reading the pipeline end-to-end rather than trusting the panels. The World's doc comment promised that a game's custom spawnEntity factory would "fall back" to the engine's for anything it didn't know — but the implementation replaced the factory outright, so a custom factory silently dropped every engine-native type it hadn't heard of. And in the Designer, the Pendulum, Dripper and Dart had palette entries and inspectors but no builder mapping — placed in a design, they fell through to the item branch and played as collectible gems. Both are one-line contracts now honoured and regression-tested. The moral is the same one the death-style dial taught us: a feature isn't the def, the field, or the panel — it's the whole road from palette to running World, and you test the road.
The inspector and the Player panel (13 July 2026)
Shipped: the Map Editor grew a second half. Until now you could place things but not tune them — every patroller paced the same way, every turret fired on the same beat, and the player was whatever profile the editor hardcoded. Now a Select tool opens an inspector on any entity: a patroller's axis (walk ↔ or bob ↕ like a piston), its mode (turn at edges, or bounce in a fixed range of tiles), speed, range and starting direction; a chaser's speed and whether it's careful at ledges or leaps after you; a turret's fire rate, aim toggle, fixed direction and shot speed; a coin's point value and tag. And a Player panel picks the physics the room plays under — the four Movement Lab profiles as presets, every core number editable on top, plus a lives counter that turns Play from a sandbox into a game: three lives, a game over, an R to try again. Lives set to 0 keeps the old endless respawns for while you're building.
The data followed the designer, not the other way round. An entity's properties ride inline on its entry — { "type": "T", "col": 22, "row": 6, "every": 120, "aim": true, ... } — so the exported JSON stays flat, readable, and hand-editable, and old exports import cleanly (missing properties just take defaults). The player block exports beside the map: { lives, profile }. Round-trip it and the preset picker even re-finds itself. One deliberate designer-facing choice: patrol ranges are expressed in tiles either side of the placement cell, not absolute pixels — move the entity and its range moves with it, which is what you meant.
The lesson passed on: lives were nearly a one-liner and weren't, because of a subtlety in the death contract. The player's death sequence ends by calling your onDeath hook after clearing the dying flag — and if the hook doesn't respawn (because that was the last life), the controller is alive again, standing at the death spot, taking input. The fix isn't in the player at all: the game loop owns "game over" and simply stops updating the world until R. The engine negotiates deaths; only the game knows when dying matters. The headless test that pinned this down also caught a wrong assumption of ours: a fresh turret fires on its first tick (warmup 0), then settles into its rhythm — by design, as anyone who ever walked into a Zzap!! screenshot knew.
Interlude — use it, abuse it (13 July 2026)
Shipped: the engine now leaves the site three ways, all from one panel on Using the Engine. Download is the single-file bundle that already existed. Include is new: one copy-paste import line that hot-links engine/index.js — a tiny barrel module re-exporting all eleven engine modules — straight from this site, so a game is one HTML file on any host and the engine never gets downloaded at all. View source is newest and simplest: the whole bundled engine, all ~1,700 lines of it, displayed right on the page with a copy button. Read it, pick it apart, take the bits you like.
The include option needed one server-side favour: browsers fetch type="module" scripts under CORS rules, so hot-linking from another domain is refused unless this site says otherwise. A twenty-line plugin (retro64-cors) now answers Access-Control-Allow-Origin: * on everything under /public/retro64/ — reads only, and only the folder that is deliberately public source. Nothing else about the site's security posture moved.
The lesson passed on: the plugin's first version set the header and the header never appeared. Fastify encapsulates each plugin in its own scope — a hook added inside one applies only to routes that plugin owns, which for this plugin is none at all. The fix is the escape hatch fastify-plugin exists for (the skip-override symbol): opt out of the sandbox and the hook lands on the shared instance, where it covers the static file routes too. If a hook seems to do nothing, the first question isn't "is the code right?" — it's "which scope am I in?"
Times, targets and scores (13 July 2026)
Shipped: engine/rules.js — the game's contract with the player as one block of data: a countdown clock, a points table and a list of win conditions. The Manor now plays under it: 90 seconds on the clock (running out costs a life), 100 a trinket — the golden one in the attic pays 500 — 50 for each new room, and 25 × every second still on the clock when the last trinket falls.
The design splits three things games usually tangle together. Times: one clock, three flavours — a Bruce Lee global countdown, a Manic Miner per-room air supply (perRoom: true), or none at all (time still counts for the HUD and the bonus, it just never runs out). Expiry is a choice: 'die' costs a life through the same hurt({ type: 'time' }) negotiation as any guardian — so yes, a shield power-up can cancel time itself — and the clock re-arms; 'end' is game over. Targets: the win is a list of declared conditions that must ALL hold — collect-all, collect n of a tagged kind, reach a room, visit several, survive n seconds. Omit the list and you get the classic default: collect everything. Scores: a points table keyed by event name — collect, first visit to a room, target completed, seconds left at the win — with per-item overrides riding on the item itself (points: 500 is exactly what makes a trinket golden).
None of it touches the physics. Rules rides the event bus that landed with the effects layer: bind(world, player) and room entries, collects and item totals flow in on their own; the game keeps one job, calling rules.tick() beside world.update(). It's also the first north-star migration landed for real — "collect everything to win" left the World's hardcode and became [{ type: 'collect-all' }], a default you can now replace.
The lesson passed on: the bug the headless test caught wasn't in the maths, it was in the order of introductions. Bind the rules after the world has already entered its first room — the natural order — and that room's entry event has been and gone; the rules never hear it and every first-visit count is off by one. Event-driven layers must ask "what already happened before I arrived?", so bind() now seeds the current room on arrival. The test suite has Node play the whole Manor under rules: every trinket room by room, the golden 500, first-visit-only bonuses, the time-bonus payout — then a one-second clock kills the player and the contact type reads 'time'.
Interlude — the engine downloads itself (13 July 2026)
Shipped: a one-click download of the whole engine as a single retro64-engine.js file — on Using the Engine and in the Map Editor's toolbar next to Export.
There is no build server and no bundler dependency, because the "bundler" is ~120 lines of the same vanilla JS as everything else (tools/bundle.js). When you click, your browser fetches the eleven engine modules as this site is currently serving them, strips the inter-module import lines, and concatenates them in dependency order into one flat ES module. That's legal for exactly one reason, and the code enforces it on every build: every module declares unique top-level names. If a future module ever collides (two private clamp helpers, say), the build refuses loudly instead of letting one silently shadow the other. It also refuses if a module imports something outside the known list — so the bundle can never quietly ship missing a piece.
Why it matters: the end-game is a designer where a game is drawn, not coded — and a designed game has to leave the designer. Export a map as JSON, download the engine as one file, and you already have a two-file game that runs from any static folder. The remaining step is letting the designer weld those together (game JSON + engine + a boot line, one payload) — the download exists; the welding is next.
Milestone 3 — More danger, more moves, and an editor (13 July 2026)
Shipped: two new tiles (ice, springboards), an animated lava hazard, projectiles + a firing turret + a homing chaser, two new movement abilities (double-jump and wall-slide/wall-jump), two new physics profiles (Bruce Lee and Turrican), a Danger Room example, and — the big one — a visual Map Editor that plays what you draw in the real engine.
Tiles are still just flags
Ice and springboards slotted into the same bitmask as everything else — SOLID | ICE, SOLID | BOUNCE. The player controller grew two small behaviours to honour them, and that was the whole job:
- Ice keeps your push but almost none of your grip. The fix was a one-word change — friction gets multiplied by the tile's
grip(≈0.14), acceleration doesn't. So you build speed nearly normally and then can't stop. The first version scaled both by grip and the result felt like walking in treacle; the headless test caught it instantly ("ice keeps speed after release" failed with ice slower than normal ground). Ice should reduce friction, not effort. - Springboards launch you on landing instead of stopping you — the collision code already knew you'd hit the tile, so it just checks for a
bounceVeland flips your velocity up.
Animated tiles came for free
The tile renderer already received a tick (the conveyor chevrons animate off it), so animated tiles cost nothing structurally: lava bubbles rise and pop on per-cell phase offsets, and the spikes got a travelling glint. If a tile can read the frame counter, it can move.
Projectiles, and the things that fire them
This is the first of the north-star entity types to land. A Projectile is a tiny thing with a velocity, optional gravity, and three ways to die: a solid tile, leaving the room, or a timeout. A Turret fires one on a timer — in a fixed direction or aimed at you — and hands it back to the game loop to manage. A Chaser walks toward you on its platform with real gravity, stopping at ledges (or, if you set careful: false, leaping after you). That's the Bruce Lee Ninja in about thirty lines.
Two new ways to move
- Double jump — one extra leap in mid-air, refreshed on landing (and on a wall-jump).
- Wall-slide + wall-jump — press into a wall while falling and you cling and slow; jump and you kick away from it. With coyote time (a few grace ticks after leaving a ledge) added alongside, the movement finally feels modern — all still opt-in per profile, so Jet Set Willy stays exactly as unforgiving as it always was.
The new Turrican profile in the Movement Lab turns both on; Bruce Lee is the other addition — quick, exact, with a fixed-height hop you steer freely in the air.
The Map Editor: draw it, play it
The headline. Paint tiles with the mouse, drop in a spawn point and enemies, press ▶ Play, and the engine runs over exactly what you drew — no reload. The trick is that there is no conversion magic: the editor's grid already is the engine's rows format, and the entity markers become Patroller/Chaser/Turret/Item instances at their grid coordinates. Export dumps the room as { rows, spawn, entities } JSON; Import loads it straight back.
That round-trip is the point, and it's the clearest step yet toward the end-game: a level is data, editable by hand, by script, or — next — from a collection in the CMS, exactly like the C64 Classics list. The editor proves the data format is real by consuming it live.
The lesson to take away
Every one of these landed as data or a small behaviour, never a new system. New tiles are flags. New enemies are objects with update/draw/hits. New moves are profile fields. The editor didn't need an "editor format" because the engine's format was already declarative. Keep the core data-shaped and the tools write themselves.
Next milestone
Migrate the last hardcoded rules (touch-enemy-dies, collect-all-wins) onto the player object as declared onContact/objectives, so a whole game — not just a level — is one JSON file. Then: sound (the SID synth), and real sprite art.
Design note — the end-game: a whole game in one JSON file (13 July 2026)
Milestone 2 proved that rooms, enemies and hazards can be data. This note fixes the destination that every milestone from here steers toward: a complete game — maps, tiles, observers, goals, players, enemies, projectiles — configurable from JSON, loaded by the engine, no game-specific code at all.
The organising principle: behaviour attaches to objects as declared rules, not engine code. Today, "touching a guardian kills you" and "collecting every trinket wins" are hardcoded in the World. In the end-state they're entries on the player object:
{
"meta": { "title": "The Manor", "version": 1 },
"profile": { "inertia": true, "maxSpeed": 1.3, "gravity": 0.13, "jumpVel": 3.1 },
"tiles": {
"#": { "flags": ["solid"], "kind": "brick", "base": "RED", "top": "LIGHT_RED" },
">": { "flags": ["solid", "conveyor"], "dir": 1, "beltSpeed": 0.5 },
"%": { "flags": ["solid", "crumble"], "hp": 22 }
},
"player": {
"spawn": { "room": "cellar", "x": 16, "y": 158 },
"lives": 3,
"objectives": [
{ "id": "all-trinkets", "type": "collect-all", "tag": "trinket",
"onComplete": [{ "action": "win" }] }
],
"onContact": {
"enemy": [{ "action": "die" }],
"trinket": [{ "action": "collect" }, { "action": "sound", "name": "chime" }],
"projectile": [{ "action": "die" }]
}
},
"enemies": {
"guard": { "w": 10, "h": 12, "behaviour": "patrol-edge", "speed": 0.7, "tag": "enemy" },
"piston": { "w": 12, "h": 14, "behaviour": "bob", "speed": 0.8, "tag": "enemy" },
"turret": { "behaviour": "static", "fires": { "projectile": "bolt", "every": 90 } }
},
"projectiles": {
"bolt": { "w": 4, "h": 2, "speed": 2.5, "gravity": 0, "tag": "projectile",
"onHitTile": [{ "action": "despawn" }] }
},
"observers": [
{ "when": { "type": "enter-zone", "room": "landing", "rect": [12, 15, 10, 4] },
"do": [{ "action": "message", "text": "The bridge remembers your weight…" }] },
{ "when": { "type": "objective-complete", "id": "all-trinkets" },
"do": [{ "action": "sound", "name": "fanfare" }] }
],
"rooms": {
"cellar": {
"grid": ["…25 strings of 40 characters…"],
"exits": { "up": "landing", "right": "engine" },
"spawns": [
{ "type": "guard", "x": 72, "y": 164, "min": 64, "max": 232 },
{ "type": "item", "x": 148, "y": 105, "tag": "trinket" }
]
}
}
}
The parts of this vocabulary:
- Tiles — already data since milestone 1; the JSON form just names flags instead of bitmasks.
- Maps/rooms — already data since milestone 2 (grids, exits, spawns).
- Players — the physics profile is already data; what moves onto the player object next is everything that answers "what happens to me":
onContactrules keyed by tag,objectiveswith completion actions, lives and spawn. - Enemies — become named types (a behaviour + dimensions + a tag) that rooms instantiate by reference, instead of every room spelling out full entity definitions.
- Projectiles — the genuinely new mechanic: spawned by enemies (turrets) or the player (Bruce Lee's fists are a melee-range projectile, squint and it's true), with their own contact rules.
- Observers — the glue:
whena condition holds (zone entered, objective complete, timer elapsed),doactions (message, sound, open door, spawn, win). This is what lets a game have scripted moments without scripts.
Two rules keep this honest as we build toward it:
- Every new mechanic must be expressible in this JSON. If a feature needs engine code per game, the design is wrong — redesign the data, not the loader.
- Tags, not types, drive interaction. The player doesn't know what a "guard" is; it knows what touching something tagged
enemydoes. That one indirection is what makes the whole system composable from configuration.
The migration is incremental: milestone 3 moves the existing hardcoded rules (touch-guardian-dies, collect-all-wins) onto the player object and adds the action vocabulary; projectiles and observers follow. The Manor will be re-expressed in this format as the reference game — same rooms, zero code.
Milestone 2 — The Manor (13 July 2026)
Shipped: flick-screen room transitions, patrolling guardians, collectables, conveyor belts, crumbling floors, ladders, inertia as a first-class physics switch — and The Manor, a four-room winnable game that uses all of it. The engine grew two modules (world.js, entities.js) and is still pure vanilla JS with zero dependencies.
A whole game is now data
This was the milestone's real test: could we build a complete game — rooms, enemies, hazards, goals — without writing any game-specific code? The Manor is one data file: four character grids, a legend, exit wiring, and entity coordinates.
belts: {
name: 'The Belt Room',
rows: [/* 25 strings of 40 characters */],
exits: { left: 'landing', down: 'engine' },
entities: [
{ type: 'patroller', x: 232, y: 164, mode: 'range', min: 224, max: 296, speed: 0.9 },
{ type: 'item', x: 120, y: 121 },
],
},
That's a room. The World class does the rest: builds the tilemap on entry, spawns the entities, watches the edges. Walk off an edge that has an exit wired and you flick to the neighbouring room — walking, falling and climbing all trigger it, which is how the ladder shaft, the doorways and the drop chute all work from the same four lines of edge-checking.
Special tiles are just more flags
Milestone 1's tile flags were SOLID, PLATFORM, HAZARD. The new tiles slot into the same bitmask: a conveyor is SOLID | CONVEYOR with a direction, a crumbling floor is SOLID | CRUMBLE with a hit-point count, a ladder is LADDER. The player controller gained three small behaviours to honour them:
- Conveyors push you through the same collision pipeline as walking — so a belt can pin you against a wall but never shove you through one. Belt speed 0.5 vs walking speed 1.3: you can fight the belt and win, which is the classic tension (Manic Miner's belts worked exactly this way).
- Crumbling floors decay per tick stood on (22 ticks ≈ 0.44s per tile), with cracks drawn in three stages so you can see the betrayal coming. State lives on the room's tilemap instance, so leaving and re-entering regrows the floor — original JSW behaviour.
- Ladders switch the controller into a climb state: gravity off, up/down to move, jump to leap off, and landing on solid ground exits the state automatically. A ladder poking through a floor hole is the entire vertical-transition mechanic — no special case needed.
Rooms reset; progress doesn't
The rule that makes flick-screen games fair: rooms rebuild on every entry (guardians re-arm, bridges regrow — because rebuilding the room object is one constructor call), but collected trinkets live on the World, keyed by room and index, so they stay banked forever. Death costs you your position in the room, never your progress. Getting that split right is most of what "feels fair" means in this genre.
Inertia became a switch, not a hack
Milestone 1 faked Jet Set Willy's momentum-free movement by cranking acceleration to near-instant values. That worked, but it buried a real design axis inside a magic number. The profile now says it outright:
inertia: false, // full speed the tick you press, dead stop the tick you release
With inertia on, accel governs the speed-up, friction the slow-down, exactly as before; gravity is independent of all of it. With it off you get the purest 8-bit movement — velocity is the stick position. It's a checkbox in the Movement Lab now, and toggling it mid-room is the fastest possible education in why both schools existed.
The lesson to take away
Aim your engine at a game, not at features. Every mechanic in this milestone was pulled in by The Manor needing it, and the demo immediately punished designs that only worked in theory — the first bridge layout was uncrossable coming back, the first belt speed was unbeatable walking against it. The headless test suite from milestone 1 scaled up too: Node now plays the manor — climbs the ladder into the room above, rides the belt, stands on the bridge until it drops, walks into the guardian and checks the death rules — all in deterministic 50Hz ticks, no browser required.
Next milestone
Sound: a SID-flavoured three-voice WebAudio synth (square, triangle, noise — jump blips, pickup chimes, and a title jingle). Plus real sprite art to retire the placeholder rectangle man, and the Impossible Mission somersault.
Milestone 1 — The Movement Lab (13 July 2026)
Shipped: fixed-timestep core, C64 palette and integer-scaled video, one-button joystick input, tile collision, scrolling camera, and a data-driven player controller with two physics profiles. Play it here.
Decision 1: 50Hz fixed timestep, because determinism is the game feel
The PAL C64 ran at 50 frames per second, and every game's physics was "per frame" by definition — there was no other clock. Retro-64 does the same thing deliberately:
update()runs in exact 20ms steps, decoupled from the display via an accumulator. A 144Hz monitor renders more often; the simulation never changes.- All physics constants are in pixels per tick. Giana's gravity is 0.14. That number means the same thing on every machine, forever.
- Deterministic physics made the engine testable without a browser: milestone 1 shipped with a Node test that actually plays the room — walks into the spike pit, dies, respawns, jumps, and asserts the apex lands within a pixel of
jumpVel² / 2·gravity.
That last point paid off immediately: the test caught a bug on day one. (In the test, as it turned out — the sim landed the player on top of a platform between two measurements and shifted the baseline. Deterministic replays made it a five-minute diagnosis.)
Decision 2: game feel is data, not code
The central bet of the whole engine. The player controller is ~80 lines, and it reads everything from a profile object:
| Value | Giana school | Jet Set Willy school |
|---|---|---|
| Max speed | 1.7 px/tick | 1.0 px/tick |
| Acceleration | 0.09 (builds up) | instant |
| Air control | 70% | zero — the arc is committed |
| Gravity | 0.14 | 0.09 (floatier) |
| Jump impulse | 3.4 (≈41px high) | 2.55 (≈36px, ~1.1s airtime) |
| Variable jump | yes — release to cut | no — tap and hold are identical |
Same code. Radically different games. The Movement Lab exposes every one of those numbers as a live slider precisely so you can feel the design space — the difference between Mario-school and Manic-Miner-school platforming is about eight floating-point numbers.
The subtlest rule: in a committed-arc profile, what happens when you walk off a ledge rather than jumping? Answer (and what the originals did): you fall carrying your walking velocity. The engine records your speed every grounded tick so the moment your feet leave the floor — by jump or by cliff — the arc is sealed.
Decision 3: the C64's constraints are the art direction
- 320×200 internal resolution, integer-scaled up with
image-rendering: pixelated— fractional scaling smears pixels, so the canvas only ever grows in whole multiples. - The 16 "Pepto" palette colours, by index. Engine code says
C64.LIGHT_RED, never a hex value. The screen is VIC blue with a light-blue border because that's what a C64 looked like the moment you turned it on. - 8×8 tiles — the machine's character resolution. Rooms are 64×25 character grids, built procedurally with named, coordinate-placed features rather than counted-out ASCII art (we tried ASCII art first; counting to column 54 four times is how bugs are born).
- A one-button joystick. The input layer models exactly what a C64 game had: four directions and fire. Up-to-jump and fire-to-jump both work, because both conventions existed and the arguments about which was right have never ended.
Decision 4: axis-separated tile collision
The classic approach, and still the right one: move horizontally, resolve against the tile grid, then move vertically and resolve again. Corners solve themselves. Jump-through platforms fall out almost for free — a platform only stops you if your feet were above it on the previous tick, which is one comparison.
At 50Hz with speeds well under 8px/tick, nothing can tunnel through a tile, so the collision test is a single edge row/column scan. The whole thing is ~60 lines.
The lesson to take away
If you're building your own: do the fixed timestep first. Every other system — tuning, testing, replays, the feel itself — got easier because a tick is a tick is a tick. Variable-timestep platformers can be made to work, but you'll spend the project multiplying by delta-time and wondering why jumps feel different on a laptop.
Next milestone
Flick-screen room transitions (a world map of rooms, Willy-style), patrol enemies with the classic turn-at-edge and fixed-loop behaviours, and the first special tiles: conveyors, crumbling floors, ladders. That's the point where the engine stops being a lab and starts being a game.
Retro-64 is pure vanilla JavaScript — no frameworks, no build step. The engine lives at /public/retro64/ as plain ES modules you can read in the browser: engine/ is the machine, playground/ is this site's movement lab built on it.
The second era — data ate everything (July 2026)
The log above ends where the engine stopped being a lab. What happened next happened fast, and one idea drove all of it: if the first era proved that feel is data, the second proved everything else is too.
Look became data
The player learned to say what its body is doing — pose, every tick: walk, run, skid, turn, jump, fall, climb — and characters became consumers of that truth rather than re-derivers of it. Then characters themselves became JSON: 12×20 frames as hex strings with fallback chains, so two frames still animate and twelve frames animate beautifully. Dash was the proof; Myrtle the Turtle carries attack frames and a bow.
Combat became data
The moveset: stances, envelopes, cooldowns, hit shapes, committed lunges, knockback and stun, key routing, even fire-on-release for a drawn bow — a table in the profile, run by the engine. The stomp — landing on an enemy — is one row (stance: 'stomp'). Three retrofitted fighters (Chan, the karateka capstone, the ghouls' lance) deleted their hand-rolled combat and got better at it. Four fighting-school presets ship ready.
Consequences became data
Nothing vanishes silently now: the Outcomes layer plays a departure for any body — puff, burst, pop, zap, ghost, spark, the arcade flash — and every World runs it automatically, with a per-enemy Death style dial in the Designer. Bubbles float the "+100" and the "POW!" (they forced the engine's first pixel font). Collectibles grant — energy, shields, extra lives, timed speed and jump boosts, a freeze — engine-applied. Wins are felt before announced: the player celebrates a configurable victory lap before the endgame screen, and every game opens with a splash and closes with a proper card.
Worlds grew teeth and company
Generators pour Stalkers until you destroy the source; Fans lift you over the unjumpable; Pendulums, Drippers and Dart traps came in from the old country. The World seats a party now — nearest-player hunting, any-hero collection, drop-in co-op proven in The Undercroft — and the engine's arithmetic was quietly made lockstep-safe (sway() instead of Math.sin, sqrt instead of hypot) for the online rooms to come.
The games
Five full games shipped or doubled in this era: Myrtle the Turtle (sixteen screens, the whole fight in her profile), The Undercroft (four floors of Gauntlet with two-player drop-in), Chopper Run grew fire-teams and a jungle with bones in it, Sisters of the Crown doubled to six rooms with an updraft storm, and every game on the shelf gained splash screens, victory laps, speaking events and visible deaths.
The lesson of the second era
The first era's lesson was fixed timestep first. The second's: make the engine own the lifecycle and the game own the meaning. Every system above — poses, moves, outcomes, bubbles, effects — splits exactly there, and every retrofit got smaller because of it. When a game file shrinks while the game gets richer, the architecture is telling you it agrees.
Next milestone: online rooms — a lobby plugin and a deterministic-lockstep relay. The engine has been rehearsing for it all era.