Skip to content

Commit 1bbf36d

Browse files
committed
feat(voxel-renderer): implement new Network APIs to synchronize world between multiple clients
1 parent 9bcd1e2 commit 1bbf36d

File tree

17 files changed

+2127
-49
lines changed

17 files changed

+2127
-49
lines changed

.changeset/better-bikes-repair.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@jolly-pixel/voxel.renderer": major
3+
---
4+
5+
Implement new Network API to synchronize world between multiple clients

packages/voxel-renderer/docs/Hooks.md

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,25 +24,53 @@ const vr = new VoxelRenderer({
2424
});
2525
```
2626

27+
You can also set (or replace) the hook after construction:
28+
29+
```ts
30+
vr.onLayerUpdated = (event) => { /* ... */ };
31+
// Clear the hook:
32+
vr.onLayerUpdated = undefined;
33+
```
34+
2735
## Event reference
2836

2937
`VoxelLayerHookEvent` is a discriminated union keyed on `action`. Narrowing on `action`
3038
gives you a precise `metadata` type with no casting required.
3139

32-
| `action` | `metadata` shape |
33-
|---|---|
34-
| `"added"` | `{ options: VoxelLayerConfigurableOptions }` |
35-
| `"removed"` | `{}` |
36-
| `"updated"` | `{ options: Partial<VoxelLayerConfigurableOptions> }` |
37-
| `"offset-updated"` | `{ offset: VoxelCoord }` or `{ delta: VoxelCoord }` |
38-
| `"voxel-set"` | `{ position, blockId, rotation, flipX, flipZ, flipY }` |
39-
| `"voxel-removed"` | `{ position: Vector3Like }` |
40-
| `"reordered"` | `{ direction: "up" \| "down" }` |
41-
| `"object-layer-added"` | `{}` |
42-
| `"object-layer-removed"` | `{}` |
43-
| `"object-layer-updated"` | `{ patch: { visible?: boolean } }` |
44-
| `"object-added"` | `{ objectId: string }` |
45-
| `"object-removed"` | `{ objectId: string }` |
46-
| `"object-updated"` | `{ objectId: string; patch: Partial<VoxelObjectJSON> }` |
40+
| `action` | `metadata` shape | Notes |
41+
|---|---|---|
42+
| `"added"` | `{ options: VoxelLayerConfigurableOptions }` | |
43+
| `"removed"` | `{}` | |
44+
| `"updated"` | `{ options: Partial<VoxelLayerConfigurableOptions> }` | |
45+
| `"offset-updated"` | `{ offset: VoxelCoord }` or `{ delta: VoxelCoord }` | |
46+
| `"voxel-set"` | `{ position, blockId, rotation, flipX, flipZ, flipY }` | |
47+
| `"voxel-removed"` | `{ position: Vector3Like }` | |
48+
| `"voxels-set"` | `{ entries: VoxelSetOptions[] }` | Bulk placement |
49+
| `"voxels-removed"` | `{ entries: VoxelRemoveOptions[] }` | Bulk removal |
50+
| `"reordered"` | `{ direction: "up" \| "down" }` | |
51+
| `"object-layer-added"` | `{}` | |
52+
| `"object-layer-removed"` | `{}` | |
53+
| `"object-layer-updated"` | `{ patch: { visible?: boolean } }` | |
54+
| `"object-added"` | `{ object: VoxelObjectJSON }` | Full object, not just ID |
55+
| `"object-removed"` | `{ objectId: string }` | |
56+
| `"object-updated"` | `{ objectId: string; patch: Partial<VoxelObjectJSON> }` | |
4757

4858
`VoxelLayerHookAction` is a convenience alias for `VoxelLayerHookEvent["action"]`.
59+
60+
## Breaking change: `"object-added"` metadata
61+
62+
Prior to the network sync layer, the `"object-added"` event carried `{ objectId: string }`.
63+
It now carries `{ object: VoxelObjectJSON }` so remote commands can fully reconstruct the
64+
object without an extra lookup. Update existing consumers:
65+
66+
```ts
67+
// Before
68+
if (event.action === "object-added") {
69+
console.log(event.metadata.objectId);
70+
}
71+
72+
// After
73+
if (event.action === "object-added") {
74+
console.log(event.metadata.object.id); // same value, richer payload
75+
}
76+
```
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
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

Comments
 (0)