A lunar rover survey game that runs in a browser tab.
No engine, no build step, no npm install, no asset files.
One vendored library: three.js.
Two hundred and fourteen days ago the far-side outpost Beacon-9 sent four seconds of empty carrier and stopped.
You operate MU-7 CASSIOPEIA, a 900 kg six-wheel survey rover put down by descent sled on the floor of Anaxagoras at 73° north. Survey the basin, restore the relay chain, and find out what is under the floor — and why the dossier does not say what the station was for.
On a phone the instrument list is cut to what you steer by, and the bottom of the screen belongs to your thumbs.
▶ Watch the 28-second demo — 1280×720, with sound.
This is a toy. It started as a single prompt — verbatim, typos and all:
"Create me a fully playable release-ready game. On GPU. Game about moon discovery with rover with gravity, moon sand and etc.. I need it in 3D and not a voxel game. Make it with maximum effort, show me best what you can create. Use all your power, knowledge and make best graphics and best game design. Surprise me with graphics, lore and everything."
That got a playable build. Everything after it came from sixteen rounds of "this bit is wrong, fix it" — steering inverted, wheels floating off the chassis, a HUD covering half a phone screen, a settings panel that had been collapsing to a 2 px strip since the first commit. Nothing here was planned up front. The physics constants, the photometric model, the lore, the sound design and the layout of the instrument panel all arrived one complaint at a time.
There are no downloaded assets, and the game fetches none at runtime — not even a failed request. Every texture, every rock, every star and every sound is generated by code at load time — partly a design constraint, partly the reason the whole thing is ~380 KB over the wire and loads in seconds.
It is not a finished game. It is a real one: it has a beginning, five missions, an ending, and a rover that will dig itself a 48 cm hole if you sit there with the throttle down — deeper than its own axle line, and there is no rescue mechanic for it.
| 6,325 | lines of JavaScript in src/ |
| 592 | lines of CSS |
| 0 | npm dependencies, build steps, and asset files the game loads |
| 5 | missions · 12 codex entries · 9 sample types |
| ~380 KB | gzipped download (1.68 MB raw), including a vendored three.js |
winchxyz.github.io/moon-rover — that is the whole install.
Chrome/Edge 89+, Firefox 108+, Safari 16.4+, iOS 16.4+. It needs WebGL2 and —
because there is no build step — native import maps, and it is the import
map rather than WebGL2 that sets those floors. Works on phones and tablets with
twin thumbsticks. Progress autosaves to localStorage every 20 seconds.
npm startThen open http://localhost:5173. Any static file server works — the only
requirement is HTTP rather than file://, because it uses ES modules.
node server.js 8080Node 18+ for the server. There is nothing to install; npm start just runs
server.js, which has no dependencies either.
It is a static site with no build step, so GitHub Pages needs nothing special:
push the repo, then Settings → Pages → Source: Deploy from a branch, main
and / (root). Every path is relative, so it works from a subdirectory without
configuration.
W S |
Throttle / reverse |
A D |
Steer — and pivot on the spot when stopped |
Space |
Brake |
X |
Right the chassis after a rollover |
G |
Ground-penetrating radar sweep (78 m) |
R |
Deploy / stow the sampling arm |
W S A D |
With the arm out: reach and swing it |
LMB |
Drill at the aim point |
B |
Deploy relay beacon |
T |
Deploy / stow the solar array |
E |
Interact (hold) |
F |
Headlights |
C |
Cycle camera · P photo mode |
K |
Save the current frame as a PNG |
Tab |
Field codex |
Esc |
Pause / systems |
H |
Toggle HUD |
Mouse looks, wheel zooms. A gamepad drives, steers, brakes and looks, and covers
scan, arm, lamp, relay, camera, codex and pause — but the drill, the solar array,
interact, righting the chassis, photo mode, HUD and the frame grab are still
keyboard and mouse, so a pad is a second controller rather than a complete one.
On phones and tablets you get twin
thumbsticks and two button groups, including HUD to clear the instruments off
the screen. They stack as columns beside the sticks — except on a landscape
phone, where they drop into a single row between them.
The chase camera drifts back behind the rover only after you have left the mouse
alone for about three seconds — set CAMERA AUTO-CENTRE to OFF under Esc if
you would rather it never moved on its own. HUD SIZE scales every instrument
panel together.
Two settings make it harder and more honest:
DRIVE ENVELOPE swaps the arcade profile for an LRV one: 13 km/h instead of 30, a 45-second core instead of 4, and a pack that lasts about twenty minutes of hard driving instead of two and a half. Every speed threshold that shapes the feel — the rover's steering taper, pivot, hill-hold and traction control, the camera's dolly, field of view and auto-centre, the motor's pitch and the grind under the wheels — is a fraction of that one top-speed number, so the steering still loads up, the camera still dollies and the motor still sounds worked; they just do it at a real rover's pace. (Wheel slip is still measured absolutely, because slip is a relative velocity rather than a road speed.)
The battery is the one number that is deliberately not realistic, and it is worth saying why rather than fudging it quietly. An LRV pack is 8.7 kWh and about 57 km of range; this basin is 864 m across, so a real pack would cross it sixty-six times. Even VIPER's much smaller 450 Wh is hours. There is no honest capacity that leaves power as a source of tension at this scale — so realistic mode does not invent one. It slows the drain eightfold, which turns power from a panic into planning, and moves the pressure to time, where the slow drive, the slow drill and the signal delay already put it.
SIGNAL DELAY puts you where a Lunokhod driver actually sat. Drive commands
cross to the Moon and the picture comes back: 1.28 s each way, 2.56 s before you
see the rover do what you told it. Measured in-game, at LRV speed, releasing the
throttle costs you 21 m of coast instead of 11 — you brake three rover
lengths earlier than feels right, or you hit the boulder you already stopped for.
Arm aiming and the camera are deliberately not delayed; that is a playability
call, not a claim about physics, and the reasoning is in main.js.
Both realism settings switched on: the LRV drive profile and the Earth round trip.
Everything below is in this repository, in plain ES modules you can open and read. The interesting parts are not the rendering tricks — they are the places where the physics and the pixels are forced to agree.
1.62 m/s². The suspension is sized for lunar weight — 1458 N total, about 9 cm of static sag. Earth spring rates would make the rover act like a steel bar; the first version used them and it did. Peak traction is roughly 1250 N, so the rover accelerates at about 1.3 m/s² and lets go of the surface long before you expect it to.
A rigid body with a real inertia tensor and six raycast wheels, each with spring-damper suspension, slip-based tyre forces inside a friction circle, and pressure-based sinkage — a saturating linear model, not a true Bekker pressure-sinkage curve. Contact pressure over the bearing strength of the top few centimetres of regolith (~12 kPa) gives 1–2 cm of sink at static load and about 6 % of weight in rolling resistance, which is roughly what Apollo measured.
The wheel-spin integration is solved semi-implicitly, because the hub's inertia is tiny next to the slip stiffness and an explicit step oscillates and then explodes:
spinNew = (w + dt*aT + dt*K*R*vLong/I) / (1 + dt*K*R*R/I)The wheels cut real geometry into the height field the physics reads back: a ~10 cm trough per side with the displaced regolith piled into berms along both flanks, so the track catches raking light and throws its own shadow.
Spin a wheel and it stops settling and starts digging — hold the throttle down and you will excavate a hole and drop into it. Turn traction control off first: it ships on, and backing the hub off before it buries itself is exactly its job. Compacted rut floor never slumps; churned rut floor creeps back over a couple of seconds. (Sinkage is a function of wheel load alone — there is no soil map, so every square metre of the basin digs the same.)
There is no air, so there is no billowing cloud and no settling haze. Every
grain flies a clean parabola at 1.62 m/s² — gravity is the only force on it —
and lands where the arithmetic says. That single fact is what makes lunar
rooster tails look lunar. One honest limit: a grain is budgeted the lifetime its
own parabola needs over flat ground (2·v/g, plus half a second), so one flung
out over ground that falls sharply away fades out before it touches down.
The surface uses Lommel–Seeliger rather than Lambert, which is why the Moon reads as a flat disc instead of a shaded ball, plus a coherent-backscatter opposition surge that washes the ground out when you drive down-sun. There is no air and so no aerial perspective to inherit — which is exactly why lunar photographs have no sense of scale. The shader keeps an authored falloff past 700 m anyway, down to about a third of full brightness by 3.4 km, purely as a depth cue: with none at all the rim wall reads as a black cut-out rather than a mountain.
A grazing sun that circles the horizon rather than arcing overhead, bobbing between 17° and 31° and taking about seventeen minutes of play to come round. Shadows sweep around you like a sundial. That band is authored, not derived: the Moon's 1.54° axial tilt caps the real sun near 18° at 73° north, and lets it set every lunation. A higher sun that never sets is what buys enough light to drive by and a rim wall that still keeps its own floor dark for hours.
Crater shadows come from a baked sun-occlusion mask that ray-marches the height field, with a penumbra sized to the sun's real 0.53° angular diameter — which is why lunar shadow edges look like they could cut you.
A full pack is about two and a half minutes of hard driving through shadow — nearer two with the lamps on, since below −30 °C the survival heaters come on too. The array charges only with the sun actually on it, so the panel angle and your own shadow both count. Parked on the sled you charge regardless, which is what makes the sled a base rather than a landmark.
Running flat does not strand you, but it is not free either. Hub torque scales with charge below 25 %, down to a floor of 18 % — enough to limp home, not enough to climb anything. The radar and the drill are harder gates: both refuse outright if the pack cannot cover the cycle.
At the default HIGH tier, a nine-level geometry clipmap: 0.16 m cells under the wheels out to 41 m cells on an outer ring 6.55 km across — 3.3 km straight ahead, 4.6 km to the corners — with ~370 k triangles and zero per-frame CPU geometry work. (ULTRA is also nine levels; MEDIUM is eight and LOW seven, with proportionally coarser cells.) Heights are baked once on the CPU into R32F textures with a box-filtered mip chain; each ring samples the mip matching its own cell size.
Without that mip chain the coarse rings interpolate straight across crater bowls
and leave a row of tents on the skyline. R32F rather than half-float because
16-bit quantises height to 12 cm at 160 m, and you can see it. The exception is
a device without OES_texture_float_linear: there the chain is never uploaded,
every ring reads mip 0, and the coarse rings alias — the trade for keeping the
surface smooth under the wheels, which is the next section.
The GPU never re-derives a height. It samples the same baked field the wheels
do, with a CPU bilinear filter written to match GL's. Where
OES_texture_float_linear is missing, the shader filters in software rather
than falling back to blocky nearest sampling.
This is the constraint the whole terrain system is built around: if the two disagree where the rover is standing, it floats or sinks, and no amount of tuning fixes it.
Farther out they are allowed to part company on purpose. Every ring but the innermost is drawn 10 % of its own cell size low, so the LOD seam falls below the finer ring instead of poking through it — up to 4 m of deliberate sag on the 41 m ring — and the whole surface is bent down quadratically with distance to fake a horizon. Neither has a counterpart on the CPU. Nothing drives out there, so nothing notices.
A 4096² field at 0.25 m per texel on HIGH and ULTRA (2048² and 0.5 m on LOW and MEDIUM), spanning ±512 m so the whole crater interior and rim crest sit inside it, CPU-authoritative and uploaded as small dirty rects. Only the touched rectangles are re-uploaded, tracked as region lists rather than a growing union — the union version reached 1.7 M cells and 62 ms/frame before it was replaced.
The grid is sized by the quality tier, so changing tier has to move it. It is resampled rather than cleared: the field holds your own ruts and drill pits, and throwing them away on a settings change would be the worse bug. A 4096 → 2048 step costs about 2 mm of rut depth, which is the honest price of halving the grid and does not compound while you stay put.
Instanced, with a triplanar procedural surface: mottled plagioclase, chipping normals that fade with distance so pebbles do not alias, and regolith dust settling on every up-face.
The field is always scattered at the densest tier's count and the lower tiers draw a prefix of it, so a boulder is in the same place at every setting. The alternative — re-rolling the scatter per tier — slides rocks around a player who is driving between them, and a boulder is a collider, not just a sprite. What the tier hides is also un-collided, so nothing invisible can stop you.
Linear HDR through the composer, bloom, ACES, and a final pass that treats the image as what it is in fiction — a camera bolted to a rover, with sensor noise that rises in shadow and a veiling glare toward the sun.
The framebuffer is capped by total pixel count, not by device pixel ratio.
This matters more than it sounds: a Retina MacBook reports devicePixelRatio 2,
so the obvious setPixelRatio(dpr) on a 1710-point-wide window renders
3420×2136 — 7.3 megapixels
through a shader that ray-marches and triplanar-samples. Capping pixels instead
brings that to 2.4 Mpx while still rendering above native CSS resolution.
let px = Math.min(devicePixelRatio, quality.maxDpr);
const over = (w * h * px * px) / quality.pixels; // pixels, not ratio
if (over > 1) px /= Math.sqrt(over);On top of that a governor watches a rolling frame time and trades resolution for smoothness between 100 % and 62 % before you notice, giving it back when there is headroom. A quality tier is guessed once from the device — reported memory and core count, with a touch screen costing one tier rather than pinning you to the bottom — and everything else follows from it: clipmap density, shadow map size, MSAA, the excavation grid, the trail and sun-mask buffers, boulder density and the dust budget.
Changing it under Esc re-fits all of them live. That costs 11–16 ms, or
130–310 ms when the excavation grid has to change size and be resampled.
Nothing re-bakes the basin — the height field does not depend on the tier, so
the expensive half of the load is reusable, which is the only reason a tier
change can be a hitch rather than a reload. MSAA stays off above 3.2 Mpx of
framebuffer whatever the tier asks for.
Every instrument dimension is a multiple of one CSS variable, so the whole panel set scales from a single knob and keeps its proportions instead of forty pixel values drifting apart. Instrument type carries a floor the scale cannot push through: shrinking the HUD takes space off canvases, bars and padding, never off the labels you steer by.
.hud { --u: 1px; --s: calc(var(--u) * var(--hud-k)); }
.sys { width: calc(214 * var(--s)); }
.gauge label { font-size: max(8px, calc(8.5 * var(--s))); }Scaling alone stops working somewhere around tablet size. A 390 px screen cannot carry mission control at any size you can still read — shrink far enough and you have six panels of 6 px type covering half the view. So phones drop instruments instead, and the survivors get the room: objective, speed, power, integrity, map. The scale unit goes up from the tablet's and the type with it, while the canvases go the other way — the map is cut to a little over half its tablet width — which is how five panels still fit a 390 px screen.
The compass strip, sample bay, thermal, status chips, wheel monitor, radar scope and the mission clock are reference readouts nobody reads mid-corner, so on a phone they are cut outright. Be clear about the cost: they do not move to the pause panel, which is settings, and nothing else brings them back. In landscape the speed dial goes too — there is no room for it.
The other rule is that thumbs own the bottom of the screen. From tablet size
up the HUD moves above the sticks, keyed on a class set by the code that
actually mounts them rather than a pointer: coarse media query, since those
two can disagree and the question that matters is whether there are thumbsticks
on screen right now. Phones do not need the hook: at that width the HUD is on
the top edge on the breakpoint alone, and the class-keyed blocks are bounded
away from it so exactly one layout wins per device.
A landscape phone is short rather than narrow, and it is the case that breaks every assumption. The HUD runs along the top edge there instead of stacking, and the buttons stop being columns: five stacked buttons plus a stick is 300 px of a 370 px screen, so the columns climbed the left and right edges straight into the mission panel and the map. They become a single row along the bottom, in the dead strip between the two sticks, which hands the entire top of the screen back to the HUD.
| viewport | HUD coverage | smallest type |
|---|---|---|
| 393 × 852 · phone | 22 % | 8.6 px |
| 932 × 370 · phone, landscape | 27 % | 8.5 px |
| 375 × 667 · small phone | 34 % | 8.6 px |
| 820 × 1180 · tablet | 18 % | 8.0 px |
| 1180 × 820 · tablet, landscape | 28 % | 8.0 px |
| 1440 × 900 · desktop | 26 % | 8.0 px |
Nothing carries sound out there. What you hear is what the chassis carries, so all of it is structure-borne and dull; the only bright things in the mix are electrical — the radio, the GPR chirp, the alarms. Everything is synthesised at runtime and there are no audio files.
The motor is one band-limited pulse train — a commutating hub — fed through a bank of fixed resonators standing in for the chassis, at 172 / 418 / 905 Hz. That split is what makes it read as a machine rather than a siren: the excitation pitch rides the wheel speed while the body formants stay exactly where they are, and load opens the upper ones.
Regolith is granular, because gravel is not a filtered hiss — it is thousands of separate impacts. A quiet bed carries the body and individual grains are scattered on top at a rate set by wheel speed and slip, each with its own pitch, filter and stereo position.
index.html shell, boot screen, HUD markup
server.js zero-dependency static server
src/
main.js bootstrap, loading, menus, frame loop 689
core/
engine.js renderer, composer, pixel budget, quality 288
input.js keyboard / mouse / gamepad / touch 203
audio.js procedural WebAudio 413
rng.js deterministic noise shared by CPU and bake 81
save.js localStorage 20
world/
terrain.js bake, clipmap, excavation, trails, sun mask 1106
props.js boulders, the sled, Beacon-9, relays 553
sky.js stars, Milky Way, sun, Earth, IBL 346
dust.js ballistic regolith 183
textures.js procedural Earth and lunar albedo 130
game/
rover.js the machine and its physics 885
gameplay.js radar, drill, power, missions 538
lore.js codex, samples, mission definitions 188
camera.js chase / orbit / mast / photo 174
ui/
hud.js instruments, minimap, codex 528
styles.css scale system, three-row HUD grid, responsive 592
vendor/three/ three.js r160, postprocessing + geometry addons (MIT)
docs/ the screenshots in this README
node server.js 5173 --shots adds a POST /__shot?n=<name>&ext=<ext> endpoint
that writes an image into .shots/ — used to capture frames while testing. Off
by default; the release server accepts no writes of any kind.
window.REGOLITH exposes the running app, plus REGOLITH.tick(dt) to advance a
single frame by hand — which is how every screenshot in this README was taken,
and how the demo video was rendered.
The demo's soundtrack is not a screen recording. Nothing here plays back an
audio file, so there was nothing to record: the capture pass wraps
audio.update() and logs the exact parameters the live engine receives on every
frame, plus each one-shot and the frame it fired on. The whole graph is then
rebuilt on an OfflineAudioContext and replayed against that log. An offline
context's currentTime does not advance while you schedule against it, so
Audio takes an explicit timeBase and every scheduling site reads now()
rather than the context clock. Picture and sound come out of the same
deterministic pass, which is why the motor pitch tracks wheels you can watch
turning.
Project code: MIT — do what you like with it.
vendor/three/ is three.js r160, MIT, copyright the
three.js authors.
There are no other third-party assets. Every texture and every sound is generated at runtime by code in this repository.
"Shadows here do not shorten at midday. They only turn."






