Voxel worlds — a Minecraft-style first-person block sandbox
prompts/pkg/llms/voxel.md on every deploy, so it
can't drift from what the platform actually does. Read it from your terminal any
time with npx vibes-diy skills.
When someone asks for "Minecraft", a "block world", a "voxel game", or a
first-person building/mining sandbox, this is the recipe. It builds on the
Three.js docs (all the THREE.* classes there apply) — this page is the
voxel-specific architecture that makes such a game actually playable, and it
calls out the handful of things that make the difference between "cool" and
"I keep getting stuck / I can't lock my mouse".
The worked examples throughout are real community apps by isaac-subach
(shared with permission): Voxel World — procedural biomes, caves, ore
veins, endless chunk streaming, day/night, live multiplayer edits, ambient
wildlife — and its bigger successor Blockcraft World, which adds the full
survival layer: crafting tiers, tool durability, smelting, armor, mob combat,
hunger, creative mode, nether/end dimensions, per-player inventory, and a
shared world reset. Blocktopia contributes the instanced chunk renderer,
material-colored controls, compact procedural island settings, and sparse
per-cell edits; Block Craft contributes exposed-face chunk meshes and
survival feedback. Adapt the useful
patterns to the persistence contracts below: whole-world autosaves and durable
movement heartbeats are expensive examples to improve, rather than defaults.
Project shape: an engine in a React shell, split across a few flat files
A voxel game is one big imperative Three.js engine wrapped in a thin React
shell. Render with Three.js/WebGL: a block world runs on real 3D depth, per-face
lighting, and a first-person perspective camera, all of which come straight from
the THREE.* classes in the Three.js docs. Build the engine once inside a
useEffect(() => {...}, []) that owns the renderer, the world model, and the
animation loop; expose a few methods on a mutable state object
(engineRef.current) so React can call in. React
renders only the HUD, the hotbar, and overlays as normal DOM on top of the
<canvas>. Never re-create the engine on re-render, and never drive
per-frame values through React state — read live values through refs.
A small game fits in one App.jsx; past ~1,000 lines, split it. The proven
layout (this is exactly how Blockcraft World is factored):
App.jsx entry — the app component; ONLY file with `export default`
core.js data tables (blocks/items/recipes/tools), worldgen noise,
texture/material builders, styles — no React, named exports
WorldCanvas.jsx the 3D engine component (+ error boundary), named exports
ui.jsx Hotbar, InventoryPanel, CraftingPanel, panels — named exports
access.js the access function (see multiplayer section)
Multi-file rules — each one is load-bearing, breaking any of them breaks the deployed app:
All files flat at the root is a simple default for this example. Nested
components/andlib/modules also work in the CLI and codegen. Relative imports resolve to uploaded files at their exact paths; keep the special App, access, backend, and seed entry files at the root.Relative imports include the extension:
./core.js,./ui.jsx— the client serves files by exact path, so an extensionless./core404s in the browser.App.jsxis the only entry and — among the client modules — the onlyexport default; the other client files use named exports and are reached from App by imports.access.jsis exempt: it is server-run, never imported fromApp.jsx, and follows the access contract's own export rules (export defaultis the catch-all access function; a named export gates only the database with the matching name).Never import from the entry (
../App.jsx/./App.jsx) — shared constants live in a sibling module (core.js), imported by both sides.Imported bindings are read-only. Module-scope mutable state (a
let peacefulflag, a difficulty setting) cannot be assigned from another file —PEACEFUL = vacross modules throws a TypeError at runtime (message varies by engine, e.g. "Assignment to constant variable"). Keep theletin its home module and export a setter; reads stay live through ESM bindings:js
// core.js export let PEACEFUL = false export function setPeaceful(v) { PEACEFUL = v } // WorldCanvas.jsx import { PEACEFUL, setPeaceful } from "./core.js" setPeaceful(true) // ✓ — assignment to the import would crash
jsxfunction CanvasStage({ engineRef, onReady }) {
const canvasRef = React.useRef(null);
React.useEffect(() => {
const canvas = canvasRef.current;
// ... build scene, camera, renderer, world model, loop ...
const state = { /* see below */ };
engineRef.current = state;
// ... requestAnimationFrame(loop); onReady?.() ...
return () => { state.running = false; /* dispose everything */ };
}, []);
// select-none touch-none on the CONTAINER too, not just the canvas — the HUD
// and overlays render inside it, and native gestures can start outside the
// canvas (see the input-hardening section).
return <section className="absolute inset-0 select-none touch-none"><canvas ref={canvasRef} className="w-full h-full block touch-none select-none" /></section>;
}
The data model: a flat block map, not a 3D array
Store the world as one Map keyed by "x|y|z" → block-type string. Sparse,
trivial to stream, and the same structure works for collision and raycasting.
jsconst keyOf = (x, y, z) => `${x}|${y}|${z}`;
const parseKey = (k) => k.split("|").map(Number);
const CHUNK_SIZE = 16;
const chunkOf = (x, z) => [Math.floor(x / CHUNK_SIZE), Math.floor(z / CHUNK_SIZE)];
const chunkKey = (cx, cz) => `${cx},${cz}`; // stable string id — compare these, never raw [cx,cz] arrays
const state = {
blocks: new Map(), // "x|y|z" -> type (the loaded world, terrain + edits)
chunks: new Map(), // "cx,cz" -> Set(blockKey) currently-resident chunks
edits: new Map(), // "x|y|z" -> type|null player edits layered over terrain
chunkMeshes: new Map(), // "cx,cz" -> THREE.InstancedMesh (EXPOSED blocks only, one per chunk)
dirty: new Set(), // "cx,cz" chunks whose mesh must be rebuilt after an edit
pos: new THREE.Vector3(0, 20, 0),
vel: new THREE.Vector3(),
yaw: 0, pitch: 0, keys: {}, onGround: false, fly: false,
};
Keep block types as a table of face colors so one material set covers the world. Use the shared palette below for matching materials, with darker side faces:
jsconst BLOCK_TYPES = {
grass: { top: 0x6fcf5b, side: 0x8a5a3b }, dirt: { top: 0x8a5a3b, side: 0x6b4626 },
stone: { top: 0x9b9b9b, side: 0x7a7a7a }, sand: { top: 0xf2dc9b, side: 0xd6c78a },
water: { top: 0x2f8fe0, side: 0x2f8fe0 }, wood: { top: 0xb5793a, side: 0x6b4626 },
leaves:{ top: 0x3f8f2f, side: 0x3f8f2f }, /* ...ores, planks, snow, etc. */
};
Voxel palette: the block in hand colors the controls
Use the Blocktopia voxel palette in every voxel app. Keep the game-specific colors in one named table, shared by material swatches and HUD controls. Add game-specific materials to that table; preserve these colors for matching materials. The surrounding app retains its supplied theme's surfaces and text.
jsexport const VOXEL_PALETTE = {
grass: "#6fcf5b", dirt: "#8a5a3b", stone: "#9b9b9b",
wood: "#b5793a", glass: "#9fe3ff", candy: "#ff6fb1",
neon: "#39ff9e", water: "#2f8fe0", lava: "#ff5a2f",
erase: "#ff4d6d", ink: "#1a1325", light: "#ffffff",
};
function luminance(hex) {
const linear = [1, 3, 5].map((i) => {
const c = parseInt(hex.slice(i, i + 2), 16) / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722;
}
export function readableInk(background) {
const bg = luminance(background);
const ratio = (color) => (Math.max(bg, luminance(color)) + 0.05) /
(Math.min(bg, luminance(color)) + 0.05);
return ratio(VOXEL_PALETTE.ink) >= ratio(VOXEL_PALETTE.light)
? VOXEL_PALETTE.ink : VOXEL_PALETTE.light;
}
The selected material supplies the active accent; erase mode supplies erase.
Pass it as tint to the active view/gravity buttons, action buttons, and the
selected hotbar slot (an IconButton): pouf fills each control with the accent
and picks its readable text. Keep unselected controls variant="quiet" and
panels on theme surfaces (Card) so the HUD stays readable over sky, water,
caves, and bright terrain:
jsxconst accent = eraseMode ? VOXEL_PALETTE.erase : selectedMaterial.color;
return <section>
{/* canvas and HUD share this accent */}
<Button tint={accent} onClick={cycleView}>Switch view</Button>
<Button tint={accent} aria-pressed={eraseMode} onClick={toggleErase}>
{eraseMode ? "Erase" : "Place"}
</Button>
</section>;
Where the game draws the accent itself (the stage border in the scene, hotbar
number labels on a canvas), choose the text with readableInk: the
higher-contrast foreground from ink and light using sRGB relative
luminance and contrast ratio (normal text: at least 4.5:1), the same choice
tint makes. For the palette above, dirt takes white and the other materials
and erase take ink; recompute for additional or customized colors. A simple
brightness threshold can choose white on wood, water, or lava even when dark
text is more readable. Keep labels/icons and aria-pressed or aria-checked:
the accent reinforces the selected mode rather than being its only signal. On
touch, keep movement/look separate from Place/Break/Jump controls, and clear
held input on pointer cancellation or lost capture.
Rendering: instance the EXPOSED blocks (this is the whole performance game)
The single most important rule: do not create a THREE.Mesh per stored
block, and never allocate a geometry or material inside the block loop. A
chunk is thousands of blocks and most are buried. Three.js pays one draw call
per mesh per material, so a mesh-per-block world with a six-material cube is
hundreds of thousands of draw calls a frame — CPU-bound, and a faster GPU does
not help. Keep buried blocks in the blocks map (collision and raycasting read
the map, not the scene) and draw the rest as one InstancedMesh per chunk.
First, the exposure test — a block with six occluding neighbors is never drawn:
jsconst NB = [[1,0,0],[-1,0,0],[0,1,0],[0,-1,0],[0,0,1],[0,0,-1]];
function occludes(t) { return t && t !== "water" && t !== "glass" && t !== "leaves"; }
function isHidden(x, y, z) {
for (const [dx, dy, dz] of NB)
if (!occludes(state.blocks.get(keyOf(x + dx, y + dy, z + dz)))) return false;
return true; // fully enclosed → no instance
}
Then the chunk mesh: one shared BoxGeometry and one shared material for the
whole world, built once outside every loop, with the block color carried by
instanceColor. This is Blocktopia's terrain — the whole island is one draw
call, and the build is a setMatrixAt loop rather than vertex arithmetic:
jsconst CUBE = new THREE.BoxGeometry(1, 1, 1); // built ONCE
const TERRAIN_MAT = new THREE.MeshLambertMaterial({ color: 0xffffff }); // built ONCE; white so instanceColor shows
const tmpM = new THREE.Matrix4(), tmpC = new THREE.Color();
function buildChunkMesh(keys) { // keys: the chunk's resident block keys
const visible = [];
for (const k of keys) { const [x, y, z] = parseKey(k); if (!isHidden(x, y, z)) visible.push([x, y, z, state.blocks.get(k)]); }
const mesh = new THREE.InstancedMesh(CUBE, TERRAIN_MAT, visible.length);
visible.forEach(([x, y, z, type], i) => {
mesh.setMatrixAt(i, tmpM.makeTranslation(x + 0.5, y + 0.5, z + 0.5));
mesh.setColorAt(i, tmpC.setHex(BLOCK_TYPES[type].top));
});
mesh.instanceMatrix.needsUpdate = true;
mesh.instanceColor.needsUpdate = true;
mesh.frustumCulled = false; // a chunk's bounds are its blocks; let the fog bound distance
return mesh;
}
Blocks whose material differs in kind — transparent water and glass, an
emissive lava — get their own InstancedMesh per type sharing the same CUBE,
so the count of draw calls per chunk stays "one plus the number of special
materials present". Never mint a material per block; the material table is a
module-level Object keyed by type, built once.
Edits rebuild the chunk, not the block. When a block is placed or broken,
mark its chunk dirty — and the neighboring chunk too when the block sits on a
chunk boundary, because a face across the border may have just become exposed
or buried. Each frame, rebuild at most one or two dirty chunks: scene.remove
the old InstancedMesh, dispose() it, and add the fresh one. Rebuilding a
16×16 chunk is a few thousand setMatrixAt calls, well under a frame. A
just-placed block can pop in by starting its instance matrix at scale 0 and
easing it to 1 over a few frames.
Raycasting and collision must read state.blocks (the full model), never the
meshes or the instance buffers — otherwise you can't hit or collide with a face
that isn't currently drawn.
For texture-heavy or very large worlds, use Block Craft's exposed-face chunk
batching instead: append only the visible faces into a BufferGeometry per
material per chunk (position, normal, uv and an index per quad), sharing
materials across chunks. It uploads less than instancing — a buried face is
never sent at all — and allows a different texture per face, at the cost of
writing the six quad windings by hand. The dirty-chunk rule is identical.
Either of these two patterns is correct; a Mesh per block is not, even with
the exposure test in front of it.
Procedural terrain: deterministic noise, layered
Terrain must be a pure function of world coordinates so chunks generate identically no matter when they stream in. Use hash-based value noise (no library, no seeds to thread):
jsfunction hash2(x, z) {
let h = x * 374761393 + z * 668265263;
h = (h ^ (h >> 13)) * 1274126177; h = h ^ (h >> 16);
return (h >>> 0) / 4294967295;
}
function smoothNoise(x, z) { /* bilinear-interpolated hash2 with smoothstep */ }
Layer octaves for the height field, add a low-frequency biome field, carve caves and place ore with 3D noise, and generate trees in a margin around each chunk so canopies that cross chunk borders stay whole:
jsfunction heightAt(x, z) {
let h = smoothNoise(x*0.06, z*0.06)*8 + smoothNoise(x*0.03, z*0.03)*5;
const mtn = smoothNoise(x*0.012 + 2000, z*0.012 + 2000);
if (mtn > 0.6) h += (mtn - 0.6) * 70; // steep mountain ranges
return Math.floor(h);
}
function biomeAt(x, z) { /* temp + moisture fields -> plains/forest/desert/snowy/mountains */ }
function carvedCave(x, y, z) { /* 3D noise > threshold, but keep y<2 solid */ }
function oreAt(x, y, z) { /* rarer + deeper = diamond; common + shallow = coal */ }
Generate a chunk into a Map and let player edits override terrain on load
so a mined/placed block survives streaming out and back:
jsfunction loadChunk(cx, cz) {
const keys = new Set();
for (const [k, t] of generateChunkBlocks(cx, cz)) { state.blocks.set(k, t); keys.add(k); }
for (const [k, t] of state.edits) { // edits win over terrain
const [ex, , ez] = parseKey(k);
if (chunkKey(...chunkOf(ex, ez)) !== chunkKey(cx, cz)) continue; // compare stable keys, not arrays
if (t === null) { state.blocks.delete(k); keys.delete(k); }
else { state.blocks.set(k, t); keys.add(k); }
}
state.chunks.set(chunkKey(cx, cz), keys);
const mesh = buildChunkMesh(keys); // one InstancedMesh for the chunk (see Rendering)
scene.add(mesh); state.chunkMeshes.set(chunkKey(cx, cz), mesh);
}
Described islands: save the recipe, regenerate the terrain
For a world described in a sentence, use Blocktopia's approach: ask callAI
for structured generator settings, normalize them, then persist that compact
recipe with a fixed seed and generator version. Bound map dimensions, heights,
densities, array lengths, and supported tree/animal kinds before generating.
Keep a usable flat-world option and preserve the description if generation
fails. Generate the voxel field locally from the saved recipe on every client;
save only player overrides. Keep seeded randomness and traversal deterministic,
and retain the matching generator for existing worlds when generation changes.
Ambient animals, flowers, clouds, and particles stay capped and local unless
their shared gameplay state actually needs persistence.
Endless streaming: one chunk per frame
Each frame, figure out which chunks should be resident around the player
(VIEW_RADIUS chunks in each direction), enqueue the missing ones sorted
nearest-first, and load exactly one per frame so the framerate never
stalls. Unload chunks that fall outside the radius (delete their blocks and
instanced meshes). Never load a whole ring in one frame.
The player controller — where "Minecraft" is won or lost
Collision: per-axis AABB + auto step-up + un-stick
Represent the player as an AABB (~0.6 wide, 1.8 tall) and test it against the block map. Resolve each horizontal axis separately so sliding along a wall works. Then add the two things that separate a good controller from a frustrating one — these are the exact complaints new voxel apps get ("I keep getting stuck", "I get stuck jumping", "I'm trapped in a block"):
- Auto step-up. If a horizontal move is blocked but the space one block higher is clear (and you're grounded), step up onto it. This is what lets you walk up stairs and 1-block ledges without hammering the jump key.
- Un-stick. At the top of each frame, if the player's AABB is already inside solid terrain (a block synced in from another player, or fresh terrain generated around you), lift the player up until free. Without this a synced block traps the player permanently.
jsfunction collides(px, py, pz) {
const minX = Math.floor(px-0.3+0.5), maxX = Math.floor(px+0.3+0.5);
const minZ = Math.floor(pz-0.3+0.5), maxZ = Math.floor(pz+0.3+0.5);
const minY = Math.floor(py-1.6+0.5), maxY = Math.floor(py+0.2+0.5);
for (let x=minX;x<=maxX;x++) for (let z=minZ;z<=maxZ;z++) for (let y=minY;y<=maxY;y++) {
const t = state.blocks.get(keyOf(x,y,z));
if (t && t !== "water") return true;
}
return false;
}
// each frame, before moving:
let lifts = 0;
while (collides(np.x, np.y, np.z) && lifts < 6) { np.y += 1; lifts++; } // un-stick
const stepAxis = (nx, nz) => { // move + auto step-up
if (!collides(nx, np.y, nz)) { np.x = nx; np.z = nz; return; }
if (state.onGround && !state.fly && !collides(nx, np.y+1, nz) && !collides(np.x, np.y+1, np.z)) {
np.x = nx; np.z = nz; np.y += 1;
}
};
stepAxis(np.x + move.x, np.z);
stepAxis(np.x, np.z + move.z);
Gravity/jump: integrate vel.y -= g*dt, and when a downward move collides set
onGround = true and zero the velocity; jump only when grounded. Add a fly
toggle (e.g. F) that swaps gravity for direct vertical thrust — great for
building. Add sprint (hold Shift while moving forward → higher speed, and
nudge the camera FOV up a few degrees for a sense of speed).
Keyboard: game actions keep the page still
During active play, prevent the browser defaults for every key bound to a game action.
Use KeyboardEvent.code for the binding map (Space, ArrowUp, KeyW,
etc.), and call event.preventDefault() in the handled keyboard events so
Space jumps and arrow keys move without scrolling the page. Apply this only
while the game has focus or its canvas owns pointer lock, with gameplay active;
leave typing in chat, inputs, and contenteditable fields, normal button
activation, and browser shortcuts available outside that input path. CSS
touch-action: none handles touch gestures; keyboard defaults need their own
handlers. Clear held keys on blur, pointer-lock loss, pause, and tab hiding so
movement stops even when the matching keyup never arrives. Remove listeners
when the engine unmounts.
Looking: pointer lock with a click-to-play overlay (+ touch drag)
Pointer lock is the number-one confusion in browser voxel games: players click and nothing happens, or they don't know the mouse controls the camera at all. Always show an explicit "Click to play" overlay with the control legend, and only request lock from that click:
jsx{worldReady && !isTouch && !locked && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-background/80 backdrop-blur-sm">
<Card>
<Stack gap={3}>
<Heading level={2}>Voxel World</Heading>
<Button type="button" size="lg" onClick={() => engineRef.current?.requestLock()}>
Click to play
</Button>
{/* WASD move · Mouse look · Shift sprint · Space jump · Left-click break · Right-click place · F fly · 1–9 blocks */}
<Text size="sm" muted>Press Esc to release the mouse</Text>
</Stack>
</Card>
</div>
)}
Drive the overlay off the real lock state so it reappears when the player hits Esc:
jsReact.useEffect(() => {
const onLock = () => setLocked(!!document.pointerLockElement);
document.addEventListener("pointerlockchange", onLock);
return () => document.removeEventListener("pointerlockchange", onLock);
}, []);
In the engine, read movementX/Y when document.pointerLockElement === canvas
(clamp per-event deltas — browsers spike them on lock acquire), and fall back to
drag-to-look on touch so phones work without pointer lock. Clamp pitch to
±1.5 rad. Provide on-screen move/jump/place/break buttons for touch.
View switcher: see your own character
Include a view switcher between first-person and third-person so players can see their own avatar.
Provide a labeled HUD button that works on touch and a keyboard shortcut
(e.g. V, shown in the control legend); toggle once per press, ignoring key
repeat. Render a simple blocky local-player avatar in third-person, following
the player's position and heading, and hide it in first-person so it does not
cover the camera. Keep the same player physics and position when switching:
offset the third-person camera behind the player and shorten that offset when
terrain blocks the view. Preserve aiming and the player's block-interaction
reach as the camera moves. Keep the button reachable when pointer lock is
released, and let the shortcut switch views during locked play.
Optional fullscreen: keep the HUD with the world
Offer an optional fullscreen button for a larger play area; the Vibes iframe allows fullscreen.
When included, call requestFullscreen() directly from the button's user
gesture on the game container that includes both the canvas and HUD. Check
document.fullscreenEnabled and method availability, and handle a rejected
request by keeping play usable in the embedded view. Derive the Enter/Exit
fullscreen label from document.fullscreenElement on fullscreenchange, use
document.exitFullscreen() to exit, and resize the renderer and camera aspect
to the container on entry and exit. Keep fullscreen and pointer lock as
separate controls; reacquire pointer lock through the click-to-play overlay
when needed. Remove the fullscreen listener on unmount.
Full screen, with one menu button
The world fills the screen (the CanvasStage above is absolute inset-0 inside a
relative h-dvh w-full root). Keep the crosshair, the hotbar and the touch controls on screen;
put the title, settings, crafting recipes, the world-reset control and the player list behind
one top-right menu button (IconButton) that toggles an overlay. The bottom-right corner
belongs to the platform. A smaller boxed world is only for when the person asks for one.
No text selection, and bind touch as well as mouse
The phone control schemes themselves (swipes, a ghost stick under the thumb, hold-to-move)
live in the touch-games skill; load it beside this one.
Two things separate "works on my laptop" from "works", and both are easy to forget because the desktop build hides them:
Kill text selection and native gestures on the game surface. A look-drag otherwise highlights the HUD text, fires iOS's selection callout, or scrolls the page under the player. Put
select-none touch-none(Tailwind foruser-select: none+touch-action: none) on the<canvas>and its container, cover the WebKit-only bits, andpreventDefault()on everymousedown/touchstartyou handle so a drag can never begin a selection:css
#world, #world * { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; /* no iOS long-press magnifier/callout */ touch-action: none; /* no scroll/zoom/pull-to-refresh on drag */ }Every mouse binding needs a touch twin, or the game is dead on a phone. Add
touchstart/touchmove/touchendnext to your mouse handlers, and — this is the part that bites — register them with{ passive: false }, or the browser treats the listener as passive and yourpreventDefault()is ignored (scroll/zoom win). A drag looks; a tap mines/places (raycast from the touch point, or from screen-center under the crosshair). On-screen move/jump buttons stand in for the keyboard.js
const opts = { passive: false }; // REQUIRED — a passive listener CANNOT preventDefault // ALWAYS read `changedTouches[0]`, never `touches[0]`: at touchend/touchcancel // `touches` is EMPTY (the finger lifted), so `touches[0]` is undefined and you // lose the final tap coordinate. A TouchEvent also has no mouse `movementX`, so // compute look deltas yourself from the last point rather than reusing the // pointer-lock path. Handle touchcancel too (a call/notification interrupts). let lx = 0, ly = 0; const pt = (e) => e.changedTouches[0]; canvas.addEventListener("touchstart", (e) => { e.preventDefault(); const t = pt(e); lx = t.clientX; ly = t.clientY; onDown(t); }, opts); canvas.addEventListener("touchmove", (e) => { e.preventDefault(); const t = pt(e); onLook(t.clientX - lx, t.clientY - ly); lx = t.clientX; ly = t.clientY; }, opts); canvas.addEventListener("touchend", (e) => { e.preventDefault(); onUp(pt(e)); }, opts); // pt(e) = the finger that lifted; a tap here = mine/place canvas.addEventListener("touchcancel",(e) => { e.preventDefault(); onCancel(); }, opts); // gesture interrupted — reset drag state // Pointer Events (pointerdown/move/up + pointercancel) unify mouse + touch in // one path and give you `movementX` under lock — often cleaner — but you STILL // need `touch-action: none` above or the browser eats the drag.
Block targeting: a DDA voxel raycast, not Raycaster
Do not use THREE.Raycaster against the meshes — it misses unmeshed faces
and is slow. March a DDA ray through the block map from the camera along its
forward vector; the first solid voxel is the target, and the face you entered
through gives the placement cell.
jsfunction castTarget() {
const d = new THREE.Vector3(0,0,-1).applyEuler(camera.rotation);
// ... standard 3D-DDA over integer voxels, max ~7 blocks reach ...
// return { x, y, z, face:[fx,fy,fz] } for the first solid, non-water voxel, else null
}
Left-click breaks the targeted block; right-click (contextmenu, prevent
default) places selected on the entered face. Guard placement so a player
can never place a block inside their own AABB. Show a wireframe highlight box on
the current target each frame, and spawn a quick burst of small colored cubes
when a block breaks — cheap, and it makes mining feel real.
Multiplayer: live block edits over Fireproof
Keep saved edits separate from temporary movement
useDocument.merge(updates) updates a local draft and broadcasts its full
merged snapshot to authorized connected peers when it has an _id.
It is a shallow merge, not a durable field-patch API; save() persists through
database.put(). The app-facing database has no database.merge() method.
Use small, separate documents for different lifetimes:
- World recipe and membership: persist the seed, generator settings, version, and world membership once, then only when deliberately changed.
- Placed/mined cells: persist one small override per world and coordinate.
Include
worldIdin both the ID and indexed fields when an app has several worlds. Keep aremoved: trueoverride for mined procedural terrain so it stays gone after reload. Use the consistency model below for concurrent edits. - Inventory, health, and resume checkpoints: persist per world and player,
with change detection and an explicit commit/checkpoint cadence. Commit
important collected items when the action completes; a player-pose heartbeat
should carry only pose.
save()still creates a durable write. To commit in the same callback as a draft update, pass the complete next snapshot tosave(snapshot); a baresave()can read the previous render's draft. A nested object replaces that entire field rather than merging individual coordinates. - Live pose/aim: use a dedicated presence draft and
merge()withoutsave(). Keep the movement cadence bounded and heartbeat while stationary. Temporary overlays expire after about 12 seconds without updates or when the peer disconnects; they are not a durable resume position or offline edit log. - Activity feed: use a bounded/coalesced recent-action summary unless a durable audit trail is required. Showing only eight results does not bound storage when every block placement appends a new activity document.
Blocktopia's per-coordinate writes already avoid saving an entire world for
each block. The useful merge() substitution is its recurring presence
write. Block Craft's whole-world snapshot every two seconds combines edits,
inventory, vitals, and pose; split those lifetimes before adopting its survival
systems. Fewer durable commits and smaller documents reduce persistence work;
measure wire bytes and delivery latency separately before claiming faster sync.
For a world whose membership is already established, mount this component
with key={worldId + ":" + handle}. Its hook stays at component scope and
starts a new draft when the player changes worlds. poseRef is the engine's
latest { x, y, z, theta } snapshot; animation updates that ref without React
state or database writes:
jsxfunction PlayerPresence({ worldId, handle, poseRef, useDocument }) {
const { merge } = useDocument({
_id: `presence:${worldId}:${handle}`, type: "presence",
worldId, userHandle: handle,
});
React.useEffect(() => {
let previous = "";
let lastSent = 0;
const timer = setInterval(() => {
const p = poseRef.current;
if (!p) return;
const pose = { x: p.x, y: p.y, z: p.z, theta: p.theta };
const stamp = JSON.stringify(pose);
const now = Date.now();
if (stamp === previous && now - lastSent < 5000) return;
merge({ ...pose, at: now });
previous = stamp;
lastSent = now;
}, 350);
return () => clearInterval(timer);
}, [merge, poseRef]);
return null;
}
Peers can receive this never-saved row through a live query on worldId;
the temporary overlay supplies the index fields. The access function must
validate the sender's identity, bind _id to its world/player, validate finite
pose coordinates, and require world membership before returning the existing
world channel. Persist the world/invitation that grants that membership first:
a presence broadcast does not create grants. Use this matching branch in the
multi-world access function (the single-world example later uses different IDs
and authorHandle):
jsif (doc.type === "presence") {
if (!user || doc.userHandle !== user.userHandle) throw { forbidden: "not you" };
if (typeof doc.worldId !== "string" || !doc.worldId) throw { forbidden: "world required" };
if (doc._id !== `presence:${doc.worldId}:${user.userHandle}`) throw { forbidden: "presence ID" };
if (![doc.x, doc.y, doc.z, doc.theta, doc.at].every(Number.isFinite)) throw { forbidden: "invalid pose" };
const channel = `world:${doc.worldId}`;
ctx.requireAccess(channel);
return { channels: [channel] };
}
A hidden or disconnected tab may miss its heartbeat, so peers expire it rather
than treating it as saved state. Use receiver arrival time for update freshness.
Rely on disconnect/expiry for departure:
an unmount-time merge() can be canceled before broadcast. This example uses
one presence ID per handle; independent same-handle tabs need a session suffix
and access rules that validate that ID. During migration from saved presence,
admit old durable rows only if their timestamps are plausibly fresh; their
first query arrival or reappearance after overlay expiry is not a heartbeat.
Alternatively, use a distinct ephemeral namespace with matching access rules.
Choose the durable edit conflict model
Know exactly what the substrate gives you before you design for it. A live
query returns the full, latest-per-document result set, deliveries are
unordered (notifications can be delayed; overlapping re-queries can resolve
out of order), and when two writes target the same _id the durable head is
whichever the server wrote last by arrival order — not the higher client
timestamp. That last fact is the one that bites: a client-side fold can order
what a session observes, but it cannot change which revision the server keeps,
so there is no free lunch here. Pick a model with eyes open:
- Model A — one doc per coordinate (bounded, eventually-consistent-per-session).
Deterministic
_id: block:<key>, so each edit overwrites the same doc and the database stays one-doc-per-cell. A client fold makes every session that observes both of two concurrent writes converge on the same winner. Its honest limit: the durable head at a coordinate is whatever persisted last by server order, so a late joiner — who only ever receives that one head — can see a lower-timestamp write win. And because the fold orders by client-supplied(at, wid), even among accepted writes a skewed writer's clock can transiently order a lower-real-time edit ahead until the skew window passes — the stamps order by observed time, not truth. Fine for a casual shared toy; not actually strong consistency, and only server-assigned ordering (#3777) removes the client-clock dependence entirely. This is the model shown below and used by the example app. - Model B — append-only op log (unbounded, strongly convergent). Every edit
is its own immutable doc (unique
_id, never overwritten), so the full set of ops is durably present and every client — late joiners included — folds the identical total order to the identical world. This is the model to reach for when correctness across joiners matters. Its cost: the doc set grows with every edit; you must compact (delete ops below the reset watermark, or snapshot). The delta from Model A is small and called out after the code. - The real fix for bounded and strong is server-assigned ordering (a
compare-and-set on
at, or a server write-sequence exposed to the fold). The platform does not expose that yet — #3777. Until it does, neither model is both bounded and joiner-correct, and this section says so rather than pretending a client fold closes the gap.
Model A: one doc per coordinate
The durable examples below use a single shared world. For a world picker,
carry worldId through document IDs, queries, reset watermarks, accumulator
lifecycles, and access checks together; use one channel per world. Keep a reset
in one world from invalidating edits or inventories in another.
Give the doc a deterministic _id of block:<key> so every edit at a
coordinate updates the same doc — the database stays one-doc-per-cell instead of
replaying every historical value. A break writes a removed: true tombstone at
the same id (keep it so the removal survives streaming, rather than deleting it):
js// A per-WRITE unique id. It is the tiebreaker AND the identity: authorHandle is
// neither (two tabs on one handle, or two anonymous players, collide), and `by`
// defaulting to "" would let distinct writes tie and fall back to arrival order.
const wid = crypto.randomUUID();
// on place/break — apply locally for instant feedback, fold the intent into
// the world map NOW, then upsert the ONE doc for this cell.
// Stamp with a STRICTLY monotonic local time: never <= our previous write
// (Date.now() can tie or step backwards), and always above the reset watermark.
// We clamp OUR OWN clock here on write, so an honest client never emits a
// future-dated stamp; readers drop the rest (see fold).
eng.applyBlock(key, type); // type=null for a break
const at = (lastAtRef.current = Math.max(Date.now(), lastAtRef.current + 1, resetAtRef.current + 1));
const doc = brk
? { _id: `block:${key}`, type: "block", key, removed: true, authorHandle: me?.userHandle, wid, at }
: { _id: `block:${key}`, type: "block", key, blockType: type, authorHandle: me?.userHandle, wid, at };
if (wins(at, wid, blockStateRef.current.get(key)))
blockStateRef.current.set(key, { at, wid, doc }); // local write folds in at put time
await database.put(doc);
Consistency: fold snapshots FORWARD — never rebuild the world from a snapshot
Because deliveries are unordered, a snapshot missing your newest write can land
after one that had it. If each delivery rebuilds the world from scratch, that
stale delivery silently resurrects old state: mined blocks visibly fill back
in behind the player, then "stabilize" back to correct when a fresh delivery
lands — the single most disorienting multiplayer bug a voxel game can have.
The rule that kills it: a voxel only ever moves forward in time. Keep a
monotonic accumulator keyed by coordinate holding { at, wid, doc } and fold
every snapshot into it — an older write can never overwrite a newer one. Local
writes fold in at put time (above), so the player's own intent is the newest
state by definition and no delivery ordering can outrace it:
jsconst MAX_SKEW = 30_000; // reject another client's clock more than ~30s ahead
const blockStateRef = React.useRef(new Map()); // key -> { at, wid, doc }
const lastAtRef = React.useRef(0); // makes our own stamps strictly increasing
// One deterministic TOTAL order, used for local folds and snapshot folds alike:
// higher at wins; equal at breaks the tie by the per-write uuid, so every client
// converges to the same value with no collisions and no arrival-order dependence.
const wins = (at, wid, cur) =>
!cur || at > cur.at || (at === cur.at && wid > cur.wid);
// The reset watermark must be monotonic ACROSS deliveries — a stale, out-of-
// order snapshot of the worldReset singleton must never lower it. Fold it into
// a ref with max(previous, incoming); mirror to state so the block fold re-runs.
// CRITICAL: apply the SAME future-stamp filter as blocks. The watermark only
// ever rises AND is destructive (it clears the world and forces local writes
// above it), so a single future-dated reset is far more damaging than a future
// block — it would poison the watermark permanently. Drop resets beyond the
// horizon here, and reject them server-side in access.js (below).
const resetAtRef = React.useRef(0);
const [resetAt, setResetAt] = React.useState(0);
const { docs: resetDocs } = useLiveQuery("type", { key: "worldReset" });
React.useEffect(() => {
const horizon = Date.now() + MAX_SKEW;
const hi = resetDocs.reduce((mx, d) => (d.at && d.at <= horizon ? Math.max(mx, d.at) : mx), resetAtRef.current);
if (hi > resetAtRef.current) { resetAtRef.current = hi; setResetAt(hi); }
}, [resetDocs]);
// Time-driven re-fold: a near-future write dropped by the skew filter becomes
// eligible once wall time passes its stamp. Without this tick the fold only
// re-runs on a new delivery, so in a quiet world a legit slightly-ahead write
// would stay dropped. `tick` is a fold dependency below.
const [tick, setTick] = React.useState(0);
React.useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), MAX_SKEW);
return () => clearInterval(id);
}, []);
const { docs: blockDocs } = useLiveQuery("type", { key: "block" });
// Fold in an EFFECT, not during render: the accumulator is a mutable store,
// and mutating it in a useMemo is a render-phase side effect (unsafe under
// concurrent/strict rendering even when the fold is idempotent).
const [blocks, setBlocks] = React.useState(() => new Map());
React.useEffect(() => {
const st = blockStateRef.current;
const horizon = Date.now() + MAX_SKEW;
for (const d of blockDocs) {
const at = d.at || 0, wid = d.wid || "";
if (at <= resetAt) continue; // watermark filters EVERY fold — deliveries re-ship old docs forever
if (at > horizon) continue; // Defense-in-depth: skip a stamp beyond the horizon THIS fold. The
// authoritative bound is server-side (access.js rejects at > now+skew
// at WRITE time), so a far-future doc should never persist; this local
// skip just absorbs a small clock lead until the tick re-admits it.
// NOTE: with the tick, this drop is NOT a durable filter — a fold that
// re-runs after wall time passes `at` will admit it. Keeping hostile
// far-future state out is the SERVER's job, not this line's.
if (wins(at, wid, st.get(d.key))) st.set(d.key, { at, wid, doc: d });
}
if (resetAt) for (const [k, v] of st) { if (v.at <= resetAt) st.delete(k); } // drop held entries when the watermark advances
const m = new Map();
for (const [k, v] of st) m.set(k, v.doc.removed ? null : v.doc.blockType);
setBlocks(m);
}, [blockDocs, resetAt, tick]);
// Apply to the engine — including keys that DISAPPEARED (a reset purged them):
// only pushing present keys would leave pre-reset blocks rendered forever.
// applyBlock(k, null) means "revert to generated terrain" in the engine model.
const prevKeysRef = React.useRef(new Set());
React.useEffect(() => {
const seen = new Set();
for (const [k, b] of blocks) { seen.add(k); eng.applyBlock(k, b); }
for (const k of prevKeysRef.current) if (!seen.has(k)) eng.applyBlock(k, null);
prevKeysRef.current = seen;
}, [blocks]);
Model B: append-only op log (the delta) — design sketch, not drop-in code
The runnable example above is Model A. Model B is described as a delta — the invariants and access branches are exact, but the fold/compaction below is a design sketch, not copy-paste code: you must wire the snapshot query, validation, and seeding into your own fold (spelled out under Compaction), and for a production world you likely want server ordering (#3777) rather than hand-rolling compaction. Treat it as "here is the shape and the traps," not a finished implementation.
To make late joiners correct too, stop overwriting: give every edit its own
immutable doc and never put the same _id twice.
- Write:
_id: op:${wid}instead ofblock:<key>(each op is a distinct, never-updated doc); keepkey,at,wid, andblockType/removedas-is. - Fold: unchanged — the same
wins(at, wid, …)reduction over the op set produces one winner perkey. Because no doc is ever overwritten, the full history is durably present, so any client that has received the same set of ops folds to the same world regardless of join time — that is what makes it convergent where Model A is only per-session. The one caveat is the shared future-stamp filter: two clients whose clocks straddle a near-future op's stamp momentarily include different op sets, so identical convergence is eventual (it holds once every client's horizon passes the op, or absent adversarial future stamps) — the same client-clock residual as Model A, closed only by server ordering (#3777). - Compaction (required, but it needs a completeness boundary): the op set
grows with every edit, and a live query that re-ships an ever-growing set
eventually stalls. Two things make a delete safe, and both matter:
- The fold must read the snapshot it produces. Fold the world into one
snapshotdoc{ at: <cut>, blocks: {key → blockType} }; the fold then seeds the accumulator from the latest snapshot (primeblockStateReffromsnapshot.blocksatsnapshot.at) and applies only ops withat > snapshot.at. Without this a late joiner reconstructs from partial history. - You may only delete ops the snapshot provably covers. The compactor's
own query is eventually-consistent, so "delete every op with
at <= cut" is unsafe — an op atat <= cutthat hadn't yet reached the compactor is deleted forever, and every late joiner then reconstructs the wrong world. The only boundary a client can trust is the owner'sworldResetwatermark: ops below it are already semantically void (the reset voided them), so deletingat <= resetAtis safe and is the degenerate snapshot (an empty world atresetAt). Reclaiming space in a long-lived, never- reset world needs a completeness guarantee no client query provides — that is server-coordinated compaction (the server vouches the snapshot saw every op<= cut), i.e. the same server-ordering gap as #3777. Until then, compact only at resets, or accept unbounded growth.
- The fold must read the snapshot it produces. Fold the world into one
- Access — Model B needs its own branches (the shown
access.jsis Model A and would reject every Model B write). Two things make the "append-only" claim real, and both are easy to miss: op docs must be create-only (an open branch that ignoresoldDoclets anyone who sees anop:<wid>rewrite that supposedly-immutable op), and deletes must be owner-only (a delete is a put of a tombstone under delete-as-put, soallowAnonymouswould otherwise let any client erase history and blow past the compaction boundary). Gate deletes once at the top, keep the op branch create-only, and add the ownersnapshotsingleton:js
// Deletes exist ONLY for owner op-compaction, and ONLY for op docs. Route by // oldDoc (the doc being removed), never by the tombstone's own fields. A blanket // owner-delete would let db.del drop world:reset (lowering the watermark, so // pre-reset ops flood back to a late joiner) or world:snapshot (stranding late // joiners) — so reject those explicitly. NOTE: the documented `at <= resetAt` // compaction boundary itself CANNOT be enforced here — the access fn can't read // the current watermark (no doc reads in ctx) — so a client is trusted to only // delete voided ops; a server revision/CAS would make it enforceable (#3777). if (doc._deleted) { if (!user) throw { forbidden: "sign in" }; ctx.requireRole("owner"); if (!oldDoc) throw { forbidden: "nothing to delete" }; if (oldDoc._id === "world:reset" || oldDoc._id === "world:snapshot") throw { forbidden: "reset/snapshot are not deletable" }; if (oldDoc.type !== "block") throw { forbidden: "only op docs are compactable" }; return { channels: [WORLD], grant: { public: [WORLD] } }; } if (doc.type === "block") { // op-log id, unique per write — NOT the block:<key> pin if (doc._id !== `op:${doc.wid}`) throw { forbidden: "op docs live at op:<wid>" }; // CREATE-ONLY: a live op is immutable. Any update (oldDoc present) is a // rewrite of history — reject it, or "append-only" is a lie on an open db. if (oldDoc) throw { forbidden: "op docs are append-only" }; if (user && doc.authorHandle && doc.authorHandle !== user.userHandle) throw { forbidden: "not author" }; if (typeof doc.at !== "number" || doc.at > Date.now() + MAX_SKEW) throw { forbidden: "stamp too far in the future" }; return { channels: [WORLD], grant: { public: [WORLD] }, allowAnonymous: true }; } if (doc.type === "snapshot") { // Group-destructive (it authorizes op deletion) — owner only, fixed _id, and // MONOTONIC. It is one durable head selected by server arrival order, so // without the monotonic guard an older snapshot arriving late overwrites a // newer one — and once ops below the newer cut are deleted a late joiner // seeding the regressed snapshot can't rebuild. Same guard as worldReset; // the fold must seed only from a snapshot whose `at >= resetAt`. if (!user) throw { forbidden: "sign in" }; ctx.requireRole("owner"); if (doc._id !== "world:snapshot") throw { forbidden: "singleton lives at world:snapshot" }; if (typeof doc.at !== "number" || (oldDoc && !(doc.at > oldDoc.at))) throw { forbidden: "snapshot cut must move forward" }; return { channels: [WORLD], grant: { public: [WORLD] } }; }
Ordering rules, each load-bearing (both models):
- Bound future stamps SERVER-SIDE; the client drop is only defense-in-depth.
An honest client stamps
max(now, last+1, resetAt+1), so it never emits a futureat. The authoritative guard against a hostile future stamp is inaccess.js: reject anyblockorworldResetwrite withat > now + MAX_SKEWso it never persists. The client fold also skips docs beyond its horizon, but do not rely on that to keep hostile state out — because the fold re-runs on the tick, a client-only skip is eventually undone once wall time passes the stamp (anow + 1hblock would surface ~an hour later). The client skip's real job is smaller: absorb a legit small clock lead until the tick re-admits it. The reset needs the same server bound and the client horizon filter, because its watermark is monotonic and destructive — a single future reset that slips through is the worst case. And do not clamp-and-remember on read — a per-client clamp diverges between clients and can't be made Byzantine-safe. Only server-assigned ordering (#3777) removes the client-clock dependence entirely. - The order must be total, identical everywhere, and collision-free. Break
equal-
atties by a per-write uuid (wid) in every fold.authorHandleis not an identity (same-handle tabs, anonymous players tie) and a session-scoped id still collides across a session's own writes; only a per-write uuid is total. A server-assigned revision, when available, replaces the whole(at, wid)scheme. - The reset watermark must gate every fold, and be monotonic across
deliveries. Deliveries re-ship the full doc set forever, so a purged
pre-reset doc comes straight back unless the fold rejects
at <= resetAtevery time. The watermark itself must only ever rise: fold theworldResetsingleton into a ref withmax(previous, incoming), or an out-of-order stale snapshot lowers it and readmits pre-reset blocks. Local writes stamp above it by construction (theresetAtRef.current + 1term). - Propagate disappearances to the engine. A reset removes keys from the
derived map; if you only apply the keys that are present, everything mined
before the reset stays rendered. Diff against the previous key set and
apply
null(revert-to-terrain) for keys that vanished. - Clear an optimistic overlay only when the synced value AGREES with it,
never when the key merely exists. A re-edited voxel always has an older doc,
so key-presence clears the overlay against stale data — the same refill bug
through a different door.
if (blocks.get(k) === pending.get(k)) pending.delete(k).
Apply the local edit before the put so the world never waits on the
network, and read live handles/permissions through refs (a value captured in the
engine closure at load time goes stale — bind live state via state.on*
callbacks or refs, never the closure).
The access.js keeps the shared world open — block docs channel publicly
with anonymous writers allowed — while survival doc types each get a branch
with their natural owner. Every type the app writes needs its own branch
before the final unknown-type throw (an unhandled type fails the write):
jsexport default function (doc, oldDoc, user, ctx) {
const WORLD = "world:overworld";
const MAX_SKEW = 30_000;
// No branch may change a doc's type: without this, the open block branch
// could squat a reserved _id (inv:<handle>, world:reset) and lock out the
// legitimate writer.
if (oldDoc && oldDoc.type !== doc.type) throw { forbidden: "type change" };
if (doc.type === "block") {
// Block docs are PINNED to their own _id namespace — an open branch that
// accepted arbitrary _ids could occupy other types' reserved keys.
if (doc._id !== `block:${doc.key}`) throw { forbidden: "block docs live at block:<key>" };
if (user && doc.authorHandle && doc.authorHandle !== user.userHandle) throw { forbidden: "not author" };
// Bound the stamp SERVER-SIDE — this, not the client fold, is what keeps a
// hostile far-future write out. The client skip is undone once the tick's
// wall clock passes `at`; only a write-time rejection prevents a `now + 1h`
// block from persisting and surfacing an hour later.
if (typeof doc.at !== "number" || doc.at > Date.now() + MAX_SKEW) throw { forbidden: "block stamp too far in the future" };
return { channels: [WORLD], grant: { public: [WORLD] }, allowAnonymous: true };
}
if (doc.type === "inventory") {
// Exactly one doc per player: the _id is BOUND to the caller's handle, so
// nobody can mint or overwrite someone else's pouch. Ownership is checked
// on create AND update — oldDoc is the doc being replaced.
if (!user) throw { forbidden: "sign in" };
if (doc._id !== `inv:${user.userHandle}`) throw { forbidden: "not your inventory" };
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not your inventory" };
if (oldDoc && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not your inventory" };
return { channels: [WORLD], grant: { public: [WORLD] } };
}
if (doc.type === "presence") {
// Roster heartbeat — same self-only rule and _id binding, create AND update.
if (!user) throw { forbidden: "sign in" };
if (doc._id !== `presence:${user.userHandle}`) throw { forbidden: "not you" };
if (doc.authorHandle !== user.userHandle) throw { forbidden: "not you" };
if (oldDoc && oldDoc.authorHandle !== user.userHandle) throw { forbidden: "not you" };
return { channels: [WORLD], grant: { public: [WORLD] } };
}
if (doc.type === "worldReset") {
// Singleton at a FIXED _id, owner only — destructive for the whole group.
// Clients ignore voxel edits older than doc.at, so "reset" deletes
// nothing; enforce the watermark so it can only move forward.
if (!user) throw { forbidden: "sign in" };
ctx.requireRole("owner");
if (doc._id !== "world:reset") throw { forbidden: "singleton lives at world:reset" };
if (typeof doc.at !== "number" || (oldDoc && !(doc.at > oldDoc.at))) throw { forbidden: "watermark must move forward" };
// Reject a far-future stamp SERVER-SIDE: the watermark is monotonic and
// destructive, so a future-dated reset would poison it for everyone and
// survive the client-side skew drop on the writer's own machine.
if (doc.at > Date.now() + MAX_SKEW) throw { forbidden: "reset stamp too far in the future" };
return { channels: [WORLD], grant: { public: [WORLD] } };
}
throw { forbidden: "unknown document type" };
}
The pattern generalizes: self-owned docs bind their _id to the writer's
handle and check ownership on both create and update; group-destructive
singletons gate on the owner role.
Do the world's block editing on the client regardless of write
permission (applyBlock always runs locally); gate only the sync on
can.create(...) so a slow or stale rule binding can never freeze the game.
The survival layer: data tables first, systems second
Everything Minecraft-ish about progression is table-driven. Define the
tables once in core.js and every system (mining, crafting, combat, UI) reads
them — adding content then means adding rows, not code:
jsexport const BLOCKS = [ { id: "grass", name: "Grass", top: 0x6fcf5b, side: 0x8a5a3b, drops: "dirt" }, /* … */ ]
export const ITEMS = { plank: { name: "Plank" }, stick: { name: "Stick" }, ironIngot: { name: "Iron Ingot" }, /* … */ }
export const RECIPES = [
{ id: "planks", out: { plank: 4 }, needs: { wood: 1 } },
{ id: "stonePick", out: { stonePick: 1 }, needs: { cobble: 3, stick: 2 }, station: "bench" },
{ id: "ironIngot", out: { ironIngot: 1 }, needs: { ironOre: 1, coal: 1 }, station: "kiln" },
]
export const TOOLS = { woodPick: { speed: 2, durability: 60 }, stonePick: { speed: 4, durability: 130 }, /* … */ }
export const HARDNESS = { dirt: 1, stone: 3, ore: 4, obsidian: 5 /* hits (or seconds) to break */ }
The systems that ride those tables:
- Mining speed & drops — breaking takes
HARDNESS[type]effort divided by the equipped tool'sspeed; wear the tool'sdurabilityper break and drop the block'sdropsitem into the inventory. - Crafting —
craftableCount(recipe, inv)= how many times the inventory coversneeds; stations (bench/kiln) gate recipe visibility. Tiers emerge from the tables alone: wood → stone → iron (smelt ore at the kiln). - Combat — clicking a mob applies weapon damage + knockback (impulse along
the camera ray), mobs carry
hp, and a death spawns a particle poof and drops. Hostiles pathfind toward the player at night; armor rows reduce incoming damage. - Hunger / health — a slow hunger drain, eating restores it, starvation chips health; respawn resets position and bars.
- Creative mode — one boolean that bypasses inventory checks, hardness, and durability; keep it a UI toggle, not a fork of the systems.
- Dimensions — nether/end are just different
baseBlock(x, y, z, dim)generators plus a portal block that flipsdim; the engine, collision, and streaming code don't change. - World depth — keep a real vertical range (e.g. bedrock at
y = -128) so caves and ores have somewhere to live.
Persist the survival state as typed docs (see the single-world access function
above): one inventory doc per player (inv:<handle>), and a worldReset
singleton the owner writes — clients ignore voxel edits older than its
timestamp. For several worlds, scope these IDs and queries by world as well
(inv:<worldId>:<handle>), and update the access checks to enforce that scope.
Ambient life & atmosphere (cheap, high-impact)
None of this needs persistence — keep it purely visual and client-side:
- Day/night cycle. Advance a
dayTime0..1, drive the sun/moon position and a blended sky/fog color, and fade light intensity. A full day every few minutes feels right. - Mobs. Spawn a small capped population near the player: animals that wander
by day, zombies that shuffle toward the player at night. Build each from a few
BoxGeometryparts in aGroup, apply gravity againstgroundYAt(x,z)(scan the block map downward for the top solid block), let them hop 1-block ledges, and despawn when far or at the wrong time of day. - Clouds. A
Groupof flattened white boxes high overhead withfog: false, following the player horizontally and drifting slowly. - Lighting.
AmbientLight+ aDirectionalLightsun, plus aHemisphereLightfor soft sky/ground fill so faces aren't flat.
Performance checklist
- One
InstancedMeshper chunk of exposed blocks (or Block Craft face batching); keep buried ones in the map for collision/raycast. - Share one geometry + one material table across all blocks, built once
outside every loop; never a
Mesh, geometry or material per block. - Edits dirty a chunk; rebuild at most a chunk or two per frame.
- Stream one chunk per frame; unload out-of-range chunks (dispose meshes).
- Raycast and collide against the block map, never the scene meshes.
- Cap mobs and particles; dispose geometries/materials on unmount and on chunk unload.
renderer.setPixelRatio(Math.min(devicePixelRatio, 2))and aFogto bound draw distance.
That set — instanced exposed-block chunks, per-axis collision with step-up and un-stick, a click-to-play pointer-lock overlay, a DDA raycast, per-voxel Fireproof docs folded forward monotonically, table-driven survival systems, and a flat multi-file split once it grows — is what turns a voxel tech demo into a game people actually play.