An infinite voxel world in your browser tab. No install, no assets, no server — every block, texture, and cloud is computed from one seed.
▶ Play it now: vincent-p-essy.github.io/minecraft-clone
Captured from the running production build with seed 12345, after moving through the terrain and breaking and placing blocks.
A Minecraft-inspired voxel game built from scratch with Three.js and TypeScript: procedurally generated infinite terrain with forests, deserts, snowy peaks and cave systems, a full day/night cycle, and free block building — with your edits persisted across reloads.
| Desktop | Touch | |
|---|---|---|
| Move / jump / sprint | WASD · Space · Shift |
left-thumb joystick (push to edge to sprint) |
| Swim | Space in water (hop out at the surface) |
jump button in water |
| Look | mouse (pointer lock — click to start) | drag the right half of the screen |
| Break / place a block | left click (hold to repeat) / right click | tap to break · place button |
| Pick a block | 1-9 or scroll wheel |
tap a hotbar slot |
| Share your world | add ?seed=<number> to the URL |
same |
It plays on your phone. On a touch device the game grows a floating joystick, a look surface, and jump/place buttons — no keyboard needed:
If your browser refuses pointer lock (some extensions and embedded contexts block it silently), the desktop game switches to drag-to-look on its own. And if your machine has no WebGL at all, the game doesn't apologize — it switches to its own CPU raycaster and keeps playing:
Same world, same physics, same controls — zero GPU. This mode exists because a real player hit exactly that.
flowchart LR
S[("seed")] --> T["terrain.ts<br/>fBm heightmap · biomes<br/>3D-noise caves · trees"]
T --> W["Web Worker<br/>chunk generation"]
W -->|transferable buffers| ST["streamer.ts<br/>load/unload around player"]
E[("localStorage<br/>edit overlay")] --> ST
ST --> M["Web Worker<br/>greedy meshing · per-vertex AO"]
M -->|transferable geometry| R["Three.js scene<br/>texture-array shader<br/>day/night sky · clouds"]
P["player<br/>swept AABB physics<br/>DDA voxel raycast"] --> ST
P --> R
- Terrain is a pure function of (seed, world position). Height, biome, cave carving, and even tree placement are all recomputable by any chunk independently — a canopy that leans across a chunk border gets identical blocks stamped from both sides without any chunk-to-chunk communication. That one design decision is what makes the world seamless and the generator trivially parallelizable.
- Generation and meshing run in Web Workers. Terrain generation returns block buffers as transferables; a second worker holds a resident mirror of the loaded chunks and does the greedy meshing off the main thread, streaming finished geometry back (also transferable, zero copy). The main thread never blocks on a remesh, so the frame rate holds steady while you explore or dig. Results are generation-tagged, so a mesh that finishes after its chunk was re-edited or unloaded is dropped, not shown stale. Without workers it falls back to synchronous meshing.
- The mesher greedy-meshes with real ambient occlusion. Per-vertex AO
(the classic "0fps" formulation, with each quad's diagonal flipped toward
the brighter vertex pair to kill the anisotropic dark-streak artifact),
and then coplanar faces that share a tile and the exact same 4-corner AO
pattern merge into one big quad — ~25% fewer triangles over real terrain,
and far fewer on flat expanses. Because merged quads span many blocks,
chunks render from a WebGL2 texture array through a custom shader that
repeats the tile with
fract()(an atlas would bleed into its neighbors), keeping one draw call per chunk and the crisp per-block grid. - Zero art assets. The entire texture atlas (grass, sand, wood rings, bark, water...) is drawn onto a small canvas at startup with a seeded PRNG. The hotbar icons are cropped from the same canvas.
- Physics never touches the renderer. Movement is a pure per-axis swept
AABB resolver over a
(x, y, z) => solid?closure; block targeting is an Amanatides-Woo DDA raycast that visits every voxel boundary (a thin wall can't be skipped the way fixed-step sampling would). Both are unit-tested against synthetic terrain with no DOM or WebGL anywhere in sight. - Edits survive. Broken and placed blocks are stored as a sparse
overlay in
localStorage, keyed by seed, and re-applied over freshly generated chunks — an explored world costs kilobytes, not megabytes. - Two renderers, one game. The WebGL path draws meshed chunks with baked AO; the CPU path marches one ray per pixel straight through the block data (no meshes at all) with the same face shading, fog, water tint, clouds and day/night palette. Everything else — world, streaming, physics, interaction — is shared and renderer-blind.
- It runs fast on weak hardware, and stays fast. The CPU raycaster only traces a real ray for one pixel in four: each traced ray records a compact hit signature, and the pixels between them interpolate wherever their neighbors agree (flat surfaces, sky, open water) and re-trace only across silhouettes and edges — a quarter of the ray budget for a near-identical image, so it holds a high internal resolution. Block reads in the hot loop go through a flat chunk lookup table, not a hash map. Both renderers watch their own frame time and trade resolution for smoothness: the CPU path scales its internal buffer (260–720px), the WebGL path steps its device-pixel-ratio down a ladder — so a phone GPU and a desktop GPU both converge on a fluid frame rate instead of a fixed quality that's wrong for one of them. The triangle savings from greedy meshing are spent on more draw distance, not just more frames; index buffers use 16-bit indices wherever a chunk fits under 65536 vertices (nearly always, after greedy merging), halving their upload.
- Input-agnostic core. Keyboard, mouse, drag-look, and the on-screen
touch controls all feed the same controller and interaction through a
tiny surface (
setExternalInput,lookBy,breakTargetedBlock). Adding the entire mobile control scheme touched no game logic.
npm ci
npm run dev # local dev server
npm run test # 216 offline tests (world, mesher, physics, raycast, streaming, sky, input)
npm run lint && npm run typecheck
npm run build # production bundleEverything interesting is testable without a browser: terrain determinism and seam consistency, cave/ocean-floor invariants, mesher face counts and AO levels and triangle winding, collision resolution, raycast face detection, streaming load/unload/persistence round trips, day/night continuity. CI runs the full gate on every push.
On top of that, scripts/visual-check.mjs boots the built game in a
headless Chromium (SwiftShader software WebGL — works on GPU-less machines
and CI) and plays it: waits for the world to mesh, verifies the player
spawned on solid ground, engages pointer lock, places a block on a
neighboring column it picks by inspecting the world, breaks the block
underfoot, walks — then opens a second page with requestPointerLock
sabotaged to prove the drag-look fallback engages and stays fully playable
(move, look, tap-break), a third page with getContext('webgl') sabotaged
to prove the CPU raycaster boots, renders real terrain, moves and breaks,
and a fourth emulating a phone with a touchscreen — proving the game
detects touch, shows its on-screen controls, and is fully playable with a
joystick drag and taps. 23 end-to-end checks with screenshots. It exists
because unit tests can't see: the one real rendering bug this project
shipped (an atlas UV/flipY mismatch that made grass sample an empty
atlas region and leaves sample sand) was invisible to every unit test and
obvious in the harness's first screenshot.
npm run build && npx vite preview --port 4173 &
node scripts/visual-check.mjs # PASS/FAIL per check + screenshots
node scripts/screenshot.mjs # regenerates docs/screenshot.png- Chunk-independent generation over chunk-to-chunk communication. The usual alternative — generate a chunk, then patch neighbors when a tree overhangs — needs ordering, patch queues, and produces seams when it's wrong. Making every feature a pure function of world position deletes the whole problem class, at the cost of some redundant computation at borders (cheap, and it runs in a worker anyway).
- Meshing on the main thread, generation off it. Meshing needs neighbor-chunk reads and produces GPU-bound buffers; shipping all that state to a worker costs more than the ~1-2ms a chunk mesh takes. The expensive, state-free part (noise sampling) is what moves.
- Reject-by-default physics steps. The collision resolver assumes small per-frame displacements and clamps them axis-by-axis; velocities zero out on contact. Terminal velocity plus the per-axis sweep keeps even long falls from tunneling through a floor.
localStorageover a save-file format. The overlay-over-regeneration model means persistence is a dictionary, not a world serializer.
- No lighting propagation (torches, sunlight flood fill) — AO plus directional shading only. It's the single feature that would most change the look of caves, and the natural next step.
- The CPU renderer trades some fidelity for universality: procedural texels instead of the full atlas and ~50 blocks of view distance. It still carries per-pixel ambient occlusion, sun-tracked face lighting, a sun disc, night stars, soft clouds, and the block-outline highlight. Its edge-adaptive sampling assumes neighboring pixels usually agree; that holds for blocky terrain but would smear a scene full of fine per-pixel detail (which this one never has).
- Water supports swimming (buoyancy, swim up, hop onto the shore) but doesn't flow — breaking a dam doesn't flood anything.
- No mobs, crafting, or inventory beyond the hotbar — the scope is the world itself: generate, explore, dig, build.
- Chunk meshes rebuild whole-chunk on edit (a greedy remesh, ~1ms) rather than patching just the touched faces — fine in practice, and the greedy pass keeps the rebuilt buffer small.
MIT — © Vincent Plessy



