A gentle 3D space explorer for young children (roughly ages 5–8). Four destinations so far: Earth, the Moon, Mars, and Saturn.
Tap a world — the big buttons along the bottom, or the planet itself in space — and you go there. One tap, one journey: ride with the spaceship along its safe route and arrive close enough to see the surface. Three real places are marked on each world — the first footprints on the Moon, the volcano on Mars, the Sahara from orbit — and finding one tells you about it and puts it in the discovery journal. One of the three is always round the back, so getting it means learning to drag. Or just look around and fly home.
Each world holds six real places and shows three of them, picked fresh each visit and weighted towards the ones you have not found, so going back to the Moon is not the same Moon. Having been there reveals Mars, and visiting Mars reveals Saturn. Find every place on all four worlds and the whole game is won — with a celebration to say so.
It installs to a home screen and works offline once loaded, with nothing ever leaving the device.
Built with Vite, TypeScript and Three.js. No backend or accounts; the checked textures, photographs and narration are bundled into the static offline build.
npm install
npm run dev
Then open http://localhost:5173.
The dev server already binds to every interface, so the LAN address works as-is:
npm run dev
Vite prints the address next to Network: when it starts — something like
http://192.168.1.x:5173. Type that into the browser on the device. Nothing else needs
configuring.
If it does not connect, it is almost always a firewall prompting (or silently blocking)
Node on a private network — allow it and reload. On Windows, ignore any 192.168.56.x
address: that is a VirtualBox adapter, not your WiFi.
npm run typecheck
npm test
npm run build
npm run narration:generate
npm run build type-checks first, then emits dist/. npm run preview serves that build
on the network the same way. npm test runs the unit tests — they cover the journal
persistence, collectible placement and visibility, touch-camera maths, photo dismissal,
narration cue coverage and the flight's easing curve — the pieces whose failure is easy
to miss by eye. Narration generation creates the offline MP3 cue pack from
src/audio/narration-script.json with a local open-weight model; it needs ffmpeg, but no
account or API key.
Planet and sky textures — earth.jpg, earth-night.jpg, moon.jpg, mars.jpg,
saturn.jpg and starfield.jpg in public/assets/, from Solar System
Scope, licensed CC BY
4.0. They are based on NASA elevation and
imagery data. Solar System Scope notes that unmapped areas are artistically completed and
colours are slightly saturated, so Saturn's body is a visual reconstruction rather than a
wholly observed global photograph.
Saturn's rings — the radial ring texture is derived from Cassini natural-colour mosaic
PIA06175, credit NASA/JPL/Space Science Institute. The source search and exact limitations
of the archival Cassini body map are documented in public/assets/README.txt.
Discovery photographs in public/assets/discoveries/ — Sahara/Africa and Amazon
River imagery from NASA Earth Observatory; Black Marble 2016 from NASA Goddard Space
Flight Center; Apollo 11 bootprint from NASA/Buzz Aldrin; Tycho and the lunar far side
from NASA/JPL/USGS and NASA/GSFC/Arizona State University; and Olympus Mons, Valles
Marineris and Elysium from NASA/JPL/USGS; and Saturn's rings, north-polar hexagon and
northern storm from NASA/JPL/Space Science Institute and NASA/JPL-Caltech/Space Science
Institute. All public domain. The source pages and image identifiers are listed in
public/assets/discoveries/README.txt.
Narration in src/audio/recordings/ — generated locally with
Kokoro-82M, whose model weights are licensed
Apache 2.0. The included voice is disclosed
as AI-generated on the grown-ups screen.
The Sun, the spaceship and everything else on screen is generated at runtime or built from Three.js primitives.
World-completion stickers also stay in the world: each earned reward becomes a bold, runtime-drawn decal on the spaceship, and finding everything adds the Space Ninja crest. They follow the saved progress record, so restarting a scene keeps the livery while Start a new adventure removes it with the other earned rewards.
design/ holds reference art that is not shipped — it deliberately sits outside
public/, because everything under public/ is copied into the deployed build whether
anything loads it or not.
The game generates all of its textures at runtime, so it looks finished with nothing
downloaded. To upgrade to real photography, drop image files into public/assets/ and
reload — the loader picks them up automatically and no code changes are needed. Anything
you add under a CC BY licence needs crediting in the list above.
Photographs of the places a child finds work the same way, and are the most worthwhile
thing you can add. Each discovery can show a real photograph of itself — a thumbnail in its
card, full screen on a tap — from a file named after it in public/assets/discoveries/.
That folder's README.txt names the specific NASA image used for each of the original twelve
places and why that one rather than another; the places added since have no file yet. They are entirely optional and added one at a time: a
place with no file simply has no photograph. Nothing is downloaded until a place is
actually found, so they cost the game nothing at startup, and a child who finds three
fetches three.
Earth is the one that needs care, because its colour and roughness maps are only correct
as a pair — see resolveEarthMaps in textures.ts. Supplying earth.jpg on its own is
the intended path: the roughness map is derived from it, so the sea catches the light and
the land does not. public/assets/README.txt has the details.
public/assets/README.txt lists the exact filenames, resolutions and where to get them.
The browser console logs one line per texture saying whether it used a file or a
placeholder.
The spaceship and celestial bodies are Three.js primitives. Each lives in its own module
(src/scene/Bodies.ts, src/scene/Spaceship.ts) so they can be swapped for GLB models
later without touching the flight or UI code.
index.html boot markup + inline loading/error/crash state
sw/ service worker (offline): sw.js template + build.ts, built into dist/
public/manifest.webmanifest web app manifest — installable, runs standalone
public/icons/ home-screen icons (icon.svg is the source; PNGs render from it)
src/
main.ts wiring, frame loop, teardown, offline + crash
config.ts scene scale, speeds, timings, and the destination copy
scene/
Stage.ts renderer, camera, bloom, resize, adaptive quality
quality.ts device tiering (low / medium / high)
Bodies.ts Sun, Earth + atmosphere, Moon, Mars, Saturn, lights
Spaceship.ts the ship, built from primitives
EngineTrail.ts the exhaust the ship leaves behind it
Starfield.ts gradient sky, star map, layered point stars
textures.ts load-a-file-or-generate-one, and the generators
controls/OrbitInput.ts drag to rotate, pinch/wheel to zoom
flight/FlightSequence.ts the scripted flight out to any destination
mission/CollectMission.ts the places to find, for any body
ui/ interface layer (ui.ts + ui.css + icons.ts)
audio/narration.ts keyed MP3 narrator + manual SpeechSynthesis fallback
audio/narration-script.json short child-directed lines for generated narration
audio/sfx.ts two synthesised cues, entirely optional
state/progress.ts discoveries, stickers and visits, persisted in localStorage
public/assets/ drop real textures here
It installs, and it works offline. A web app manifest makes Space Ninja add to a home
screen and run standalone — no address bar, no tabs, just the planet. Measured off a Surface
screenshot, browser furniture had been eating close to a fifth of the screen, so this is the
single biggest lever on how big the planet looks, and it is a small file. A service worker
precaches the app and the globe textures on the first visit, so from the second launch on the
game opens with no internet at all — which is squarely how a tablet game gets used: in a car,
on a plane, at a grandparent's with bad wifi. The discovery photographs stay lazy, fetched
only when a place is found and then kept, so the offline promise costs nothing at startup. The
worker is built from the bundle rather than hand-written, because Vite hashes its file names;
sw/build.ts is the pure, tested core of that. Nothing runs in development.
The grown-ups panel shows the deployed Git build id so an intermittent phone report can be
separated from an old service worker still serving the previous shell.
Privacy is a feature, so it is claimed. No backend, no accounts, no analytics — nothing ever leaves the device, and the journal lives in the tablet's own storage. The grown-ups panel says so in one line, because that is exactly what a parent wants to know before handing a tablet over.
A crash is made visible. An exception inside the frame loop used to leave the last good frame frozen on screen, which looks fine — the only signal was a child saying it stopped. The loop now catches it and shows a friendly "the spaceship stopped" screen with a button that reloads (the journal survives), plus the actual error in small print for a bug report.
Distances are compressed, hard. At true scale the Moon would be thirty Earth-diameters
away and invisible. config.ts holds the numbers; FRAMING_RADIUS is derived from the
Moon's orbit so a portrait phone — whose horizontal field of view is very narrow — never
loses the destination off the edge of the screen.
Rendering always goes through EffectComposer, even when bloom is disabled, so tone mapping and colour conversion happen in exactly one place for every material including the custom atmosphere and sky shaders.
Quality adapts. Hardware hints pick a starting tier, then real frame times are measured for a few seconds after startup and the tier steps down if the budget is being missed. Pixel ratio and bloom are the levers; geometry and texture sizes are fixed at construction.
The flight owns the camera outright. Orbit input is disabled and the orbits are frozen so the destination holds still. On arrival the ship is re-parented to the destination so it rides along, and the orbit controller re-derives its angles from wherever the camera finished — so control returns without a snap. The journey is deliberately cinematic: a drag used to add a safe sideways offset, but the chase camera followed that offset and made the ship appear almost stationary while its contrail wiggled. Removing that unexplained gesture leaves the child free to watch the destination approach and keeps interaction for the world itself, where a drag has an immediate purpose.
The solar-system view is scenery and navigation has dependable controls. Every world has a stable, finger-sized button along the bottom. The moving 3D bodies remain tappable, but finding a tiny speck in the widest Saturn shot is no longer the only route to the next flight.
One tap is one journey. Touching a world starts the flight — there is no separate Fly button. It used to take two presses, and the second one was somewhere else on screen, so the natural response to "I touched Mars and nothing happened" was to touch Mars again. That defeated adults as reliably as children. The worlds not yet earned still appear in the bar, padlocked: the bodies stay reveal-gated so an un-earned planet cannot loom into the shot, but hiding them completely also hid the fact that there was anywhere else to go.
The arrival is not a sequence. The gold places are on screen the moment the ship lands. Landing briefly played a staged introduction — a welcome, then the world turning through a whole day, then the targets — which put more than twenty seconds between pressing a world and being allowed to touch anything, on every visit. A tap skipped it, and needing a skip was the tell. The day turn is a button again.
The day/night button wears the thing it does. Its face is a small globe of the world you are at, half in night, with the terminator crawling across it — a working model of the action at the size of an icon, in the same space a drawn sun used to take. While a real turn runs that little globe is driven by the real one, so they turn together and finish together. And once every place on a world is found and nothing is happening, it pulses to say it is still there.
A hand shows the gesture when nothing is happening. After six seconds with nothing touched, a finger appears on a gold place and taps it; once only the hidden one is left, it sweeps across the planet instead. Every instruction here had become a caption written for somebody who cannot read, and narration answers that once. An arrow at the edge of the screen has never meant "put your finger down and slide it".
Destinations are data. DESTINATIONS in config.ts holds the copy; Bodies.ts holds
the geometry; main.ts matches them by id and builds a flight, a fact and a mission for
each the same way. FlightSequence.start() takes the destination as an argument, so
nothing in the flight knows which body it is aiming at — only its radius and where it is
right now. Adding the next planet is a config entry and a body, not new logic.
Mars gets its own compressed path around the scene centre, not a heliocentric orbit. At true scale it would be thousands of Earth-radii away and the Sun is already at 105; this keeps every destination inside one composable frame. Radii, though, stay true — the Moon really is 0.27 Earths and Mars really is 0.53 — because relative size is something a child can learn from a picture, and relative distance at this scale is unshowable.
The map only widens and reveals a world once it has earned the right to. Fitting every
destination from the first frame shrinks Earth and the Moon to specks, which is a poor first
impression for a child with no reason to care about the outer worlds yet. Visiting the
Moon widens the view and reveals Mars; visiting Mars does the same for Saturn. The new world
fades and grows into the settled home map, alongside its new destination button, rather than
appearing during a flight. Visiting, not finishing, is the gate: flying out, looking and
coming home is what this game is about, and locking the solar system behind a tapping task
would say otherwise. progress.ts therefore tracks visits separately from stickers: where
you have been and what you finished are different facts.
Collecting is ambient, not modal. The rocks are simply present when the ship arrives — there is no button that starts a mission and no state to be finished before leaving. That is a deliberate reversal: the collect mission used to be a mode, and the only way out of it was to complete it, which is what made "how do I get back?" the most common reaction to the game. Fly Home is on screen from arrival onward and never moves. The mission still exists, still awards its sticker, and still teaches the drag gesture — it just no longer holds the door shut.
A visit has a visual focus without losing its setting. The destination stays solid while the other earned worlds fade to quiet, still-visible context, and the parked spaceship does the same. A Moon or ship that crosses the camera can therefore no longer become an opaque wall over a real-coordinate target. Full colour returns as Fly Home pulls back to the map.
Earth is a destination too. A child's first instinct is to tap their own planet, and for a long time the game answered by doing nothing at all when they did. "Flying" to the planet you are already at is not a contradiction: the opening shot is a wide view of the whole neighbourhood, and this drops you into low orbit over it, close enough to pick out the Sahara. It needed no special case in the flight — home and destination being the same body simply leaves the departure axis at zero.
The places to find are real places. Each entry in config.ts carries the feature's
actual latitude and longitude, and the marker is placed from them onto the body's own
surface mesh — so the ring a child taps really is sitting on Tycho's rays or on Olympus
Mons, not somewhere plausible. That is the whole difference from the collectibles this
replaced: a rock could be anywhere, so finding one taught nothing about where you were.
The body is turned to face them, and the flight aims at them. Only two things about an arrival are free — which way the body happens to have rotated, and which latitude you approach over — and both are chosen from the destination's own list. The body turns about its own axis to bring the near ones round; the camera swings to the latitude they sit at. Neither moves a feature relative to another, so every angle between them stays true. Real missions time their arrivals for the same reasons.
One of them is always over the horizon, because reaching it needs a drag, which teaches the camera control through need rather than through instructions a five-year-old cannot read. On the Moon that one is round the far side, where having to go around to see it is the fact. There is a test for how far round it is: past the limb teaches the gesture, but far past it is half a turn of dragging across an unlit hemisphere, which a small child abandons.
The mission knows nothing about the Moon. CollectMission takes a CelestialBody and
derives marker size, hit-target size and particle scale from its radius, so the next
destination is a definition object rather than new code.
Every stateful module owns a reset(), and main.ts is the only caller. That is what
makes Fly Home work without reloading the page — the flight, the ship, the trail, the
world, the camera, the UI and the mission each undo exactly their own state. The bodies keep
orbiting throughout, so the Moon is deliberately not put back where it was. Fly Home
first eases the camera back out to the map over about a second (a reverse of the flight, so it
reads as a journey rather than a jump); the reset runs the moment that pull-back lands, on the
exact view it lands on, so nothing snaps.
You can turn a world through a day and watch morning arrive. Children playing this asked about the sunrise, which is better evidence than any of the reasoning elsewhere in this file. The scene had always answered the question correctly and never shown it: the city lights are masked by the world-space Sun direction and the sunlight is a world-space directional light, so turning the surface makes places cross into darkness with their lights coming on, and back out into morning. All of it already worked and none of it ever moved. Spin the Earth drives the rotation the surface hold is already reproducing — no new physics — and swings the camera side-on first, because the flight arrives near the sub-solar point where the day/night line hugs the limb and nothing appears to change. The explanation card folds before the turn starts, even while its narration is playing: this is the one moment where watching the light move is the entire lesson, so the speaker button may remain but the full-width words must not cover or compete with the globe.
A touch drag is distance, not frame-rate-dependent velocity. A full short-edge drag turns about 130 degrees and each pointer delta is applied once. Only the measured release speed becomes a capped, time-based glide. The previous code accumulated drag deltas into a value applied again on every animation frame, which made a high-refresh phone spin much farther than a 60Hz screen. Tests pin both sampling-rate independence and equal inertia at 30, 60 and 120fps.
A collectible looks like a target, not a light. It keeps its warm gold separation from grey Moon and rusty Mars, but its meaning comes from an opaque double ring with a dark keyline. The additive halo is now small and dim and renders behind that silhouette. Do not solve this by changing only the hue: the reported problem was that a warm luminous blob read as Sunlight rather than as something to tap.
A marker cannot be tapped through the planet it is on. The hit spheres are many times the size of the marker they surround, deliberately, so that a five-year-old's aim on a tablet is enough — and the raycast tests only those spheres, with no idea the body is in between. At Earth's arrival the Sahara and the hidden night-side marker project within thirty pixels of each other, one in front of the globe and one behind it, so tapping twice in the same place used to collect the far-side discovery through the whole planet. Every hit is now checked against the horizon the camera can actually see over.
The Moon keeps one face towards Earth, as the real one does — which is exactly why its far side went unseen until a spacecraft flew round the back, and the game says so to a child. Locking it means giving it no rotation of its own: the surface simply rides the orbit it already inherits.
The destination's surface is held still while you are there. A marker fixed to a
turning body slides out from under the finger reaching for it. What holdSurface() freezes
is the body's orientation against the stars, and the mission releases it on the way home.
Sound is synthesised and optional by design. Two cues, the flight's engine and the day turn's sunrise — all generated at runtime, no audio files. The AudioContext is created from the press that launches a flight, because mobile browsers start audio suspended and only allow it to resume inside a user gesture — and that press is the last one guaranteed to happen before the ship reaches somewhere with sounds to make. If Web Audio is missing the calls no-op. The two continuous sounds follow a value the picture is already using, frame by frame, rather than starting a timed ramp, so they stay with the picture on a slow device.
Good narration is the primary guide; the device voice is not. Exact keyed MP3 cues in
src/audio/recordings/ start automatically when sound is on. The card stays compact while
they play, but now keeps a labelled Show words button beside the photograph and replay
control; it reveals the full-width paragraph for a fresh reading window and becomes Hide
words. Each instructional cue also names a visible action — tap the gold target, or swipe
the planet — so a pre-reader is not being asked to infer a verb from prose. A missing cue
never auto-starts the browser's poor SpeechSynthesis voice; that fallback remains available
only from the speaker button. This makes a partial voice pack safe to ship and keeps silence
preferable to bad narration.
npm run narration:generate creates the MP3 pack locally with the Apache-licensed
Kokoro-82M model. The default British bf_emma voice is slowed slightly, then every cue is
normalised and compressed by ffmpeg for a phone speaker. The first run downloads about
90MB of model weights into a temporary cache; no script text or audio is sent to a service.
The command preserves existing files unless passed --force, and accepts --voice=<name>
and --speed=<number>. OpenAI remains an optional alternative via
npm run narration:generate:openai. Generated narration is disclosed as AI-generated in
the grown-ups panel. The MP3s are Vite assets, fingerprinted and precached by the existing
service worker, so playback is deterministic and offline.
Without recorded cues, the voice is chosen on the grown-ups panel. It appears by itself the first time the game is opened on a device and lists every voice that device offers, best first. Tap one to audition a real line; the last one tapped is remembered. This is a fallback, not the route for making audio primary.
Sound can be turned off there too, which covers the read-aloud voice as well and takes the speaker button away with it.
To open it again: press and hold the round book button for two seconds. A hold rather
than a visible button, because a settings control on screen is a settings control a
five-year-old will press — and the panel says so in writing, which works precisely because
the person it is hiding from cannot read it yet. ?grownups on the end of the address does
the same thing, which is the way back in if the browser's storage has been cleared
(?voices still works too).
Start a new adventure lives in that grown-ups panel. It takes two deliberate presses and removes only visits, discoveries and earned stickers. Sound, the one-time grown-up greeting and the offline installation remain device choices rather than game progress.
Reduced motion removes motion rather than speeding it up: prefers-reduced-motion
skips the exhaust trail and the widening view, removes camera inertia, thins the collect
particles and stops the UI animations. It deliberately does not shorten the flight or the
day turn any more — running the same sweeping camera move in a fifth of the time is more
motion per second, not less, which is the opposite of what the preference is asking for.
No planets past Saturn, no downloaded models, and no real orbital physics. Those are deliberately still out of scope.
All four worlds can be spun through a day — the button is a config entry rather than a special case. This was once true of Earth alone, on the reasoning that "why does the Sun come up?" is a question about here and answering it four times would dilute it. The dilution is real, so each world's card now names what is different about its own day (a fortnight of sunshine on the Moon, ten hours on Saturn) rather than all four reading "Day and Night". It is offered by its own button and never plays on its own; it was briefly an automatic arrival introduction on every world, which put eleven seconds of watching between arriving and being allowed to touch anything, four times over.
The Moon can wander into the shot while you are exploring Earth, and at these compressed distances it is large when it does. The flight steers its arrival clear of anything that would loom, but the camera then orbits on a shell that the Moon's own orbit crosses, so dragging far enough round will still find it. Moving the Moon out would change every other shot in the game, so it stays.
The included narration pack still needs a real-device listen and child playtest. Its audio
format, levels, duration and offline bundling are checked, but only a child can establish
whether the delivery actually prompts the intended tap or swipe. SpeechSynthesis remains
the manual fallback for any future cue whose MP3 has not yet been generated.
TypeScript
88.7%
CSS
8.3%
JavaScript
1.8%
HTML
1.1%
A gentle 3D space explorer for young children (roughly ages 5–8). Four destinations so far: Earth, the Moon, Mars, and Saturn.
Tap a world — the big buttons along the bottom, or the planet itself in space — and you go there. One tap, one journey: ride with the spaceship along its safe route and arrive close enough to see the surface. Three real places are marked on each world — the first footprints on the Moon, the volcano on Mars, the Sahara from orbit — and finding one tells you about it and puts it in the discovery journal. One of the three is always round the back, so getting it means learning to drag. Or just look around and fly home.
Each world holds six real places and shows three of them, picked fresh each visit and weighted towards the ones you have not found, so going back to the Moon is not the same Moon. Having been there reveals Mars, and visiting Mars reveals Saturn. Find every place on all four worlds and the whole game is won — with a celebration to say so.
It installs to a home screen and works offline once loaded, with nothing ever leaving the device.
Built with Vite, TypeScript and Three.js. No backend or accounts; the checked textures, photographs and narration are bundled into the static offline build.
npm install
npm run dev
Then open http://localhost:5173.
The dev server already binds to every interface, so the LAN address works as-is:
npm run dev
Vite prints the address next to Network: when it starts — something like
http://192.168.1.x:5173. Type that into the browser on the device. Nothing else needs
configuring.
If it does not connect, it is almost always a firewall prompting (or silently blocking)
Node on a private network — allow it and reload. On Windows, ignore any 192.168.56.x
address: that is a VirtualBox adapter, not your WiFi.
npm run typecheck
npm test
npm run build
npm run narration:generate
npm run build type-checks first, then emits dist/. npm run preview serves that build
on the network the same way. npm test runs the unit tests — they cover the journal
persistence, collectible placement and visibility, touch-camera maths, photo dismissal,
narration cue coverage and the flight's easing curve — the pieces whose failure is easy
to miss by eye. Narration generation creates the offline MP3 cue pack from
src/audio/narration-script.json with a local open-weight model; it needs ffmpeg, but no
account or API key.
Planet and sky textures — earth.jpg, earth-night.jpg, moon.jpg, mars.jpg,
saturn.jpg and starfield.jpg in public/assets/, from Solar System
Scope, licensed CC BY
4.0. They are based on NASA elevation and
imagery data. Solar System Scope notes that unmapped areas are artistically completed and
colours are slightly saturated, so Saturn's body is a visual reconstruction rather than a
wholly observed global photograph.
Saturn's rings — the radial ring texture is derived from Cassini natural-colour mosaic
PIA06175, credit NASA/JPL/Space Science Institute. The source search and exact limitations
of the archival Cassini body map are documented in public/assets/README.txt.
Discovery photographs in public/assets/discoveries/ — Sahara/Africa and Amazon
River imagery from NASA Earth Observatory; Black Marble 2016 from NASA Goddard Space
Flight Center; Apollo 11 bootprint from NASA/Buzz Aldrin; Tycho and the lunar far side
from NASA/JPL/USGS and NASA/GSFC/Arizona State University; and Olympus Mons, Valles
Marineris and Elysium from NASA/JPL/USGS; and Saturn's rings, north-polar hexagon and
northern storm from NASA/JPL/Space Science Institute and NASA/JPL-Caltech/Space Science
Institute. All public domain. The source pages and image identifiers are listed in
public/assets/discoveries/README.txt.
Narration in src/audio/recordings/ — generated locally with
Kokoro-82M, whose model weights are licensed
Apache 2.0. The included voice is disclosed
as AI-generated on the grown-ups screen.
The Sun, the spaceship and everything else on screen is generated at runtime or built from Three.js primitives.
World-completion stickers also stay in the world: each earned reward becomes a bold, runtime-drawn decal on the spaceship, and finding everything adds the Space Ninja crest. They follow the saved progress record, so restarting a scene keeps the livery while Start a new adventure removes it with the other earned rewards.
design/ holds reference art that is not shipped — it deliberately sits outside
public/, because everything under public/ is copied into the deployed build whether
anything loads it or not.
The game generates all of its textures at runtime, so it looks finished with nothing
downloaded. To upgrade to real photography, drop image files into public/assets/ and
reload — the loader picks them up automatically and no code changes are needed. Anything
you add under a CC BY licence needs crediting in the list above.
Photographs of the places a child finds work the same way, and are the most worthwhile
thing you can add. Each discovery can show a real photograph of itself — a thumbnail in its
card, full screen on a tap — from a file named after it in public/assets/discoveries/.
That folder's README.txt names the specific NASA image used for each of the original twelve
places and why that one rather than another; the places added since have no file yet. They are entirely optional and added one at a time: a
place with no file simply has no photograph. Nothing is downloaded until a place is
actually found, so they cost the game nothing at startup, and a child who finds three
fetches three.
Earth is the one that needs care, because its colour and roughness maps are only correct
as a pair — see resolveEarthMaps in textures.ts. Supplying earth.jpg on its own is
the intended path: the roughness map is derived from it, so the sea catches the light and
the land does not. public/assets/README.txt has the details.
public/assets/README.txt lists the exact filenames, resolutions and where to get them.
The browser console logs one line per texture saying whether it used a file or a
placeholder.
The spaceship and celestial bodies are Three.js primitives. Each lives in its own module
(src/scene/Bodies.ts, src/scene/Spaceship.ts) so they can be swapped for GLB models
later without touching the flight or UI code.
index.html boot markup + inline loading/error/crash state
sw/ service worker (offline): sw.js template + build.ts, built into dist/
public/manifest.webmanifest web app manifest — installable, runs standalone
public/icons/ home-screen icons (icon.svg is the source; PNGs render from it)
src/
main.ts wiring, frame loop, teardown, offline + crash
config.ts scene scale, speeds, timings, and the destination copy
scene/
Stage.ts renderer, camera, bloom, resize, adaptive quality
quality.ts device tiering (low / medium / high)
Bodies.ts Sun, Earth + atmosphere, Moon, Mars, Saturn, lights
Spaceship.ts the ship, built from primitives
EngineTrail.ts the exhaust the ship leaves behind it
Starfield.ts gradient sky, star map, layered point stars
textures.ts load-a-file-or-generate-one, and the generators
controls/OrbitInput.ts drag to rotate, pinch/wheel to zoom
flight/FlightSequence.ts the scripted flight out to any destination
mission/CollectMission.ts the places to find, for any body
ui/ interface layer (ui.ts + ui.css + icons.ts)
audio/narration.ts keyed MP3 narrator + manual SpeechSynthesis fallback
audio/narration-script.json short child-directed lines for generated narration
audio/sfx.ts two synthesised cues, entirely optional
state/progress.ts discoveries, stickers and visits, persisted in localStorage
public/assets/ drop real textures here
It installs, and it works offline. A web app manifest makes Space Ninja add to a home
screen and run standalone — no address bar, no tabs, just the planet. Measured off a Surface
screenshot, browser furniture had been eating close to a fifth of the screen, so this is the
single biggest lever on how big the planet looks, and it is a small file. A service worker
precaches the app and the globe textures on the first visit, so from the second launch on the
game opens with no internet at all — which is squarely how a tablet game gets used: in a car,
on a plane, at a grandparent's with bad wifi. The discovery photographs stay lazy, fetched
only when a place is found and then kept, so the offline promise costs nothing at startup. The
worker is built from the bundle rather than hand-written, because Vite hashes its file names;
sw/build.ts is the pure, tested core of that. Nothing runs in development.
The grown-ups panel shows the deployed Git build id so an intermittent phone report can be
separated from an old service worker still serving the previous shell.
Privacy is a feature, so it is claimed. No backend, no accounts, no analytics — nothing ever leaves the device, and the journal lives in the tablet's own storage. The grown-ups panel says so in one line, because that is exactly what a parent wants to know before handing a tablet over.
A crash is made visible. An exception inside the frame loop used to leave the last good frame frozen on screen, which looks fine — the only signal was a child saying it stopped. The loop now catches it and shows a friendly "the spaceship stopped" screen with a button that reloads (the journal survives), plus the actual error in small print for a bug report.
Distances are compressed, hard. At true scale the Moon would be thirty Earth-diameters
away and invisible. config.ts holds the numbers; FRAMING_RADIUS is derived from the
Moon's orbit so a portrait phone — whose horizontal field of view is very narrow — never
loses the destination off the edge of the screen.
Rendering always goes through EffectComposer, even when bloom is disabled, so tone mapping and colour conversion happen in exactly one place for every material including the custom atmosphere and sky shaders.
Quality adapts. Hardware hints pick a starting tier, then real frame times are measured for a few seconds after startup and the tier steps down if the budget is being missed. Pixel ratio and bloom are the levers; geometry and texture sizes are fixed at construction.
The flight owns the camera outright. Orbit input is disabled and the orbits are frozen so the destination holds still. On arrival the ship is re-parented to the destination so it rides along, and the orbit controller re-derives its angles from wherever the camera finished — so control returns without a snap. The journey is deliberately cinematic: a drag used to add a safe sideways offset, but the chase camera followed that offset and made the ship appear almost stationary while its contrail wiggled. Removing that unexplained gesture leaves the child free to watch the destination approach and keeps interaction for the world itself, where a drag has an immediate purpose.
The solar-system view is scenery and navigation has dependable controls. Every world has a stable, finger-sized button along the bottom. The moving 3D bodies remain tappable, but finding a tiny speck in the widest Saturn shot is no longer the only route to the next flight.
One tap is one journey. Touching a world starts the flight — there is no separate Fly button. It used to take two presses, and the second one was somewhere else on screen, so the natural response to "I touched Mars and nothing happened" was to touch Mars again. That defeated adults as reliably as children. The worlds not yet earned still appear in the bar, padlocked: the bodies stay reveal-gated so an un-earned planet cannot loom into the shot, but hiding them completely also hid the fact that there was anywhere else to go.
The arrival is not a sequence. The gold places are on screen the moment the ship lands. Landing briefly played a staged introduction — a welcome, then the world turning through a whole day, then the targets — which put more than twenty seconds between pressing a world and being allowed to touch anything, on every visit. A tap skipped it, and needing a skip was the tell. The day turn is a button again.
The day/night button wears the thing it does. Its face is a small globe of the world you are at, half in night, with the terminator crawling across it — a working model of the action at the size of an icon, in the same space a drawn sun used to take. While a real turn runs that little globe is driven by the real one, so they turn together and finish together. And once every place on a world is found and nothing is happening, it pulses to say it is still there.
A hand shows the gesture when nothing is happening. After six seconds with nothing touched, a finger appears on a gold place and taps it; once only the hidden one is left, it sweeps across the planet instead. Every instruction here had become a caption written for somebody who cannot read, and narration answers that once. An arrow at the edge of the screen has never meant "put your finger down and slide it".
Destinations are data. DESTINATIONS in config.ts holds the copy; Bodies.ts holds
the geometry; main.ts matches them by id and builds a flight, a fact and a mission for
each the same way. FlightSequence.start() takes the destination as an argument, so
nothing in the flight knows which body it is aiming at — only its radius and where it is
right now. Adding the next planet is a config entry and a body, not new logic.
Mars gets its own compressed path around the scene centre, not a heliocentric orbit. At true scale it would be thousands of Earth-radii away and the Sun is already at 105; this keeps every destination inside one composable frame. Radii, though, stay true — the Moon really is 0.27 Earths and Mars really is 0.53 — because relative size is something a child can learn from a picture, and relative distance at this scale is unshowable.
The map only widens and reveals a world once it has earned the right to. Fitting every
destination from the first frame shrinks Earth and the Moon to specks, which is a poor first
impression for a child with no reason to care about the outer worlds yet. Visiting the
Moon widens the view and reveals Mars; visiting Mars does the same for Saturn. The new world
fades and grows into the settled home map, alongside its new destination button, rather than
appearing during a flight. Visiting, not finishing, is the gate: flying out, looking and
coming home is what this game is about, and locking the solar system behind a tapping task
would say otherwise. progress.ts therefore tracks visits separately from stickers: where
you have been and what you finished are different facts.
Collecting is ambient, not modal. The rocks are simply present when the ship arrives — there is no button that starts a mission and no state to be finished before leaving. That is a deliberate reversal: the collect mission used to be a mode, and the only way out of it was to complete it, which is what made "how do I get back?" the most common reaction to the game. Fly Home is on screen from arrival onward and never moves. The mission still exists, still awards its sticker, and still teaches the drag gesture — it just no longer holds the door shut.
A visit has a visual focus without losing its setting. The destination stays solid while the other earned worlds fade to quiet, still-visible context, and the parked spaceship does the same. A Moon or ship that crosses the camera can therefore no longer become an opaque wall over a real-coordinate target. Full colour returns as Fly Home pulls back to the map.
Earth is a destination too. A child's first instinct is to tap their own planet, and for a long time the game answered by doing nothing at all when they did. "Flying" to the planet you are already at is not a contradiction: the opening shot is a wide view of the whole neighbourhood, and this drops you into low orbit over it, close enough to pick out the Sahara. It needed no special case in the flight — home and destination being the same body simply leaves the departure axis at zero.
The places to find are real places. Each entry in config.ts carries the feature's
actual latitude and longitude, and the marker is placed from them onto the body's own
surface mesh — so the ring a child taps really is sitting on Tycho's rays or on Olympus
Mons, not somewhere plausible. That is the whole difference from the collectibles this
replaced: a rock could be anywhere, so finding one taught nothing about where you were.
The body is turned to face them, and the flight aims at them. Only two things about an arrival are free — which way the body happens to have rotated, and which latitude you approach over — and both are chosen from the destination's own list. The body turns about its own axis to bring the near ones round; the camera swings to the latitude they sit at. Neither moves a feature relative to another, so every angle between them stays true. Real missions time their arrivals for the same reasons.
One of them is always over the horizon, because reaching it needs a drag, which teaches the camera control through need rather than through instructions a five-year-old cannot read. On the Moon that one is round the far side, where having to go around to see it is the fact. There is a test for how far round it is: past the limb teaches the gesture, but far past it is half a turn of dragging across an unlit hemisphere, which a small child abandons.
The mission knows nothing about the Moon. CollectMission takes a CelestialBody and
derives marker size, hit-target size and particle scale from its radius, so the next
destination is a definition object rather than new code.
Every stateful module owns a reset(), and main.ts is the only caller. That is what
makes Fly Home work without reloading the page — the flight, the ship, the trail, the
world, the camera, the UI and the mission each undo exactly their own state. The bodies keep
orbiting throughout, so the Moon is deliberately not put back where it was. Fly Home
first eases the camera back out to the map over about a second (a reverse of the flight, so it
reads as a journey rather than a jump); the reset runs the moment that pull-back lands, on the
exact view it lands on, so nothing snaps.
You can turn a world through a day and watch morning arrive. Children playing this asked about the sunrise, which is better evidence than any of the reasoning elsewhere in this file. The scene had always answered the question correctly and never shown it: the city lights are masked by the world-space Sun direction and the sunlight is a world-space directional light, so turning the surface makes places cross into darkness with their lights coming on, and back out into morning. All of it already worked and none of it ever moved. Spin the Earth drives the rotation the surface hold is already reproducing — no new physics — and swings the camera side-on first, because the flight arrives near the sub-solar point where the day/night line hugs the limb and nothing appears to change. The explanation card folds before the turn starts, even while its narration is playing: this is the one moment where watching the light move is the entire lesson, so the speaker button may remain but the full-width words must not cover or compete with the globe.
A touch drag is distance, not frame-rate-dependent velocity. A full short-edge drag turns about 130 degrees and each pointer delta is applied once. Only the measured release speed becomes a capped, time-based glide. The previous code accumulated drag deltas into a value applied again on every animation frame, which made a high-refresh phone spin much farther than a 60Hz screen. Tests pin both sampling-rate independence and equal inertia at 30, 60 and 120fps.
A collectible looks like a target, not a light. It keeps its warm gold separation from grey Moon and rusty Mars, but its meaning comes from an opaque double ring with a dark keyline. The additive halo is now small and dim and renders behind that silhouette. Do not solve this by changing only the hue: the reported problem was that a warm luminous blob read as Sunlight rather than as something to tap.
A marker cannot be tapped through the planet it is on. The hit spheres are many times the size of the marker they surround, deliberately, so that a five-year-old's aim on a tablet is enough — and the raycast tests only those spheres, with no idea the body is in between. At Earth's arrival the Sahara and the hidden night-side marker project within thirty pixels of each other, one in front of the globe and one behind it, so tapping twice in the same place used to collect the far-side discovery through the whole planet. Every hit is now checked against the horizon the camera can actually see over.
The Moon keeps one face towards Earth, as the real one does — which is exactly why its far side went unseen until a spacecraft flew round the back, and the game says so to a child. Locking it means giving it no rotation of its own: the surface simply rides the orbit it already inherits.
The destination's surface is held still while you are there. A marker fixed to a
turning body slides out from under the finger reaching for it. What holdSurface() freezes
is the body's orientation against the stars, and the mission releases it on the way home.
Sound is synthesised and optional by design. Two cues, the flight's engine and the day turn's sunrise — all generated at runtime, no audio files. The AudioContext is created from the press that launches a flight, because mobile browsers start audio suspended and only allow it to resume inside a user gesture — and that press is the last one guaranteed to happen before the ship reaches somewhere with sounds to make. If Web Audio is missing the calls no-op. The two continuous sounds follow a value the picture is already using, frame by frame, rather than starting a timed ramp, so they stay with the picture on a slow device.
Good narration is the primary guide; the device voice is not. Exact keyed MP3 cues in
src/audio/recordings/ start automatically when sound is on. The card stays compact while
they play, but now keeps a labelled Show words button beside the photograph and replay
control; it reveals the full-width paragraph for a fresh reading window and becomes Hide
words. Each instructional cue also names a visible action — tap the gold target, or swipe
the planet — so a pre-reader is not being asked to infer a verb from prose. A missing cue
never auto-starts the browser's poor SpeechSynthesis voice; that fallback remains available
only from the speaker button. This makes a partial voice pack safe to ship and keeps silence
preferable to bad narration.
npm run narration:generate creates the MP3 pack locally with the Apache-licensed
Kokoro-82M model. The default British bf_emma voice is slowed slightly, then every cue is
normalised and compressed by ffmpeg for a phone speaker. The first run downloads about
90MB of model weights into a temporary cache; no script text or audio is sent to a service.
The command preserves existing files unless passed --force, and accepts --voice=<name>
and --speed=<number>. OpenAI remains an optional alternative via
npm run narration:generate:openai. Generated narration is disclosed as AI-generated in
the grown-ups panel. The MP3s are Vite assets, fingerprinted and precached by the existing
service worker, so playback is deterministic and offline.
Without recorded cues, the voice is chosen on the grown-ups panel. It appears by itself the first time the game is opened on a device and lists every voice that device offers, best first. Tap one to audition a real line; the last one tapped is remembered. This is a fallback, not the route for making audio primary.
Sound can be turned off there too, which covers the read-aloud voice as well and takes the speaker button away with it.
To open it again: press and hold the round book button for two seconds. A hold rather
than a visible button, because a settings control on screen is a settings control a
five-year-old will press — and the panel says so in writing, which works precisely because
the person it is hiding from cannot read it yet. ?grownups on the end of the address does
the same thing, which is the way back in if the browser's storage has been cleared
(?voices still works too).
Start a new adventure lives in that grown-ups panel. It takes two deliberate presses and removes only visits, discoveries and earned stickers. Sound, the one-time grown-up greeting and the offline installation remain device choices rather than game progress.
Reduced motion removes motion rather than speeding it up: prefers-reduced-motion
skips the exhaust trail and the widening view, removes camera inertia, thins the collect
particles and stops the UI animations. It deliberately does not shorten the flight or the
day turn any more — running the same sweeping camera move in a fifth of the time is more
motion per second, not less, which is the opposite of what the preference is asking for.
No planets past Saturn, no downloaded models, and no real orbital physics. Those are deliberately still out of scope.
All four worlds can be spun through a day — the button is a config entry rather than a special case. This was once true of Earth alone, on the reasoning that "why does the Sun come up?" is a question about here and answering it four times would dilute it. The dilution is real, so each world's card now names what is different about its own day (a fortnight of sunshine on the Moon, ten hours on Saturn) rather than all four reading "Day and Night". It is offered by its own button and never plays on its own; it was briefly an automatic arrival introduction on every world, which put eleven seconds of watching between arriving and being allowed to touch anything, four times over.
The Moon can wander into the shot while you are exploring Earth, and at these compressed distances it is large when it does. The flight steers its arrival clear of anything that would loom, but the camera then orbits on a shell that the Moon's own orbit crosses, so dragging far enough round will still find it. Moving the Moon out would change every other shot in the game, so it stays.
The included narration pack still needs a real-device listen and child playtest. Its audio
format, levels, duration and offline bundling are checked, but only a child can establish
whether the delivery actually prompts the intended tap or swipe. SpeechSynthesis remains
the manual fallback for any future cue whose MP3 has not yet been generated.
TypeScript
88.7%
CSS
8.3%
JavaScript
1.8%
HTML
1.1%