Advanced Topics
Deep dives into custom transports, dynamic spawning, interest management, performance tuning, and other advanced multiplayer patterns.
import { useMultiplayer, useRoom, useNetworkState } from "@carverjs/multiplayer";Custom Transport #
The built-in WebRTC and WebSocket transports cover most use cases, but you can plug in your own backend by implementing the CarverTransport interface.
Interface #
interface CarverTransport {
readonly peerId: string;
readonly peers: ReadonlySet<string>;
readonly hostId: string;
readonly isHost: boolean;
onPeerJoin(cb: (peerId: string) => void): void;
onPeerLeave(cb: (peerId: string) => void): void;
onHostChanged(cb: (newHostId: string) => void): void;
createChannel(name: string, options?: ChannelOptions): CarverChannel;
connect(roomId: string, config?: ConnectionConfig): Promise<void>;
disconnect(): void;
}
interface CarverChannel {
send(data: ArrayBuffer): void;
onMessage(cb: (data: ArrayBuffer, fromPeerId: string) => void): void;
close(): void;
}Example: Custom WebSocket Transport #
class MyWebSocketTransport implements CarverTransport {
readonly peerId = crypto.randomUUID();
private _peers = new Set<string>();
private _hostId = "";
private ws: WebSocket | null = null;
private joinCbs: ((id: string) => void)[] = [];
private leaveCbs: ((id: string) => void)[] = [];
private hostCbs: ((id: string) => void)[] = [];
get peers(): ReadonlySet<string> { return this._peers; }
get hostId(): string { return this._hostId; }
get isHost(): boolean { return this.peerId === this._hostId; }
onPeerJoin(cb: (id: string) => void) { this.joinCbs.push(cb); }
onPeerLeave(cb: (id: string) => void) { this.leaveCbs.push(cb); }
onHostChanged(cb: (id: string) => void) { this.hostCbs.push(cb); }
createChannel(name: string): CarverChannel {
return {
send: (data) => this.ws?.send(this.encode(name, data)),
onMessage: (cb) => { /* register listener */ },
close: () => { /* cleanup */ },
};
}
async connect(roomId: string) {
this.ws = new WebSocket(`wss://your-server.com/rooms/${roomId}`);
// Handle open, message, close events
}
disconnect() { this.ws?.close(); }
private encode(channel: string, data: ArrayBuffer): ArrayBuffer { return data; }
}Use it with useRoom:
const transport = useMemo(() => new MyWebSocketTransport(), []);
const room = useRoom({
transport,
displayName: "Player1",
});Spawn & Despawn (useNetworkState) #
useNetworkState provides host-authoritative dynamic entity management. Use it for objects that are created and destroyed at runtime — projectiles, loot drops, NPCs, etc.
API #
| Method | Caller | Description |
|---|---|---|
spawn(id, state) | Host only | Create a new networked entity with initial state |
despawn(id) | Host only | Remove a networked entity |
requestSpawn(id, config) | Any peer | Request the host to spawn an entity. Host validates and calls spawn |
getState(id) | Any peer | Read the current state of an entity |
setState(id, partial) | Owner only | Update fields on an entity (merges with existing state) |
Example: Dynamic Projectile Spawning #
function ShootingSystem() {
const { spawn, despawn, requestSpawn } = useNetworkState();
const { isHost, peerId } = useRoom();
const { isActionJustPressed } = useInput({ actions: { fire: ["MouseLeft"] } });
useGameLoop(() => {
if (!isActionJustPressed("fire")) return;
const bulletId = `bullet-${peerId}-${Date.now()}`;
const config = { type: "projectile", position: [0, 1, 0], velocity: [0, 0, -20] };
if (isHost) {
spawn(bulletId, { ...config, owner: peerId });
setTimeout(() => despawn(bulletId), 3000);
} else {
requestSpawn(bulletId, config); // Host validates before spawning
}
});
return null;
}Interest Management #
In large worlds with hundreds of entities, sending every entity to every player wastes bandwidth. Interest management uses a spatial hash to filter entities by proximity — each client only receives entities within its view distance.
Configuration #
const multiplayer = useMultiplayer({
interestManagement: {
enabled: true,
cellSize: 50, // spatial hash cell size (world units)
viewDistance: 200, // max sync distance per client
hysteresis: 20, // buffer zone at the boundary
},
});How It Works #
The world is divided into a grid of cells (
cellSizexcellSize).Each entity is assigned to the cell containing its position.
For each client, the host only sends entities in cells within
viewDistanceof that client's position.The
hysteresisbuffer prevents entities from flickering in and out when a player moves along a cell boundary.
Performance Implications #
| Scenario | Without Interest Mgmt | With Interest Mgmt |
|---|---|---|
| 500 entities, 8 players | ~4000 entity updates/broadcast | ~200–400 per player |
| 1000 entities, 16 players | ~16000 updates/broadcast | ~300–600 per player |
Performance Tuning #
Every multiplayer game has unique bandwidth and latency requirements. Here's how each setting affects performance.
broadcastRate #
Controls how many state updates per second the host sends.
| Value | Bandwidth | Smoothness | Best For |
|---|---|---|---|
10 | Low | Acceptable with interpolation | Turn-based, slow-paced games |
20 | Medium | Good | Most action games (default) |
30–60 | High | Very smooth | Competitive shooters, racing |
const multiplayer = useMultiplayer({ broadcastRate: 30 });tickRate #
The simulation rate affects physics precision. Separate from broadcastRate.
30 Hz — adequate for platformers and casual games
60 Hz — good default for most games
120 Hz — competitive games requiring precise physics
quantize #
Reducing floating-point precision is the lowest-effort bandwidth optimization.
quantize: {
position: 0.01, // saves ~40% on position data
rotation: 0.001, // saves ~50% on rotation data
velocity: 0.05, // saves ~30% on velocity data
}deltaThresholds #
Skip fields that haven't changed meaningfully. A stationary entity sends almost no data between keyframes.
deltaThresholds: {
position: 0.001, // ignore sub-millimetre jitter
rotation: 0.001, // ignore sub-degree wobble
velocity: 0.01, // ignore negligible velocity changes
}keyframeInterval #
Full snapshots are reliable but expensive. Deltas are cheap but can accumulate errors.
| Value | Trade-off |
|---|---|
30 (0.5s at 60Hz) | Very reliable, higher bandwidth |
60 (1s at 60Hz) | Good balance (default) |
120 (2s at 60Hz) | Lower bandwidth, slower error recovery |
IP Privacy (TURN-Only Mode) #
By default, WebRTC exposes player IP addresses via STUN. Setting privacy: 'relay' forces all traffic through TURN servers, hiding IPs from other peers at the cost of ~10-30ms added latency.
const room = useRoom({
privacy: "relay",
iceServers: [
{ urls: "turn:your-turn-server.com:3478", username: "user", credential: "pass" },
],
});Host Migration #
When the current host disconnects, CarverJS automatically elects a new host so the game continues without interruption.
How It Works #
Detection — peers detect host disconnection via heartbeat timeout.
Election — the peer with the lowest sorted peer ID is elected as the new host. This deterministic rule ensures all peers agree without coordination.
Grace period — a 500ms grace period allows the old host to reconnect before migration proceeds.
State transfer — the new host already has the latest state from prior snapshots. It immediately begins broadcasting.
Configuration #
const room = useRoom({
hostMigration: true, // enabled by default
onHostMigration: (newHostId) => {
console.log(`New host: ${newHostId}`);
if (newHostId === room.peerId) {
console.log("I am now the host!");
// Perform any host-specific initialization
}
},
});State is preserved because all peers maintain the latest keyframe locally. The new host resumes from the last acknowledged tick, and clients re-interpolate from their buffers for a seamless visual transition.
Network Quality #
CarverJS continuously monitors connection quality and exposes a three-tier signal for adaptive gameplay.
Quality Levels #
| Level | RTT | Packet Loss | Typical Scenario |
|---|---|---|---|
good | < 100ms | < 2% | Wired or strong Wi-Fi |
degraded | 100–250ms | 2–10% | Mobile data, congested Wi-Fi |
poor | > 250ms | > 10% | Weak signal, international relay |
Reading Network Quality #
function NetworkIndicator() {
const { networkQuality, rtt, packetLoss } = useRoom();
const color = {
good: "green",
degraded: "orange",
poor: "red",
}[networkQuality];
return (
<div style={{ color }}>
{networkQuality.toUpperCase()} — {rtt}ms RTT, {(packetLoss * 100).toFixed(1)}% loss
</div>
);
}Adaptive Quality Patterns #
Adjust settings dynamically based on network conditions:
function AdaptiveMultiplayer() {
const { networkQuality } = useRoom();
const config = useMemo(() => {
const presets = {
good: { broadcastRate: 30, interpolation: { delay: 80 } },
degraded: { broadcastRate: 15, interpolation: { delay: 150 } },
poor: { broadcastRate: 10, interpolation: { delay: 250 } },
};
return presets[networkQuality];
}, [networkQuality]);
const multiplayer = useMultiplayer(config);
return <GameWorld multiplayer={multiplayer} />;
}Type Definitions #
See Types for all type definitions including CarverTransport, CarverChannel, ChannelOptions, NetworkQuality, UseNetworkStateReturn, and InterestManagementOptions.