Game expansion: Specification · Phased implementation checklist.
Play RETRO BALL in your browser
A multi-course, isometric marble game in the spirit of Marble Madness, rebuilt as a Synthwave / Outrun fever dream: glowing grid slabs floating in a purple nebula, sweeping laser grids, data voids, light-elevators, jump pads, VCR scanlines and a synth soundtrack whose kick drum drives the glow.
npm install
npm run dev # http://localhost:5173/retro-ball/
npm run build # type-checks, then bundles to dist/
| Key | Action |
|---|---|
| Arrows / WASD | Roll (relative to the isometric camera) |
| R | Reboot at the last checkpoint / re-run |
| M | Mute |
| F (or ⛶) | Enter / exit browser fullscreen |
| Esc (or ⚙) | Open / close the settings panel |
| Space | Boot from the intro screen |
Screen-up pushes the marble "into" the screen (world -x, -z), so following a
corridor that runs up-right means holding Up + Right, exactly as in the
original arcade cabinet's trackball logic.
The brief asked for a fixed isometric orthographic view, neon wireframe / translucent grid surfaces, chromatic bloom, VCR artefacts and a marble that lights the track. That list is a post-processing pipeline plus a handful of bespoke shaders, and Three.js is the best fit for exactly that:
OrthographicCamerawith a fixed direction (src/render/Renderer.ts) gives the true isometric look. The camera never rotates, so player input can be mapped through a constant ground basis (groundBasis()), which is what makes controls feel predictable.EffectComposer+UnrealBloomPass+ a customShaderPass(src/render/PostFX.ts). Bloom provides the soft neon halo; the VCR pass adds radial chromatic aberration, scanlines, an aperture grille, tracking slips, block glitches (spiked on boot, death and win), a rolling VHS band, grain, vignette and flicker.OutputPassperforms ACES tone mapping last so bright neon cores burn toward white the way real phosphor does.- Bespoke
ShaderMaterials instead of stock PBR for the level. The grid material (src/render/GridMaterial.ts) draws anti-aliased 1-tile grid lines from a per-vertex tile-space attribute, tints top faces differently from sides, adds a slow travelling light band, and, crucially, adds a distance-based glow from auMarblePosuniform so the marble's pink light visibly washes over the slabs beneath it. Doing this in the shader is cheaper and far more controllable than relying on a point light alone (a point light is still attached to the marble for the chrome shell itself). - Fat lines (
LineSegments2/LineMaterial) for slab edges. WebGL's native lines are 1 px; the addons' screen-space lines give crisp 2 px neon edges that bloom evenly at any resolution. - Geometry merging. Static pieces are merged into one draw call per colour tone, and their 12 edges each into one fat-line batch. A 60-piece course renders in a handful of draw calls, leaving the frame budget for the full-screen bloom chain.
- PMREM environment for the marble.
buildNeonEnvironment()bakes a tiny scene of pink/cyan light strips into a prefiltered environment map so the chrome marble reflects neon bands instead of a flat colour.
Babylon.js would also have worked, but its strengths (a full scene editor pipeline, node materials, built-in glow layers) are heavier than needed for a single hand-authored track, and Three.js's post-processing addons made the VCR/bloom look a two-file job. WebGPU was ruled out for portability.
The marble has to feel like heavy chrome, move fast, bounce predictably and never tunnel through a thin ledge at 20+ m/s. Rapier was chosen over cannon-es / Ammo / a hand-rolled solver because:
- Continuous collision detection is a one-liner (
setCcdEnabled(true)), and the marble is small and fast, which is precisely the tunnelling case. - Rust-compiled WebAssembly solver with a fixed 120 Hz sub-step
(
src/physics/Physics.ts) keeps contact response stable on ramps and edges regardless of the display refresh rate. - Kinematic position-based bodies make the light-elevators trivial: the
platform is moved each step with
setNextKinematicTranslationand friction carries the marble up naturally. - Restitution combine rules let walls bounce harder than floors (
Maxcombine rule, 0.75 on bumpers vs 0.35 on track) so chicane rebounds feel arcade-like while landings stay grounded. - The
-compatbuild inlines the WASM as base64, sonpm run devworks with no special bundler configuration.
All numbers live in TUNING (src/physics/Physics.ts):
- Gravity is
-36(about 3.7 g). Heavier gravity makes ramps and landings read as weighty and keeps jump arcs short and readable in an isometric view. - Input applies an impulse at the centre of mass plus a matching torque, so the sphere both slides and spins up; friction converts one into the other and the result is ~20 m/s² of effective acceleration, i.e. about a second from rest to top speed. Air control is about a third of that.
- A soft speed cap only cancels the component of input that would push the
marble beyond
maxSpeed(16 u/s). Braking and steering always stay available, and downhill ramps can still exceed the cap, which is where the "the marble feels fast" moments come from. - Moderate linear damping (0.25) and angular damping (0.35) produce a heavy coast-down that still settles when you release the keys.
- Jump pads solve the ballistic launch analytically for their target so every launch lands on the same spot, no matter the entry speed.
src/content/levels/legacy.json stores the original course, with
src/game/LevelData.ts retained as its regression fixture. The shared document
format describes courses declaratively (slabs, ramps,
walls, jump pads, elevators, lasers, voids, checkpoints, goal). The route winds
through roughly 50 × 65 tiles, several screens in each direction:
- Boot sector with a bumper chicane, then a ramp onto the laser plateau (one sweeping beam that parks, safe and blue, at each end of its sweep).
- A railed pink ledge with a jog, then checkpoint A and a jump pad over a void.
- A corridor to light-elevator 1, a high platform with checkpoint B and a 20-tile downhill speed ramp.
- The void field: a wide slab with data voids on its outer lanes. The centre lanes are clear, so the ramp exit is a straight brake run.
- A railed ledge to checkpoint C, a timed laser gate, and light-elevator 2.
- The elevated skyway (checkpoint D at its start) through three timed laser gates that open in travel order, a second jump pad onto a floating island with checkpoint E, and a walled drop ramp.
- A final railed S-bend of ledges, one last sweeping laser, and the GOAL portal.
Difficulty notes: ledges are at least 3 tiles wide (marble diameter is 1) and
carry low bumper rails on every open edge, so a drift is a bounce rather than
a fall. Lasers are always visible: a live beam burns red, a safe beam turns
cool blue, and a beam flickers from blue toward red for 0.5 s before it goes
live. Sweeping lasers park, safe, for 2.5 s at each end of their sweep
(dwell), which is the crossing window. Gated lasers are safe for 2.8 s of
every 4 s. Jump pads solve
their own trajectory and their trigger is wider than the pad graphic.
Elevators are solid light columns (the shaft is never an open hole). The
reference route is verified end to end by the dev autopilot (see below).
Falling off, touching a live beam, or dropping into a void triggers a glitch-burst reset at the last of five checkpoints. The system clock keeps running, so resets cost time.
The soundtrack, Retro Ball (Main Theme), is an original composition
written for this project and dedicated to the public domain (CC0). Rather than
ship an MP3, src/audio/Soundtrack.ts performs it live with the Web Audio
API: a 112 BPM, eight-bar A-minor loop (Am F C G | Am F Dm E) with a
four-on-the-floor kick, claps, hats, a side-chained octave bass, a plucked
16th-note arpeggio through a dotted-eighth delay, detuned saw pads through a
convolution reverb, and a lead phrase over the second half of the loop.
Because the sequencer schedules every kick itself, the exact kick times are
known in advance. beatEnergy() returns a value that spikes to 1 on each kick
and decays exponentially, and it drives:
- grid-line brightness and edge opacity of every slab,
- bloom strength and chromatic aberration,
- the marble's core, halo and point light,
- jump-pad rings, elevator beams, the goal portal and the nebula.
Sound effects (src/audio/Sfx.ts) are synthesised on the same graph:
speed-driven rolling noise and hum, impacts scaled by the velocity change,
jump-pad sweeps, per-hazard death sounds, a checkpoint chime and a win
arpeggio. Audio unlocks when Space starts the game (browser autoplay policy).
Settings has independent Music and Sound FX switches, saved across reloads. M temporarily mutes both without changing those choices; unmuting restores only enabled categories. Music can be off while the visual beat continues.
src/
main.ts bootstrap + frame loop
game/
LevelData.ts declarative level definition
Level.ts static geometry (merged grid meshes + fat-line edges)
Dynamics.ts jump pads, elevators, lasers, voids, checkpoints, goal
Game.ts state machine: intro -> play -> reset -> win
Marble.ts chrome shell, core, halo, light, trail, env map
Burst.ts neon shard particles for death/win
physics/Physics.ts Rapier wrapper + tuning constants
render/
Renderer.ts isometric orthographic camera
GridMaterial.ts neon grid surface shader
PostFX.ts bloom + VCR pass (MSAA target, bloom toggle)
Background.ts nebula (optionally offscreen), floor grid, stars, data shards
settings/Settings.ts quality settings + localStorage persistence
audio/ procedural soundtrack, SFX, glue
input/Input.ts keyboard -> screen-space axis
ui/ HUD, overlays, settings panel
debug/Autopilot.ts dev-only waypoint driver used for automated play-tests
In development builds window.__retro exposes app and the active session’s
game, input, autopilot,
debug and applySettings. First load a course through the relay or
await __retro.app.loadLevel('legacy'); no session exists while at the hub.
autopilot.start() drives the marble around the reference
route (used to verify the course is completable end to end);
debug.stepsPerFrame / debug.fixedDt run the simulation faster than real
time for headless testing; game.godMode ignores hazard deaths.
npm test (Node 22.6 or later) checks slab overlap, bend footprints, void
clearance and the void field's floor coverage. npm run build
checks TypeScript and the production bundle.
For GPU checks, start the dev server, then use an installed agent-browser:
agent-browser --session retro-check open http://127.0.0.1:5173/retro-ball/
agent-browser --session retro-check wait --fn '!!window.__retro'
agent-browser --session retro-check eval "window.__retro.app.loadLevel('legacy')"
agent-browser --session retro-check eval --stdin < tests/rendering.browser.js
agent-browser --session retro-check eval --stdin < tests/voids.browser.jsThe browser check reads the HDR scene and post-processing buffers at every checkpoint, elevator and the goal, with antialiasing and bloom on and off. It fails on non-finite pixel values or WebGL errors. Inspect both bends while moving the camera as well: pixel validity alone does not detect depth flicker. The void check drops the marble into each opening from five positions and verifies a void death followed by a respawn at checkpoint B.
For the full rendering/audio/void/course suite, run npm run test:browser.
For recorded frame/physics/resource measurements, run npm run benchmark:browser.
Both require the dev server and installed agent-browser. See the
phase-0 baseline for configuration, budgets and evidence.
Phase 1 runtime and content contracts describe the relay selector, course lifecycle, validation, component factories and checkpoint snapshots. P pauses; the course toolbar provides retry and return-to-relay.