Building an open world in the browser, part 15: Replace the baseline, then sync it
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.
The first fourteen parts covered spikes 1 through 30. That run ended with a terrain system that could be sculpted in real time, plus a character who could walk, glide, and fall on it. This part picks the series back up at spike 31, and the first thing we had to decide wasn't technical. It was what to do with all that spike code.
The "replace, don't backport" decision
We had 30 standalone HTML files, each proving one isolated concept, and zero integration. The production world/client/ was still the old stack: WebGL, a simple heightmap, a 75-line character controller, a MessagePack protocol with nine message types. No editing, no WebGPU, no materials, no vegetation.
The obvious plan was to backport spike results into that production codebase one at a time. We threw it out. Spike 30 already had better terrain, physics, materials, vegetation, and camera than world/client/ ever did. Backporting into the old WebGL code would mean fighting it the whole way. So we made the call to replace the world implementation with the most successful spike and build forward. Spike 30 became the new baseline, and world/client/ became dead code.
That reframed the remaining work. To get from "great single-player tech demo" to "product" we needed multiplayer, persistence, infinite-world streaming, and object placement. Multiplayer terrain sync went first, because it's the one that forces architecture. The question it answers is simple to ask and expensive to get wrong: when Player A sculpts, what actually goes over the wire?
Brush-param replay, not pixel sync
Open Spike 31 in a new tab ↗ · View source
Before writing a line of network code, we traced exactly what a brush stroke does. The heightmap brush walks a radius around the cursor in a CPU Float32Array, applies a smoothstep falloff, and either adds, subtracts, smooths, or flattens. The SDF brush does the same thing in 3D over a sphere of voxels. Both paths are pure CPU array math. No GPU compute in the loop, no randomness, no nondeterministic floating point. Same input array plus same params equals same output, on every machine.
That's the whole trick. We don't send the edited terrain. We send the brush parameters, 56 bytes per stroke tick, and every client replays the same deterministic function. The sync protocol is four message types: peer discovery, the brush message {op, wx, wy, wz, radius, strength, flattenTarget}, and a player-position message at 20Hz.
For the spike we skipped the server entirely and used BroadcastChannel, the browser API for same-origin cross-tab messaging. Open two tabs, they talk, zero infrastructure. That isolates the sync question from latency, auth, and Durable Object wiring. If param replay converges across tabs, it'll converge across a WebSocket too.
The one place replay can diverge is order-dependent operations. Raise and lower are commutative, so val + strength * falloff lands the same regardless of who applied first. Smooth and flatten read neighbor values, so two clients smoothing the exact same spot in the same instant can drift by fractions of a millimeter per tick. In practice that never fires, and the production fix is already obvious: route edits through the DO, let it assign a monotonic sequence number, apply optimistically on the client, and correct order if the authoritative sequence disagrees. Classic optimistic concurrency, and the DO is a natural serialization point anyway.
The peer capsule that kept vanishing
Edits synced on the first try. The remote player's capsule did not. It flickered in and out of existence on the other tab, and it took three separate bugs to make it stay solid.
The capsule spawned at the world origin, which is buried under the terrain, because the join message arrives before any position data. Fix: start it hidden and reveal it on the first position update. The position broadcast lived inside the render loop, and Chrome throttles requestAnimationFrame on unfocused tabs, so the other tab's staleness check would reap the peer and the next message would recreate it. Fix: move the broadcast to a setInterval, which isn't throttled for visible tabs. And the staleness timeout was a too-aggressive 5 seconds, tripped by any GC pause. Fix: raise it to 30 seconds and rely on the clean leave message for normal closes.
Persistence and late-join, same format
We folded persistence into the same spike instead of spinning up a new one, because the serialization format is identical whether the destination is IndexedDB or another tab. A snapshot is the full heightmap (a 129×129 Float32Array, about 66 KB) plus only the edited SDF chunks (each
The first persistence test surfaced a nice ordering bug. Grass is scattered synchronously at init using the procedural heights, but the IndexedDB restore is async and overwrites the heightmap afterward, leaving every blade floating or sunk. The fix is a refreshAllGrass() pass that resamples the height under each instance and hides any blade now on a bad slope or altitude. The same function serves both load and late-join.
The slope saga
Sculpted terrain is rougher than the smooth procedural baseline, and it exposed three physics bugs the old terrain never could. Walking straight uphill made the capsule slide sideways. The cause was a velocity projection meant to keep movement tangent to the ground, but written with only the horizontal components of the normal. On a diagonal slope with normal
The drift persisted from a second source. The SDF collision probes push the body out along the gradient by the penetration depth. On any slope the gradient has horizontal components, so a 0.1 m penetration on a 15° slope pushes about 0.026 m sideways per step, and at 120Hz that's roughly 3 m/s of invisible drift. Fix: split the response by slope. On walkable ground (
The third bug froze the capsule at chunk boundaries, because collision probes sampled a single chunk's SDF and got the "deep in air" sentinel when a probe crossed into the neighbor. The fix was sdfSampleWorld(wx, wy, wz) and sdfGradientWorld(...), which find the right chunk for any world position and fall back to a heightmap distance estimate where no SDF exists. The SDF-to-heightmap collision transition is continuous now.
Water completes the world
Open Spike 32 in a new tab ↗ · View source
Every spike up to here was "land above water." Spike 32 added an ocean, and with it a new movement verb. We set the water level at 22 in a terrain that ranges roughly 8 to 58, which floods the low valleys, leaves beaches at the shoreline, and keeps plenty of dry land for play.
The surface is a MeshStandardNodeMaterial built in TSL, the same node approach as the terrain. Three overlapping sine waves at different frequencies displace the vertices, and the surface normal comes from the analytic cosine derivatives of those waves rather than from mesh normals. Color blends from turquoise shallows to dark teal depths using a depth estimate
Swimming is a buoyancy spring. The player enters swim mode when the feet drop below the water level and the body center is within a capsule half-height of the surface. A spring pulls the body toward a target just under the surface, and with a buoyancy constant of 12 against a water damping of 4 the player bobs stably with their head out, no oscillation. Swim speed is slower than walking with floaty acceleration and drag, jumping near the surface launches you out at 60% of normal jump velocity, and entry caps downward velocity at -5 m/s so you don't plunge. Terrain collision still runs underwater, so you can walk the lake bed where it rises above the swim target. The swimming flag rides along in the position broadcast so peers see you swim, and an HTML gradient overlay tints the view when the camera dips below the surface.
Technology referenced in this chapter
Deterministic brush-param replay. Instead of streaming edited terrain, each client sends only the brush parameters and replays the same CPU function. This works because both the heightmap and SDF brushes are pure Float32Array math with no randomness or GPU nondeterminism, so identical inputs produce bit-identical outputs everywhere. Payload is 56 bytes per stroke tick. Commutative operations (raise, lower) converge regardless of order, while neighbor-reading operations (smooth, flatten) need a serialization point to guarantee convergence, which the production Durable Object provides via monotonic sequence numbers.
BroadcastChannel as a WebSocket stand-in. A browser API for same-origin cross-tab messaging with zero server. Used here to test the sync protocol in isolation from network latency and authentication. The serialization format (raw Float32Array heightmap plus edited SDF chunks plus MC-locked chunk IDs) is the same bytes used for IndexedDB persistence and late-join state transfer, so one format covers three jobs.
Slope-split SDF collision response. When a capsule probe penetrates volumetric terrain, the naive fix pushes the body out along the SDF gradient by the penetration depth. On slopes that gradient has horizontal components, injecting lateral drift. Splitting the response so walkable surfaces (
TSL water with analytic wave normals. The ocean is a node material whose vertices are displaced by three summed sine waves. Rather than recomputing mesh normals after displacement, the surface normal is derived analytically from the cosine derivatives of the wave functions, which is cheaper and avoids the artifacts of finite-difference normals on a coarse grid. Depth-driven color, shoreline foam, and depth-driven transparency all key off a single depth estimate.
Buoyancy-spring swimming. Swim physics models the body as a damped spring pulled toward a target just below the surface. With buoyancy constant 12 and damping 4, the player settles at the surface without oscillating. Distinct movement constants (slower speed, floaty acceleration, heavy drag) give swimming a different feel from walking, and the existing capsule-vs-terrain collision keeps working underwater.
Part 15 of 29. Previous: Part 14 - The world comes alive Next: Part 16 - Structure for a world that keeps growing Series guide: /blog/2026-02-25-open-world-browser-series-guide