Building an open world in the browser, part 16: Structure for a world that keeps growing
By Oleg Sidorkin, CTO and Co-Founder of Cinevva
New here? Use the series guide. It explains what a spike is and links all parts.
Every spike since part 13 had followed the same recipe: copy the previous monolith, add one feature. By the end of spike 32 that monolith was 6,285 lines of index.html in a single <script type="module">. Code search was noisy, finding where to add a feature took longer than writing it, and any architectural change touched a file too big to diff in your head. Before adding another feature we paid down the structure.
Breaking the monolith with zero behavior change
Open Spike 33 in a new tab ↗ · View source
The constraint was strict: every split had to be a pure refactor, not a redesign. The monolith became 19 .mjs files plus a 151-line host shell. Top-level modules for scene, water, grass, character, physics, multiplayer, and UI, a wgsl.mjs that holds every WGSL source string as the single source of GPU truth, and a terrain/ subtree for heightmap, SDF, chunks, GPU buffer wrappers, brush, LOD, and persistence. Total code came out at 6,555 lines, basically the monolith plus import boilerplate. No net change in volume, a large change in navigability.
Then the page loaded to a black screen. Two error messages, two unrelated root causes. The first was a WebGPU complaint about a zero-byte buffer binding. In the monolith, the SDF brush buffer was lazily allocated when the first marching-cubes chunk appeared, and the bind-group factory happened to run later, after the buffer existed. Splitting terrain/gpu.mjs from terrain/brush.mjs reordered module evaluation so the factory now ran first and tried to bind a null placeholder. The fix was to defer bind-group creation to the first dispatch with a getOrCreateBindGroup(chunk) helper. The "create everything up front" pattern was an artifact of the monolith's single init path.
The second message was a scary-looking FBX skeleton warning that turned out to be a red herring. It had printed since spike 25 and was harmless. The character was missing only because the first bug cascaded: every compute dispatch threw, the heightmap never got written, height samples returned 0, and the character spawned at the origin and fell through the world. Fix the buffer, the character animates fine, warning and all.
That's the real lesson of the refactor. The monolith hid every "X must exist before Y is built" relationship inside top-to-bottom script order. Modularizing shuffled that order and surfaced three more latent ordering bugs: grass scattering before the heightmap upload, the water plane added before the environment map finished decoding, and persistence load completing after the first frame. All three were one-line fixes, and none would have been caught without the split.
A hundred props without growing anything
Open Spike 34 in a new tab ↗ · View source
Spike 34 was the test of whether the structure paid off. The goal was a first-person palette to place trees, rocks, bushes, mushrooms, and paths from a CC0 model pack, with terrain-aware alignment, persistence, multiplayer sync, and physics colliders, all without leaving the controls. Every line of new code landed in five new files under src/props/, and no existing module grew by more than ten hook-up lines.
The asset arc took a detour worth recording, because it's the kind of thing that eats a day. We started on Quaternius's Ultimate Nature pack, an FBX library with no embedded textures. The FBX materials shipped as MeshPhong with no map, so we wired a manual material-name-to-PNG table, converted Phong to Standard, and set color spaces by hand. About 30% of materials had no matching PNG and several names were ambiguous between similar trees. A second FBX pack had the same gap. The fix wasn't more mapping tables, it was a better-authored pack: Quaternius's Stylized Nature MegaKit ships 116 complete glTFs with embedded PBR materials and baked normals. Swapping FBXLoader for GLTFLoader deleted the cm-to-meters scaling, the texture table, and the Phong conversion, and shrank library.mjs by about 80 lines. The takeaway: glTF with PBR is the right pipeline for CC0 packs that ship it, and FBX with manual texture mapping was twice the code for half the quality.
A few sharp edges came with the glTF path. The palette renders 116 thumbnails, and WebGPU's canvas.toDataURL() returns blank for a GPUCanvasContext surface, so thumbnails render into a RenderTarget, read back with readRenderTargetPixelsAsync, and blit into a 2D canvas, minding WebGPU's 256-byte row alignment. Ghost previews clone each material to tint them green, which broke on meshes whose material is an array, fixed with an Array.isArray branch. And the 16-bit normal maps that shipped at ~200 MB got a one-off mogrify -depth 8 down to ~32 MB, visually identical since browsers downsample at upload anyway.
When the rendered geometry only exists on the GPU
The most instructive bug was the ghost preview snapping in 1 to 2 meter steps as the cursor moved. The terrain meshes store vertex positions in a StorageBufferAttribute because the compute pipeline writes them directly on the GPU, so three.js's CPU Raycaster can't see them and returns nothing. The fallback was a coarse 1.5 m ray-march against the analytic heightmap, and that fixed stride was the grid the user saw. We replaced it with an adaptive march: stride 2.5 m while high above the surface, shrink to 0.4 m within 5 m of it, then bisect 14 times when the sign of
Colliders that stay honest through edits
We chose primitive proxies over convex hulls or mesh colliders. Quaternius props are blobby and low-poly with no meaningful concavities, so hulls would be roughly 50 times the code and 10 times the runtime cost for the same gameplay. Each prop reduces to one shape derived from its bounding box: trees and cacti to a vertical capsule, rocks to a sphere, logs to a horizontal capsule along the long axis, and decorative bushes and flowers to nothing. Rocks and logs are walkable (vertical-only push so you can stand on them), trees and cacti are blocking (full 3D push so you can't climb a trunk). An 8 m spatial hash keeps the per-frame test to the player's 3×3 neighborhood, typically zero to six props.
Two design decisions kept the system coherent. Terrain alignment is a manifest flag, not a category enum hard-coded in code, so the ghost preview and the committed placement read the same placement.alignToTerrain value and can't disagree. And placed props react to terrain edits through one helper: after a brush stroke (local or replayed from a peer), refreshPlacementsInRadius resamples the ground under every prop in the affected disc, re-applies alignment, and re-derives the collider endpoints. Sculpt a hill under a tree and the tree rides up with it. Persistence and multiplayer reuse spike 31's pattern exactly, storing a flat list of {uid, propId, x, y, z, rotY, scale} and mirroring place, remove, and nudge events over BroadcastChannel.
Technology referenced in this chapter
ES module decomposition under WebGPU. Splitting a monolithic <script type="module"> into bare-path .mjs imports needs no bundler when modules are served as static assets, and three.js TSL plays fine across module boundaries. The hidden cost is initialization order: a monolith encodes "build X before Y" in top-to-bottom script order, while modules evaluate in import order, which can run a GPU bind-group factory before its buffer exists. The fix pattern is lazy initialization (getOrCreate... on first use) and awaiting the right promise rather than relying on declaration order.
glTF with embedded PBR vs FBX with manual mapping. glTF ships in meters, references its own textures, and gives MeshStandardMaterial directly, so a CC0 pack authored as glTF drops straight into a PBR pipeline. FBX packs without texture-binding metadata require a hand-maintained material-name-to-PNG table that drifts on every pack update, plus a Phong-to-Standard conversion and manual color-space tagging. A foliage safety net promotes transparent materials with no alphaTest to alphaTest: 0.5 cutout cards so they sort correctly behind opaque geometry.
WebGPU offscreen thumbnails. canvas.toDataURL() returns blank for a GPUCanvasContext-backed canvas because there's no path from a presentation surface back to a 2D context. Rendering into a RenderTarget, reading pixels with readRenderTargetPixelsAsync, and blitting into a 2D canvas works, as long as the blit steps at WebGPU's 256-byte-aligned read-back stride. Results cache in localStorage under a version-bumped key so pack changes invalidate stale renders.
Adaptive ray-march against an analytic heightmap. When terrain vertices live in a GPU StorageBufferAttribute, the CPU raycaster can't see them. Marching the analytic height function with a large stride far from the surface, a small stride near it, and a binary-search refinement on the sign flip of
Primitive capsule colliders with a spatial hash. Each prop is reduced to a category-derived capsule or sphere from its bounding box, recorded as {kind, walkable, radius, p1, p2} and registered in every 8 m hash bucket it overlaps. Per frame the player tests only the props in their 3×3 bucket neighborhood, one capsule-vs-capsule resolution each. Walkable proxies (rocks, logs) get a vertical-only push, blocking proxies (trees) get the full 3D push. See SDF terrain collisions for the capsule math this builds on.
Part 16 of 29. Previous: Part 15 - Replace the baseline, then sync it Next: Part 17 - Animations that didn't need retargeting, and a live asset search Series guide: /blog/2026-02-25-open-world-browser-series-guide