For a while, bedtime ended with the same game. My son and I took turns describing a planet that couldn’t exist: a mushroom planet with a red cap and white spots, a rainbow planet, a planet with an elephant’s trunk. One rule — nothing from Earth. Then we’d pretend to visit it and ask about the weather and the inhabitants.
The planets were good. They lasted about ten minutes, until he fell asleep, and then they were gone. No page, no drawing, no recording. The next night started somewhere else.
I wanted somewhere to put them. Not a note — a place. So I built Wonder Planet (奇怪星球): a desktop browser app where a child talks, and the planet they describe becomes something they can see, land on, name, and keep.

What Wonder Planet Is
It’s a voice-first expedition game for kids roughly four to nine. The whole thing is one continuous five-act adventure: jump to an unknown star system, scan the planet from the cockpit, land and record what happened, name it in a ceremony, stamp it into the captain’s log. No forms. The child holds a button, talks to the ship’s robot, and the ship does the rest.
The app is desktop-only, by choice. The expedition lives in a cockpit with three screens and a window, and that needs at least 1024 pixels of viewport to feel like a place. A four-year-old’s hands don’t fit portrait mode, so I didn’t build a compromised mobile version.

That also means no sign-up and no public build yet; it runs locally. I’ll come back to that.
From a Sentence to a Planet
The planet pipeline was the most expensive and slowest part, so it changed the most.
The first design was procedural: a shape enum — sphere, mushroom, rainbow arch, elephant trunk, invisible — with a builder per shape, assembled in Three.js. The probe rendered a mushroom planet in seconds at zero marginal cost.
But the enum was a gate on the creation space. If a child says “a planet with an elephant’s trunk,” the code can fall back to a sphere, refuse, or wait for me to write another builder. Every new shape is a development ticket. That’s not a product, it’s a wish list.
So I killed it. The reversal is the point of the project:
- The description goes to an LLM (DeepSeek V4-Flash by default, OpenAI-compatible) and comes back as planet DNA: a Zod-validated JSON with shape, palette, terrain, atmosphere, inhabitants and special properties, versioned so the renderer can change without breaking old planets.
- Seedream, Volcengine’s image model, draws a 2048×2048 sheet with four orthographic views: front, back, left, right.
- The child confirms it. They see only the front view; if they don’t like the planet, they re-scan. Images are cheap enough to reject a few.
- Only after confirmation does the sheet get sliced into four views and sent to image-to-3D: Tripo3D’s multiview endpoint online, or ComfyUI on TRELLIS 2 / TripoSR locally. Both return a GLB.
- Three.js loads the GLB. The child can spin it and zoom in.
Four views are what make the back real.

The 3D generation runs fully in the background. It takes minutes; the expedition doesn’t wait — landing, story and naming don’t need the model. It’s revealed at the end when the log is stamped; if it isn’t ready, the forge keeps working and the reveal waits for the next visit. If it fails, the app falls back to a procedural sphere that keeps the DNA’s colors and terrain. The old approach didn’t die; it got demoted to a safety net.
The Story Is the Point
The planet is the stage. What I actually want my son to practice is telling a story that holds together.
Which is why the story step cannot be skipped. The robot asks three questions in order: what happened? then what? how did it get solved? After each answer it repeats the key points back; if an answer is too thin it asks one contextual follow-up. If the child stalls, the answers get stitched into a minimal story and the expedition continues. Simple is fine. Broken is not.
A pinyin toggle puts romanization above the characters so a kid can read their own story; the parent can fix transcription in place.
Once the story is confirmed, the comic chain starts in the background: the LLM polishes it, splits it into 4–8 panels, and generates them concurrently with a rate limit. Each panel gets a short caption and no text inside the image — that’s typeset in HTML, because models still garble characters. Reference images are per panel, not per story: when a panel’s scene includes the ship, the captain or the robot, that asset’s reference art is injected, and every panel also gets the planet’s visual summary so colors and terrain stay consistent. The ship is snapshotted into the story, so changing ships later never rewrites an old memory.
The probe found the failure mode early: panel four was supposed to show the lighthouse mushroom glowing, and the model put the rainbow light on the ship instead. The fix isn’t a better model — it’s a redraw-one-panel button, an explicit visual subject per panel, and a storyboard that names it. When the image service is down, the app says so and produces nothing. A half-broken comic is worse than an honest error.

