Skip to content

Building an open world in the browser, part 24: Saving a world, and wind you can see

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.

Part 23 put people in the world and gave them a voice. This part is about making the world remember what they did to it, and making it feel alive when nobody's touching it. Spike 47 is persistence: a creator sculpts terrain and places props, and those edits survive a reload, sync to every other peer, and arbitrate cleanly when two people edit at once. Spike 49 is wind: porting a stylized nature pack's vegetation shaders so trees, bushes, and grass move the way the artist intended, which turned into a fight with the shader compiler more than with the math.

A world that remembers

Open Spike 47 in a new tab ↗ · View source

The setup is a shared authoring session. Players walk a world, place props with a click, remove them with a right-click, and sculpt the ground by dragging a brush, raising and lowering the heightmap or carving volumetric caves through the SDF terrain that promotes a chunk to marching cubes. Everything they do persists in a Cloudflare WorldChunkDO, a server-authoritative Durable Object backed by its own SQLite storage. Clients don't write state directly. They send intent, the DO arbitrates and broadcasts the result, and the DO is the single source of truth, so a fresh joiner gets a snapshot and lands in exactly the same world everyone else sees.

Two persistence details earned their keep. Terrain isn't stored as an event log to be replayed on join; it's stored as per-chunk binary blobs that are the source of truth, uploaded on stroke-end, so a joiner loads the committed bytes directly instead of re-running thousands of brush samples. And those blobs live one storage key per chunk rather than one big row, because a single SDF chunk is 168 KB and the DO has a 2 MB per-row limit. A chunk index tracks which keys exist so the DO can rehydrate the whole map on wake. Identity is handled with two storages on the client: the player id lives in sessionStorage so two tabs are two distinct peers rather than one peer that overwrites itself in the DO's player map, while the display name lives in localStorage so a rename in one tab carries across all of them.

Making concurrent edits converge

The genuinely hard part of multiplayer authoring is what happens when two people edit overlapping ground in the same instant. The spike splits edits by their algebra. Additive operations, raise and lower on the heightmap and add and subtract on the SDF, are commutative: applying them in any order lands on the same result, so they ride an optimistic-stamp path where each client applies locally and ships the stamp, and the DO broadcasts it to everyone with no coordination. Order genuinely doesn't matter, so there's nothing to coordinate.

The order-dependent operations, smooth and flatten, were the interesting case. The first design gave them a region lock: a client requests a lock on pointer-down, the DO grants or denies it, the client buffers samples during the press, and on pointer-up the DO applies the whole stroke atomically. It works, but it's a separate protocol with its own lock TTL and grant/deny round-trip. The cleaner answer that replaced it is a precomputed delta: the originator runs the smooth or flatten brush locally, then ships the resulting list of per-cell deltas, and every peer just adds those deltas to their own cells without re-deriving anything. That turns an order-dependent operation into a commutative one by freezing its result at the source, so the whole edit system runs on one uniform commutative protocol with identical convergence and no locks at all. Prop locks survive for a different reason: per-record locking replaced owner-only deletion, so any peer can delete any prop unless someone has locked it, and only the locker can clear it. Undo and redo work by letting the client name a prop's id before placing it, so it knows the id ahead of the server echo and can reverse its own actions deterministically. Hibernating WebSockets keep an idle room free throughout, the same property that made the avatar relay in the previous part cheap.

Wind ported faithfully, then fought

Open Spike 49 in a new tab ↗ · View source

Spike 49 takes Quaternius's Stylized Nature MegaKit and ports its wind to our stack. The pack ships its source Godot shaders, four of them, and the right move was a faithful translation rather than a re-invention. Bark has an empty vertex function, so trunks are rigid; an earlier procedural-mask attempt had been making trunks wave, and the fix was simply to stop applying wind to bark at all. Leaves get a per-vertex chaotic sway from a triangular-pulse hash, masked by height so canopies move and the base stays planted. Base foliage gets a noise-modulated sin/cos sway in world space. Grass is base foliage plus a wind-line bobble, sampling a scrolling noise texture through a power curve so only the bright bands of the texture contribute, which produces the visible ripples that travel across a meadow. The dispatch follows the pack's own material-naming convention, so a material called Leaves_Birch routes to the leaves path and Grass_Common to the grass path, no guessing.

