A Marble Madness–style arcade game for the iPhone, built on Three.js with a hand-written shader pipeline. Ten courses, tilt or thumbstick controls, no asset files — every surface, sound and particle is generated at runtime.
There is no MeshStandardMaterial anywhere, no cubemap render target, no
EffectComposer, no physics library, and not a single image or audio file.
The marble mirrors the live sky by evaluating the same procedural skyColor()
the skybox uses, with the reflection vector instead of the view vector.
▶ Play it — best on a phone.
npm install
npm run dev # stick controls; plain http
npm run dev:https # required if you want TILT on a real phone
npm run buildThe dev server binds to 0.0.0.0, so the "Network" URL it prints is reachable
from a phone on the same Wi-Fi. On iOS, add it to your home screen to get a
fullscreen, chrome-free window.
| Tilt | Stick | |
|---|---|---|
| Steer | tilt the phone | drag the left pad |
| Jump | JUMP button | JUMP button |
Pick one under Steering on the title screen. Keyboard (WASD/arrows + space) works everywhere for desktop testing.
iOS will not release the motion sensors outside a secure context, and a LAN
http://192.168.x.x origin is not one. Over plain http the sensors are not
merely denied — Chrome and Safari hide DeviceOrientationEvent altogether, so
there is nothing to prompt for.
npm run dev:httpsThis serves over TLS with a self-signed certificate, so the phone shows a warning the first time: Show Details → visit this website on Safari, Advanced → Proceed on Chrome. After that, tap Tilt on the title screen and accept the iOS motion prompt.
If your phone refuses to accept the self-signed certificate, use a tunnel with
a real one instead — any of these gives a trusted https:// URL:
npx cloudflared tunnel --url http://localhost:5173
npx localtunnel --port 5173Your choice is remembered. Tilt cannot be switched back on at page load — the permission API requires a user gesture — so the game restores it on the tap that starts your next run, and falls back to the stick (clearing the preference) if that fails.
Whatever attitude you are holding when tilt is enabled becomes "level", and Pause → Re-centre tilt resets it. Axes are rotated by the current screen orientation, so landscape does not swap or invert the controls, and rotating the phone re-centres automatically.
If enabling tilt fails, the title screen says exactly why — insecure origin, permission denied, no sensor, or permission granted but no readings arriving. That last case matters: permission is not the same as data, so the game waits 1.5s for a real reading and falls back to the stick if none comes, rather than leaving you holding a phone that steers nothing.
Steering is camera-relative: tilting right always moves the marble right on screen, whatever yaw the current course uses.
| # | Name | Introduces |
|---|---|---|
| 1 | First Roll | rolling, momentum |
| 2 | Mind the Gap | gaps, jumping, boost strips |
| 3 | The Labyrinth | a maze with one way through |
| 4 | Descent | a spiral of ramps, hazard tiles |
| 5 | Black Ice | near-zero friction, sticky brake zones |
| 6 | Clockwork | moving platforms that carry you |
| 7 | Collapse | tiles that fall as you touch them |
| 8 | Pinball | bumpers, crossing boost strips |
| 9 | The Gauntlet | an 89-tile maze with everything in it |
| 10 | Freefall | a 40-unit descent, movers and hazards |
Each has a time limit rather than lives; falling costs you seconds, not a run.
Checkpoints move your respawn. Progress and best times persist in
localStorage.
There is no MeshStandardMaterial anywhere. Every draw is a ShaderMaterial
written for this game.
- Sky (
render/Environment.ts) — a procedural gradient with a sun disc, atmospheric scattering, a twinkling star field and an fbm nebula, evaluated per-pixel from a reconstructed view ray on a single clip-space quad. - Marble (
render/MarbleMaterial.ts) — domain-warped fbm veining evaluated in object space, so the pattern turns with the ball and you can read its spin. Reflections call the sameskyColor()the sky uses, with the reflection vector instead of the view vector — the ball genuinely mirrors the live sky (sun, stars, nebula) with no cubemap render target anywhere. Plus fresnel-weighted reflectivity, thin-film iridescence, metallic flake sparkle, and friction heat at speed. - Surfaces (
render/SurfaceMaterial.ts) — one material for the entire level. Per-block variation rides on vertex attributes (aType,aState,aFaceUv,aFaceSize), so the whole static course renders in one draw call while each block still gets its own look: ice crackle, scrolling boost chevrons, crumble fractures that glow as the tile dies, goal rings, hazard hatching.aFaceSizelets the shader inset a panel outline by a constant world distance, so a huge platform and a small tile get identical trim. - Shadows (
render/ShadowPass.ts) — our own orthographic depth pass, RGBA8-packed, texel-snapped to stop edge shimmer, with 3×3 PCF and normal-offset bias. Three's shadow system is deliberately unused: it would couple every material to three's internal lighting chunks. - Post (
render/Post.ts) — a hand-rolled chain rather thanEffectComposer, for exact control over how many half-res passes run: threshold → 5-step downsample pyramid (13-tap) → 5-step tent upsample → composite (radial speed blur, lateral chromatic aberration, bloom, ACES, sRGB, vignette, grain) → optional FXAA. - Sparks (
render/Sparks.ts) — particles simulated entirely in the vertex shader from spawn state; emitting costs one buffer write and the CPU never touches a particle again.
Aimed at a 60fps budget on A-series hardware:
- Device pixel ratio is capped at 2, and an adaptive scaler walks the render scale between 0.55 and 1.0 based on a smoothed frame time. Its thresholds straddle the vsync interval (22ms down / 18ms up) — a step-up threshold below 16.7ms would strand the scaler at whatever value it first dropped to and never recover on a 60Hz panel.
- FXAA only runs when the effective pixel ratio drops under 1.6 — which is both when aliasing actually shows and when the pass is cheapest.
- Surfaces use a cheap
skyAmbient()gradient; only the marble (a small fraction of the screen) pays for the fullskyColor()with its noise. - The static level is merged into a single buffer at load; only movers, crumbling tiles and animated pads get their own meshes.
Custom, in physics/Physics.ts — a sphere against oriented boxes, stepped at a
fixed 120Hz with an accumulator.
- Two friction coefficients, and the difference is the whole feel. A sphere
rolling without slipping has a contact point with no relative velocity, so
kinetic friction does no work on it — what slows a real marble is rolling
resistance, one to two orders of magnitude smaller. Spin here is derived
from linear velocity rather than integrated as angular momentum, so the
marble is rolling by construction. Contacts blend between
rollingand Coulombfrictionby impact speed: resting contact coasts, real collisions scrub. Applying Coulomb friction to everything made the marble decelerate at μg ≈ 21 u/s² — a sliding block — and no ramp in the game was climbable. It now loses ~4 u/s² at speed, and momentum is something you manage. - Contacts are solved in the surface's frame, so moving platforms carry the marble exactly (measured: ball velocity matches platform velocity to 0.01).
- Restitution is suppressed below 1.6 u/s, which is what stops a resting ball buzzing on a flat floor. The rolling/Coulomb blend starts above 0.6 u/s so the gravity a resting ball accumulates between substeps does not read as an impact — that alone was trebling the rolling resistance.
- Steering acceleration is projected onto the ground plane so you drive up a ramp instead of into it; air control is cut to 32%.
- Coyote time (0.12s) on jumps.
- Rolling spin is integrated from ω = (n × v)/r for the visual only. Verified by deriving ω back out of the orientation each frame and checking the contact point is stationary — which is what "rolling without slipping" means.
Ramps are authored as two surface endpoints and the tilt is solved for
(ramp() in game/levels.ts); a 2.4-over-5 ramp measures back as a 25.6°
contact normal in game.
Fixed-yaw follow camera for the isometric feel, with occlusion avoidance: since every course is walled, the camera searches each frame for the least extra pitch that gives a clear line to the marble, then eases toward it — fast to uncover, slow to settle. Without it the ball vanishes behind rails constantly.
src/
core/ Engine (loop, sizing, adaptive resolution), Input, Audio
physics/ sphere-vs-OBB solver
render/ glsl chunks, Environment, ShadowPass, materials, Post, Sparks
game/ Game (state machine), Level (block compiler), levels, Progress
ui/ screens, HUD, touch pads
tools/reachability-audit.js answers the only question that matters: can the
marble get from spawn to goal? Run a dev build, open the console, paste the
file, then await audit.all(). audit.map(i) prints an ASCII map of what is
reachable, which is how you find where a route breaks.
Run it after touching level geometry. It builds a height field by raycasting onto every collider, sweeps moving platforms along their paths, discards surfaces narrower than the marble, and flood-fills with ballistic gap jumps that have to clear whatever they fly over.
Two traps it exists to catch, both of which shipped:
rails()builds a closed ring. Around a deck on the route that is a cage — rails are 0.9 wide and the marble is 1.16, so it can neither pass through nor balance on top. Passopensides, or userailWithGap().- Jump clearance is only 2.15 under the ball. Anything taller than that is a wall, not an obstacle, and bumpers do not help — they throw you sideways, reaching barely 1.0 of lift even at full speed.
Levels are data. game/levels.ts has helpers (plat, ramp, mover,
boost, rails, railWithGap) plus fromGrid(), which compiles an ASCII map — # wall,
I ice, X crumble, O bumper, ~ sticky, S/G/C spawn/goal/checkpoint
— run-merging identical tiles along X to keep both vertices and colliders down.
The two mazes were machine-generated (recursive backtracker + braiding) and
flood-fill verified solvable.
Every push to main typechecks, builds and publishes to GitHub Pages via
.github/workflows/deploy.yml. Live at
https://johnwlockwood.github.io/marble-loco/.
Two things make that work, and both are easy to break:
base: './'in the Vite config. A Pages project site is served from/marble-loco/, and an absolute base would 404 every asset there.- HTTPS, which Pages provides automatically. This is not cosmetic — iOS refuses the motion sensors outside a secure context, so tilt controls do not exist without it.
Production sourcemaps are off; the map is ~2.9MB against a 604KB bundle. Build
with SOURCEMAP=1 npm run build when you need a readable stack trace from a
real device.
To put it on your own domain: add the domain in the repo's Pages settings, then
point DNS at GitHub — an ALIAS/ANAME at the apex, or a CNAME to
johnwlockwood.github.io for a subdomain. Pages provisions the certificate.
Nothing in the build needs changing; base: './' works at the root too.
MIT © John Lockwood.
Three.js is BSD-3-Clause and is the only runtime dependency.