Waiting is the other design problem. 3D takes minutes, comics take minutes, and progress bars are the worst possible answer. The naming ceremony is the buffer: ribbon unfurls, name lights along an arc, ship plants a flag — and by the time it’s over, the first panels are ready. Afterward, “next page” only appears once the next panel exists. The child sees a story growing, not a loading state.
What Broke (and What It Taught Me)
Browser text-to-speech silently ate words. The first reading voice was speechSynthesis. On my machine, speak() produced no onstart, onerror or onend; speaking and pending stayed false; cancel(), resume() and gesture retries did nothing. That’s not fixable from the application layer — the engine is unreliable. The whole path is gone from the codebase and must not come back. Reading now goes one way: server-side Edge TTS via msedge-tts (pure JS over WebSocket, no key, no native module) returns an MP3 that the page plays through <audio>. Fixed lines are pre-generated; dynamic ones are synthesized and cached by text hash. If TTS fails, captions stay and the page never auto-advances — silent fast-forward is worse than no sound.
Browser speech recognition is not usable here. Chrome’s Web Speech routes through Google’s online service, which doesn’t work on mainland China networks — I confirmed that on my own connection. Recognition runs server-side with sherpa-onnx and a zipformer Chinese model, CPU-only. The 598 MB model stays out of the repo and loads lazily behind a native module, so the API starts without it. The recording is resampled to 16 kHz PCM16 WAV and uploaded; the audio is never written to disk, because a child’s voice shouldn’t be stored by default. If that fails, Web Speech is the fallback; if that fails, a parent types.
Rendering bugs don’t show up in code review. Before scaffolding anything, I built a probe: one HTML file, Three.js from a CDN, no build step. It caught a bug that code review would have missed. Every surface decoration — huts, mushroom people, grass — was buried inside the cap. The Y-coordinate formula was wrong; nothing threw, the scene just rendered with its inhabitants hidden. The fix was one shared ellipsoid height function. Since then, rendering self-tests take headless screenshots. If the screenshot doesn’t show a planet, the code isn’t done.
Under the Hood
It’s a pnpm monorepo:
apps/web— React 18 + Vite + Tailwind + react-three-fiber. The expedition is a single-route scene state machine: discovery, scan, landing, story, naming, log.apps/api— Hono on Node, Drizzle over better-sqlite3. Image generation, image-to-3D, TTS and ASR run server-side; keys stay in a local.dev.varsand never reach the frontend bundle.packages/dna— shared Zod contracts for planet DNA and storyboard panels, with schema versions.packages/renderer— the Three.js renderer and quality tiers, plus the headless screenshot suite.
About 24,000 lines of TypeScript and TSX, more than 260 vitest cases; typecheck and production build pass.
The architecture is local-first but portable. SQLite is a file, images are a directory, identity is one local profile — but every row carries a user_id and identity access goes through one adapter, so multi-account later doesn’t need a data migration. The cloud move is meant to be a swap, not a rewrite: Hono on Node → Pages Functions, Drizzle + SQLite → D1, local disk → R2, Vite → Pages static hosting. None of it is deployed yet, and I won’t pretend otherwise.
Where It Stands
The five-act expedition works end to end in a local build. The project site is live at wonderplanet.app, with two gameplay recordings:
- 发现新的星球 — discovering a new planet (2:50): warp, scan, resolve, land.
- 舰长日志与故事朗读 — captain’s log and story reading (2:30): revisit a planet and hear its story read aloud.
The game itself is not publicly playable. No real accounts, no hosted version. Next: better-auth multi-account sign-in, then a Cloudflare deploy with D1 and R2. After that: co-visiting other captains’ planets, leaving messages, and a captain avatar whose three-view sheet gets injected into comic generation so the kid appears in his own stories.

What surprised me wasn’t how good the models got. It was how concrete “keeping an imagination” is: a planet, a comic that reads itself, a log you can walk back into. My son won’t remember what he said on a random Tuesday. The log will.
If you’re building something similar, or you just want to make a planet with your kid, I’d like to hear about it.