To get the fastest possible working prototype, we should use the Isomorphic
TypeScript Route: Bun (or Node.js) paired with Rapier3D via WebSockets.
Because your voxel logic is already written in TypeScript, the server can
instantly re-use your exact quadrant.ts math and Uint32Array logic. You won't
have to rewrite your coordinate systems in Rust or C++.
Here is the conceptual blueprint and pseudocode for the MVP. We will build a
single authoritative headless server that tracks voxel edits, turns them into
invisible static physical boxes, drops bouncy balls into the world, and
streams their kinematic states back to the browser for predictive rendering.
1. The Shared Protocol (Isomorphic Math)
Both the client and server will agree on a highly compressed array format for
the network. We bypass JSON overhead for the physics stream entirely.
/ shared/protocol.ts // Physics State Packet: [entityId, px, py, pz, vx, vy,
vz] export const PACKET_SIZE = 7; export function decodeKinematicPacket(buffer:
Float32Array) { const entities = []; for (let i = 0; i < buffer.length; i +=
PACKET_SIZE) { entities.push({ id: buffer[i], pos: { x: buffer[i+1], y:
buffer[i+2], z: buffer[i+3] }, vel: { x: buffer[i+4], y: buffer[i+5], z:
buffer[i+6] } // Note: Skipping rotation/acceleration for the V1 Demo to keep
it fast }); } return entities; }
2. The Headless Physics Node (Server)
The server runs Rapier3D. It listens to the exact same RingBuffer edits your
client generates, places 1x1x1 static box colliders at those world
coordinates, and steps the physics world at 60Hz.
/ server/physics_node.ts import RAPIER from '@dimforge/rapier3d'; import {
WebSocketServer } from 'ws'; import { worldToQuadrant } from '../shared/quadran
t'; const world = new RAPIER.World({ x: 0.0, y: -9.81, z: 0.0 }); const
voxelColliders = new Map<string, RAPIER.Collider>(); // Track boxes to remove
them const dynamicEntities = new Map<number, RAPIER.RigidBody>(); // 1. Spawn
a test bouncing ball const bodyDesc = RAPIER.RigidBodyDesc.dynamic().setTransla
tion(4, 20, 4); const ballBody = world.createRigidBody(bodyDesc); const
colliderDesc = RAPIER.ColliderDesc.ball(0.5).setRestitution(0.7);
world.createCollider(colliderDesc, ballBody); dynamicEntities.set(1,
ballBody); // Entity ID 1 const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', (ws) => { // 2. Listen for building edits from the client
ws.on('message', (data) => { const edit = JSON.parse(data); const { wx, wy,
wz, voxelData } = edit; const key = `${wx},${wy},${wz}`; if (voxelData !== 0)
{ // Block Added if (!voxelColliders.has(key)) { // Create an invisible 1x1x1
static box const rigidBody = world.createRigidBody(RAPIER.RigidBodyDesc.fixed()
.setTranslation(wx + 0.5, wy + 0.5, wz + 0.5)); // Rapier uses half-extents
for boxes (0.5 = 1 unit total) const collider = world.createCollider(RAPIER.Col
liderDesc.cuboid(0.5, 0.5, 0.5), rigidBody); voxelColliders.set(key,
collider); } } else { // Block Removed const collider = voxelColliders.get(key)
; if (collider) { world.removeCollider(collider, true); voxelColliders.delete(k
ey); } } }); }); // 3. The 60Hz Physics Tick & Stream Firehose setInterval(()
=> { world.step(); // Pack all dynamic bodies into a single Float32Array const
streamBuffer = new Float32Array(dynamicEntities.size * 7); let offset = 0;
dynamicEntities.forEach((body, id) => { const pos = body.translation(); const
vel = body.linvel(); streamBuffer[offset++] = id; streamBuffer[offset++] =
pos.x; streamBuffer[offset++] = pos.y; streamBuffer[offset++] = pos.z;
streamBuffer[offset++] = vel.x; streamBuffer[offset++] = vel.y; streamBuffer[
offset++] = vel.z; }); // Broadcast raw binary to all connected clients
wss.clients.forEach(client => client.send(streamBuffer)); }, 1000 / 60);
3. The Predictive Client (Babylon.js)
The client keeps rendering your chunked terrain exactly as it does now. We
just add a WebSocket listener to receive the kinematic states, and use the
Babylon.js render loop to perform Dead Reckoning so the ball moves perfectly
smoothly at 144Hz, even if the server is ticking at 60Hz.
/ client/renderer.ts import { decodeKinematicPacket } from '../shared/protocol'
; const ws = new WebSocket('ws://localhost:8080'); ws.binaryType =
'arraybuffer'; // Store the visual representations of our dynamic physics
objects const visualEntities = new Map<number, { mesh: Mesh, velocity: Vector3
}>(); // 1. Receive the ground-truth from the server ws.onmessage = (event) =>
{ if (event.data instanceof ArrayBuffer) { const kinematics =
decodeKinematicPacket(new Float32Array(event.data)); for (const entity of
kinematics) { let visual = visualEntities.get(entity.id); // Spawn a visual
sphere if it's new if (!visual) { const mesh = MeshBuilder.CreateSphere(`ent_${
entity.id}`, { diameter: 1 }, scene); visual = { mesh, velocity:
Vector3.Zero() }; visualEntities.set(entity.id, visual); } // Snap to the
authoritative position (Interpolation can be added later) visual.mesh.position.
set(entity.pos.x, entity.pos.y, entity.pos.z); visual.velocity.set(entity.vel.x
, entity.vel.y, entity.vel.z); } } }; // 2. Broadcast our building edits to
the server function onVoxelPlaced(wx, wy, wz, voxelData) { // (Your existing
ringBuffer.push goes here) // Tell the physics engine a block appeared!
ws.send(JSON.stringify({ wx, wy, wz, voxelData })); } // 3. The Predictive
Dead Reckoning Loop engine.runRenderLoop(() => { const deltaTime =
engine.getDeltaTime() / 1000; // Time in seconds since last frame // Predict
the new position based on $P_t = P_0 + V \cdot \Delta t$ visualEntities.forEach
(visual => { visual.mesh.position.addInPlace(visual.velocity.scale(deltaTime));
}); scene.render(); });
Why this MVP path works
Zero UI/Voxel changes: Your Web Worker and OPFS code remain completely
untouched. The physics engine is purely additive.
True Decoupling: The server handles collision geometry (Rapier boxes). The
client handles visual geometry (Babylon chunks). They only share coordinate
math.
Instant Validation: The moment you connect, a ball will fall from the sky. If
you click to place a block underneath it, the server spawns the Rapier box,
the ball bounces, and the kinematic vector streams back to the client
instantly.