|
| 1 | +# Network Sync Layer |
| 2 | + |
| 3 | +The network sync layer adds **transport-agnostic, server-authoritative multiplayer** on top of the existing voxel renderer. It lets multiple clients share the same voxel world in real time without coupling the renderer to any specific transport technology. |
| 4 | + |
| 5 | +## Architecture overview |
| 6 | + |
| 7 | +``` |
| 8 | +┌─────────────┐ local mutation ┌──────────────────┐ sendCommand ┌─────────────┐ |
| 9 | +│VoxelRenderer│──────────────────▶│ VoxelSyncClient │────────────────▶│ Transport │ |
| 10 | +│ (Three.js) │ │ │◀────────────────│ (WebSocket, │ |
| 11 | +│ │◀──applyRemote──── │ │ onCommand │ WebRTC, …) │ |
| 12 | +└─────────────┘ └──────────────────┘ └──────┬──────┘ |
| 13 | + │ wire |
| 14 | + ▼ |
| 15 | + ┌─────────────────┐ |
| 16 | + │ VoxelSyncServer │ |
| 17 | + │ (headless) │ |
| 18 | + │ VoxelWorld │ |
| 19 | + └─────────────────┘ |
| 20 | +``` |
| 21 | + |
| 22 | +**Flow:** |
| 23 | +1. A local mutation (e.g. `setVoxel`) fires the `onLayerUpdated` hook. |
| 24 | +2. `VoxelSyncClient` intercepts the hook, stamps the command with `clientId / seq / timestamp`, and calls `transport.sendCommand(cmd)`. |
| 25 | +3. The transport sends the command over the wire to `VoxelSyncServer.receive()`. |
| 26 | +4. The server validates the command (LWW conflict resolution), applies it to its authoritative `VoxelWorld`, and broadcasts it to all connected clients. |
| 27 | +5. Each client's transport calls `onCommand(cmd)`, which `VoxelSyncClient` routes to `renderer.applyRemoteCommand(cmd)`. |
| 28 | +6. `applyRemoteCommand` sets an internal flag so that the resulting hook event is **not** re-emitted — preventing infinite echo loops. |
| 29 | + |
| 30 | +## VoxelTransport interface |
| 31 | + |
| 32 | +You must supply a concrete transport implementation. The interface is minimal: |
| 33 | + |
| 34 | +```ts |
| 35 | +interface VoxelTransport { |
| 36 | + readonly localClientId: string; |
| 37 | + sendCommand(cmd: VoxelNetworkCommand): void; |
| 38 | + requestSnapshot(): void; |
| 39 | + onCommand: ((cmd: VoxelNetworkCommand) => void) | null; |
| 40 | + onSnapshot: ((snapshot: VoxelWorldJSON) => void) | null; |
| 41 | + onPeerJoined: ((peerId: string) => void) | null; |
| 42 | + onPeerLeft: ((peerId: string) => void) | null; |
| 43 | +} |
| 44 | +``` |
| 45 | + |
| 46 | +### WebSocket example stub |
| 47 | + |
| 48 | +```ts |
| 49 | +import type { VoxelTransport, VoxelNetworkCommand } from "@jolly-pixel/voxel.renderer"; |
| 50 | +import type { VoxelWorldJSON } from "@jolly-pixel/voxel.renderer"; |
| 51 | + |
| 52 | +class WebSocketTransport implements VoxelTransport { |
| 53 | + readonly localClientId = crypto.randomUUID(); |
| 54 | + onCommand: ((cmd: VoxelNetworkCommand) => void) | null = null; |
| 55 | + onSnapshot: ((snapshot: VoxelWorldJSON) => void) | null = null; |
| 56 | + onPeerJoined: ((peerId: string) => void) | null = null; |
| 57 | + onPeerLeft: ((peerId: string) => void) | null = null; |
| 58 | + |
| 59 | + constructor(private ws: WebSocket) { |
| 60 | + ws.addEventListener("message", (ev) => { |
| 61 | + const msg = JSON.parse(ev.data as string); |
| 62 | + switch (msg.type) { |
| 63 | + case "snapshot": this.onSnapshot?.(msg.data); break; |
| 64 | + case "command": this.onCommand?.(msg.data); break; |
| 65 | + case "peer-joined": this.onPeerJoined?.(msg.peerId); break; |
| 66 | + case "peer-left": this.onPeerLeft?.(msg.peerId); break; |
| 67 | + } |
| 68 | + }); |
| 69 | + } |
| 70 | + |
| 71 | + sendCommand(cmd: VoxelNetworkCommand): void { |
| 72 | + this.ws.send(JSON.stringify({ type: "command", data: cmd })); |
| 73 | + } |
| 74 | + |
| 75 | + requestSnapshot(): void { |
| 76 | + this.ws.send(JSON.stringify({ type: "snapshot-request" })); |
| 77 | + } |
| 78 | +} |
| 79 | +``` |
| 80 | + |
| 81 | +## VoxelSyncClient |
| 82 | + |
| 83 | +### Setup |
| 84 | + |
| 85 | +```ts |
| 86 | +import { |
| 87 | + VoxelSyncClient, |
| 88 | + type VoxelSyncClientOptions |
| 89 | +} from "@jolly-pixel/voxel.renderer"; |
| 90 | + |
| 91 | +const client = new VoxelSyncClient({ |
| 92 | + renderer: vr, // pre-constructed VoxelRenderer |
| 93 | + transport: myTransport |
| 94 | +}); |
| 95 | +``` |
| 96 | + |
| 97 | +The client: |
| 98 | +- Replaces `renderer.onLayerUpdated` with its own interceptor. |
| 99 | +- Wires `transport.onCommand` and `transport.onSnapshot`. |
| 100 | + |
| 101 | +### Lifecycle |
| 102 | + |
| 103 | +```ts |
| 104 | +// When the session ends: |
| 105 | +client.destroy(); |
| 106 | +``` |
| 107 | + |
| 108 | +`destroy()` clears `renderer.onLayerUpdated` and the transport callbacks so the renderer |
| 109 | +reverts to standalone mode. |
| 110 | + |
| 111 | +### Options |
| 112 | + |
| 113 | +| Option | Type | Description | |
| 114 | +|--------|------|-------------| |
| 115 | +| `renderer` | `VoxelRenderer` | The local renderer to synchronize. | |
| 116 | +| `transport` | `VoxelTransport` | Your transport implementation. | |
| 117 | + |
| 118 | +## VoxelSyncServer |
| 119 | + |
| 120 | +The server runs **headlessly** (no Three.js dependency) and can be used in Node.js, Deno, or Bun. |
| 121 | + |
| 122 | +### Setup |
| 123 | + |
| 124 | +```ts |
| 125 | +import { VoxelSyncServer, type ClientHandle } from "@jolly-pixel/voxel.renderer"; |
| 126 | + |
| 127 | +const server = new VoxelSyncServer(); |
| 128 | +// Optionally pass an existing world: |
| 129 | +// const server = new VoxelSyncServer({ world: existingVoxelWorld }); |
| 130 | +``` |
| 131 | + |
| 132 | +### WebSocket server example |
| 133 | + |
| 134 | +```ts |
| 135 | +import { WebSocketServer } from "ws"; |
| 136 | + |
| 137 | +const wss = new WebSocketServer({ port: 3000 }); |
| 138 | + |
| 139 | +wss.on("connection", (ws) => { |
| 140 | + const client: ClientHandle = { |
| 141 | + id: crypto.randomUUID(), |
| 142 | + send: (data) => ws.send(JSON.stringify(data)) |
| 143 | + }; |
| 144 | + |
| 145 | + server.connect(client); // sends snapshot to new client |
| 146 | + |
| 147 | + ws.on("message", (raw) => { |
| 148 | + const cmd = JSON.parse(raw.toString()); |
| 149 | + server.receive(cmd); // validate → apply → broadcast |
| 150 | + }); |
| 151 | + |
| 152 | + ws.on("close", () => server.disconnect(client.id)); |
| 153 | +}); |
| 154 | +``` |
| 155 | + |
| 156 | +### API |
| 157 | + |
| 158 | +| Method | Description | |
| 159 | +|--------|-------------| |
| 160 | +| `connect(client)` | Registers client; sends current snapshot; notifies peers. | |
| 161 | +| `disconnect(clientId)` | Removes client; notifies remaining peers. | |
| 162 | +| `receive(cmd)` | Validates, applies, and broadcasts a command. | |
| 163 | +| `snapshot()` | Returns the current world as `VoxelWorldJSON`. | |
| 164 | +| `world` | The authoritative `VoxelWorld` instance. | |
| 165 | + |
| 166 | +### Options |
| 167 | + |
| 168 | +| Option | Type | Default | Description | |
| 169 | +|--------|------|---------|-------------| |
| 170 | +| `world` | `VoxelWorld` | new world | Existing world to use as authoritative state. | |
| 171 | +| `chunkSize` | `number` | `16` | Chunk size when creating a new world. | |
| 172 | +| `conflictResolver` | `ConflictResolver` | `LastWriteWinsResolver` | Custom conflict strategy. | |
| 173 | + |
| 174 | +## VoxelNetworkCommand — wire format |
| 175 | + |
| 176 | +A `VoxelNetworkCommand` is a `VoxelLayerHookEvent` extended with routing metadata: |
| 177 | + |
| 178 | +```ts |
| 179 | +type VoxelNetworkCommand = VoxelLayerHookEvent & { |
| 180 | + clientId: string; // originating client ID |
| 181 | + seq: number; // monotonically increasing per client |
| 182 | + timestamp: number; // Unix ms (Date.now()) at time of mutation |
| 183 | +}; |
| 184 | +``` |
| 185 | + |
| 186 | +Commands are plain JSON-serializable objects — no special framing required. |
| 187 | + |
| 188 | +## ConflictResolver |
| 189 | + |
| 190 | +### Default: LastWriteWinsResolver |
| 191 | + |
| 192 | +The default resolver uses **timestamp** to determine which command wins at a given voxel |
| 193 | +position. On a tie, the lexicographically greater `clientId` wins (deterministic without |
| 194 | +coordination). |
| 195 | + |
| 196 | +```ts |
| 197 | +import { LastWriteWinsResolver } from "@jolly-pixel/voxel.renderer"; |
| 198 | + |
| 199 | +const server = new VoxelSyncServer({ |
| 200 | + conflictResolver: new LastWriteWinsResolver() // default, no need to pass explicitly |
| 201 | +}); |
| 202 | +``` |
| 203 | + |
| 204 | +### Custom resolver |
| 205 | + |
| 206 | +Implement `ConflictResolver` for custom strategies (e.g. first-write-wins, priority by |
| 207 | +role, etc.): |
| 208 | + |
| 209 | +```ts |
| 210 | +import type { ConflictResolver, ConflictContext } from "@jolly-pixel/voxel.renderer"; |
| 211 | + |
| 212 | +class FirstWriteWinsResolver implements ConflictResolver { |
| 213 | + resolve({ existing }: ConflictContext): "accept" | "reject" { |
| 214 | + // Accept only if no prior command exists at this position |
| 215 | + return existing ? "reject" : "accept"; |
| 216 | + } |
| 217 | +} |
| 218 | + |
| 219 | +const server = new VoxelSyncServer({ conflictResolver: new FirstWriteWinsResolver() }); |
| 220 | +``` |
| 221 | + |
| 222 | +> **Note:** Conflict resolution only applies to per-position voxel operations (`"voxel-set"`, |
| 223 | +> `"voxel-removed"`). Structural layer operations (`"added"`, `"removed"`, `"reordered"`, etc.) |
| 224 | +> are always accepted. |
| 225 | +
|
| 226 | +## VoxelCommandApplier — headless usage |
| 227 | + |
| 228 | +`applyCommandToWorld` lets you replay hook events against a bare `VoxelWorld` without a |
| 229 | +renderer. Useful for server-side logic, unit tests, or offline editing tools. |
| 230 | + |
| 231 | +```ts |
| 232 | +import { VoxelWorld, applyCommandToWorld } from "@jolly-pixel/voxel.renderer"; |
| 233 | + |
| 234 | +const world = new VoxelWorld(16); |
| 235 | +applyCommandToWorld(world, { |
| 236 | + action: "added", |
| 237 | + layerName: "Ground", |
| 238 | + metadata: { options: {} } |
| 239 | +}); |
| 240 | +applyCommandToWorld(world, { |
| 241 | + action: "voxel-set", |
| 242 | + layerName: "Ground", |
| 243 | + metadata: { |
| 244 | + position: { x: 0, y: 0, z: 0 }, |
| 245 | + blockId: 1, |
| 246 | + rotation: 0, |
| 247 | + flipX: false, |
| 248 | + flipZ: false, |
| 249 | + flipY: false |
| 250 | + } |
| 251 | +}); |
| 252 | +``` |
0 commit comments