Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/better-bikes-repair.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@jolly-pixel/voxel.renderer": major
---

Implement new Network API to synchronize world between multiple clients
58 changes: 43 additions & 15 deletions packages/voxel-renderer/docs/Hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,25 +24,53 @@ const vr = new VoxelRenderer({
});
```

You can also set (or replace) the hook after construction:

```ts
vr.onLayerUpdated = (event) => { /* ... */ };
// Clear the hook:
vr.onLayerUpdated = undefined;
```

## Event reference

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

| `action` | `metadata` shape |
|---|---|
| `"added"` | `{ options: VoxelLayerConfigurableOptions }` |
| `"removed"` | `{}` |
| `"updated"` | `{ options: Partial<VoxelLayerConfigurableOptions> }` |
| `"offset-updated"` | `{ offset: VoxelCoord }` or `{ delta: VoxelCoord }` |
| `"voxel-set"` | `{ position, blockId, rotation, flipX, flipZ, flipY }` |
| `"voxel-removed"` | `{ position: Vector3Like }` |
| `"reordered"` | `{ direction: "up" \| "down" }` |
| `"object-layer-added"` | `{}` |
| `"object-layer-removed"` | `{}` |
| `"object-layer-updated"` | `{ patch: { visible?: boolean } }` |
| `"object-added"` | `{ objectId: string }` |
| `"object-removed"` | `{ objectId: string }` |
| `"object-updated"` | `{ objectId: string; patch: Partial<VoxelObjectJSON> }` |
| `action` | `metadata` shape | Notes |
|---|---|---|
| `"added"` | `{ options: VoxelLayerConfigurableOptions }` | |
| `"removed"` | `{}` | |
| `"updated"` | `{ options: Partial<VoxelLayerConfigurableOptions> }` | |
| `"offset-updated"` | `{ offset: VoxelCoord }` or `{ delta: VoxelCoord }` | |
| `"voxel-set"` | `{ position, blockId, rotation, flipX, flipZ, flipY }` | |
| `"voxel-removed"` | `{ position: Vector3Like }` | |
| `"voxels-set"` | `{ entries: VoxelSetOptions[] }` | Bulk placement |
| `"voxels-removed"` | `{ entries: VoxelRemoveOptions[] }` | Bulk removal |
| `"reordered"` | `{ direction: "up" \| "down" }` | |
| `"object-layer-added"` | `{}` | |
| `"object-layer-removed"` | `{}` | |
| `"object-layer-updated"` | `{ patch: { visible?: boolean } }` | |
| `"object-added"` | `{ object: VoxelObjectJSON }` | Full object, not just ID |
| `"object-removed"` | `{ objectId: string }` | |
| `"object-updated"` | `{ objectId: string; patch: Partial<VoxelObjectJSON> }` | |

`VoxelLayerHookAction` is a convenience alias for `VoxelLayerHookEvent["action"]`.

## Breaking change: `"object-added"` metadata

Prior to the network sync layer, the `"object-added"` event carried `{ objectId: string }`.
It now carries `{ object: VoxelObjectJSON }` so remote commands can fully reconstruct the
object without an extra lookup. Update existing consumers:

```ts
// Before
if (event.action === "object-added") {
console.log(event.metadata.objectId);
}

// After
if (event.action === "object-added") {
console.log(event.metadata.object.id); // same value, richer payload
}
```
252 changes: 252 additions & 0 deletions packages/voxel-renderer/docs/Network.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
# Network Sync Layer

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.

## Architecture overview

```
┌─────────────┐ local mutation ┌──────────────────┐ sendCommand ┌─────────────┐
│VoxelRenderer│──────────────────▶│ VoxelSyncClient │────────────────▶│ Transport │
│ (Three.js) │ │ │◀────────────────│ (WebSocket, │
│ │◀──applyRemote──── │ │ onCommand │ WebRTC, …) │
└─────────────┘ └──────────────────┘ └──────┬──────┘
│ wire
┌─────────────────┐
│ VoxelSyncServer │
│ (headless) │
│ VoxelWorld │
└─────────────────┘
```

**Flow:**
1. A local mutation (e.g. `setVoxel`) fires the `onLayerUpdated` hook.
2. `VoxelSyncClient` intercepts the hook, stamps the command with `clientId / seq / timestamp`, and calls `transport.sendCommand(cmd)`.
3. The transport sends the command over the wire to `VoxelSyncServer.receive()`.
4. The server validates the command (LWW conflict resolution), applies it to its authoritative `VoxelWorld`, and broadcasts it to all connected clients.
5. Each client's transport calls `onCommand(cmd)`, which `VoxelSyncClient` routes to `renderer.applyRemoteCommand(cmd)`.
6. `applyRemoteCommand` sets an internal flag so that the resulting hook event is **not** re-emitted — preventing infinite echo loops.

## VoxelTransport interface

You must supply a concrete transport implementation. The interface is minimal:

```ts
interface VoxelTransport {
readonly localClientId: string;
sendCommand(cmd: VoxelNetworkCommand): void;
requestSnapshot(): void;
onCommand: ((cmd: VoxelNetworkCommand) => void) | null;
onSnapshot: ((snapshot: VoxelWorldJSON) => void) | null;
onPeerJoined: ((peerId: string) => void) | null;
onPeerLeft: ((peerId: string) => void) | null;
}
```

### WebSocket example stub

```ts
import type { VoxelTransport, VoxelNetworkCommand } from "@jolly-pixel/voxel.renderer";
import type { VoxelWorldJSON } from "@jolly-pixel/voxel.renderer";