A surprise from the port is that the leaf color isn't in the texture. Quaternius authors leaf appearance entirely from a vertical gradient and a Fresnel rim: the albedo is a mix from an extra color at the canopy bottom to the leaf color at the top, keyed on height, with a subsurface-scattering tint added as emission scaled by a (1NV)3 Fresnel term. That's why a plain textured pass looked flat. The colors come from the gradient and the rim, not the painted map. The port reads a baked heightFactor vertex attribute instead of raw local Y, normalized per leaf group at load time from world-space Y, so the gradient and the wind mask both behave correctly no matter how the FBX import rotated each mesh's local axes. The Godot color constants are tagged sRGB and converted to linear before the shader sees them, so the port does the same conversion rather than feeding the bright sRGB numbers in as linear and washing the foliage out.

The recompile that ate the frame rate

The reason this shipped as an FBX baseline first, with the full wind material set aside in a .bak file, is a per-frame recompile bug that dropped the scene to about 1 fps. Layering custom TSL onto FBX-loaded materials was triggering Three.js to rebuild shader programs every frame, with needsUpdate effectively stuck on. The diagnostic discipline was to strip each foliage material back to a plain textured pass with no custom nodes and watch whether the recompile loop persisted. If it stopped, the custom graph was the culprit; if it continued, the cause was upstream in the FBX material setup or Three.js itself. The fix that let the real shaders come back was binding every per-material difference, leaf color, SSS color, strength, and blend, as uniforms so all leaf assets share one compiled program instead of the compiler emitting a fresh shader for every unique color combination and thrashing the compile queue.

Two more pieces are worth keeping. Grass renders as one InstancedMesh per source file, and its wind is computed in world space because the sin phases key off world position. But the displacement has to be applied in local space before the instance matrix runs, and WGSL has no inverse() to call. For an instance matrix of translation, a Y rotation, and a uniform scale, the inverse of the upper 3×3 is just its transpose divided by the scale squared, so the shader multiplies the world displacement by the transposed model matrix and divides by the squared length of the matrix's first column, recovering the scale without a square root. After the vertex transform re-applies the matrix, the motion lands in world space exactly as authored, independent of each tuft's rotation or scale. And each tuft runs a per-vertex GPU frustum cull: it projects the instance center to clip space, and if it falls outside the frustum with margin, it collapses every vertex to the local origin so all three vertices of each triangle coincide, the rasterizer drops the degenerate triangle, and no fragment, alpha-test, or shadow work happens for off-screen grass. That stacks on top of the coarse per-chunk bounding-sphere cull Three.js already does, built from float step rather than booleans so it multiplies straight into the position mix.

Technology referenced in this chapter

Server-authoritative world persistence. A WorldChunkDO Durable Object arbitrates prop placement and terrain edits, persists per-chunk binary blobs as the source of truth (so joiners load committed bytes instead of replaying an event log), and stores one storage key per chunk to stay under the DO's 2 MB per-row limit. Player id lives in sessionStorage so tabs are distinct peers; display name lives in localStorage so renames carry across tabs.

Commutative edit convergence. Additive terrain ops (raise/lower, SDF add/subtract) are commutative and ride an optimistic-stamp path with no coordination. Order-dependent ops (smooth, flatten) are made commutative by shipping precomputed per-cell deltas instead of acquiring a region lock, so the whole system converges under one uniform protocol with no locks. Per-record prop locks replace owner-only deletion, and client-named object ids enable deterministic undo/redo before the server echo arrives. See GPU-driven LOD.

Faithful shader porting from Godot to TSL. Quaternius's four source wind shaders translate line-for-line: rigid bark, height-masked leaf sway, world-space foliage sway, and grass with a scrolling wind-line bobble, dispatched by the pack's material-naming convention. Leaf color comes from a height gradient plus a Fresnel-driven SSS rim rather than the texture, and sRGB authoring constants are converted to linear so the look matches the reference renders.

Avoiding per-frame shader recompiles. Layering custom TSL on FBX materials can pin needsUpdate and rebuild programs every frame, collapsing to ~1 fps. Binding every per-material difference as a uniform lets all variants share one compiled program instead of emitting a fresh shader per unique parameter set. A plain-textured diagnostic pass isolates whether the custom graph or the upstream setup is the cause.

World-space wind on instanced foliage. Grass wind is computed in world space and transformed back to local with a hand-derived inverse (transpose over scale-squared) because WGSL has no inverse(). A per-vertex GPU frustum cull collapses off-screen tufts to a degenerate triangle so no fragment or shadow work runs, stacking on top of Three.js's per-chunk bounding-sphere cull.


Part 24 of 29. Previous: Part 23 - Fifty avatars and a voice in the room Next: Part 25 - One skeleton, every outfit Series guide: /blog/2026-02-25-open-world-browser-series-guide