class WebSocketTransport implements VoxelTransport {
readonly localClientId = crypto.randomUUID();
onCommand: ((cmd: VoxelNetworkCommand) => void) | null = null;
onSnapshot: ((snapshot: VoxelWorldJSON) => void) | null = null;
onPeerJoined: ((peerId: string) => void) | null = null;
onPeerLeft: ((peerId: string) => void) | null = null;

constructor(private ws: WebSocket) {
ws.addEventListener("message", (ev) => {
const msg = JSON.parse(ev.data as string);
switch (msg.type) {
case "snapshot": this.onSnapshot?.(msg.data); break;
case "command": this.onCommand?.(msg.data); break;
case "peer-joined": this.onPeerJoined?.(msg.peerId); break;
case "peer-left": this.onPeerLeft?.(msg.peerId); break;
}
});
}

sendCommand(cmd: VoxelNetworkCommand): void {
this.ws.send(JSON.stringify({ type: "command", data: cmd }));
}

requestSnapshot(): void {
this.ws.send(JSON.stringify({ type: "snapshot-request" }));
}
}
```

## VoxelSyncClient

### Setup

```ts
import {
VoxelSyncClient,
type VoxelSyncClientOptions
} from "@jolly-pixel/voxel.renderer";

const client = new VoxelSyncClient({
renderer: vr, // pre-constructed VoxelRenderer
transport: myTransport
});
```

The client:
- Replaces `renderer.onLayerUpdated` with its own interceptor.
- Wires `transport.onCommand` and `transport.onSnapshot`.

### Lifecycle

```ts
// When the session ends:
client.destroy();
```

`destroy()` clears `renderer.onLayerUpdated` and the transport callbacks so the renderer
reverts to standalone mode.

### Options

| Option | Type | Description |
|--------|------|-------------|
| `renderer` | `VoxelRenderer` | The local renderer to synchronize. |
| `transport` | `VoxelTransport` | Your transport implementation. |

## VoxelSyncServer

The server runs **headlessly** (no Three.js dependency) and can be used in Node.js, Deno, or Bun.

### Setup

```ts
import { VoxelSyncServer, type ClientHandle } from "@jolly-pixel/voxel.renderer";

const server = new VoxelSyncServer();
// Optionally pass an existing world:
// const server = new VoxelSyncServer({ world: existingVoxelWorld });
```

### WebSocket server example

```ts
import { WebSocketServer } from "ws";

const wss = new WebSocketServer({ port: 3000 });

wss.on("connection", (ws) => {
const client: ClientHandle = {
id: crypto.randomUUID(),
send: (data) => ws.send(JSON.stringify(data))
};

server.connect(client); // sends snapshot to new client

ws.on("message", (raw) => {
const cmd = JSON.parse(raw.toString());
server.receive(cmd); // validate → apply → broadcast
});

ws.on("close", () => server.disconnect(client.id));
});
```

### API

| Method | Description |
|--------|-------------|
| `connect(client)` | Registers client; sends current snapshot; notifies peers. |
| `disconnect(clientId)` | Removes client; notifies remaining peers. |
| `receive(cmd)` | Validates, applies, and broadcasts a command. |
| `snapshot()` | Returns the current world as `VoxelWorldJSON`. |
| `world` | The authoritative `VoxelWorld` instance. |

### Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `world` | `VoxelWorld` | new world | Existing world to use as authoritative state. |
| `chunkSize` | `number` | `16` | Chunk size when creating a new world. |
| `conflictResolver` | `ConflictResolver` | `LastWriteWinsResolver` | Custom conflict strategy. |

## VoxelNetworkCommand — wire format

A `VoxelNetworkCommand` is a `VoxelLayerHookEvent` extended with routing metadata:

```ts
type VoxelNetworkCommand = VoxelLayerHookEvent & {
clientId: string; // originating client ID
seq: number; // monotonically increasing per client
timestamp: number; // Unix ms (Date.now()) at time of mutation
};
```

Commands are plain JSON-serializable objects — no special framing required.

## ConflictResolver

### Default: LastWriteWinsResolver

The default resolver uses **timestamp** to determine which command wins at a given voxel
position. On a tie, the lexicographically greater `clientId` wins (deterministic without
coordination).

```ts
import { LastWriteWinsResolver } from "@jolly-pixel/voxel.renderer";

const server = new VoxelSyncServer({
conflictResolver: new LastWriteWinsResolver() // default, no need to pass explicitly
});
```

### Custom resolver

Implement `ConflictResolver` for custom strategies (e.g. first-write-wins, priority by
role, etc.):

```ts
import type { ConflictResolver, ConflictContext } from "@jolly-pixel/voxel.renderer";

class FirstWriteWinsResolver implements ConflictResolver {
resolve({ existing }: ConflictContext): "accept" | "reject" {
// Accept only if no prior command exists at this position
return existing ? "reject" : "accept";
}
}

const server = new VoxelSyncServer({ conflictResolver: new FirstWriteWinsResolver() });
```

> **Note:** Conflict resolution only applies to per-position voxel operations (`"voxel-set"`,
> `"voxel-removed"`). Structural layer operations (`"added"`, `"removed"`, `"reordered"`, etc.)
> are always accepted.

## VoxelCommandApplier — headless usage

`applyCommandToWorld` lets you replay hook events against a bare `VoxelWorld` without a
renderer. Useful for server-side logic, unit tests, or offline editing tools.

```ts
import { VoxelWorld, applyCommandToWorld } from "@jolly-pixel/voxel.renderer";

const world = new VoxelWorld(16);
applyCommandToWorld(world, {
action: "added",
layerName: "Ground",
metadata: { options: {} }
});
applyCommandToWorld(world, {
action: "voxel-set",
layerName: "Ground",
metadata: {
position: { x: 0, y: 0, z: 0 },
blockId: 1,
rotation: 0,
flipX: false,
flipZ: false,
flipY: false
}
});
```
Loading
Loading