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("room-123", {
transport,
displayName: "Player1",
});Your custom transport must handle peer discovery, message routing, and host election. CarverJS calls createChannel for its internal sync channels — make sure your implementation supports multiple named channels.
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;
}Always use requestSpawn from clients instead of calling spawn directly. This lets the host validate the request (e.g., check cooldowns, ammo count) before creating the entity.
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 |
Interest management only affects what the host sends. The host still simulates all entities. For very large worlds (10,000+ entities), also consider reducing broadcastRate and increasing keyframeInterval.
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 |
Start with the defaults. Profile with the debug overlay enabled (debug: { overlay: true }) and tune one setting at a time. Over-optimizing without measurement often makes things worse.
Prediction Rollback Internals #
Prediction mode (mode: "prediction") is full-world prediction with full-world rollback: every peer simulates every networked entity each fixed tick, and clients correct against the host's authoritative snapshots by rolling back and resimulating the entire world. This section covers what happens under the hood and what your game must guarantee.
What Happens on Each Snapshot (Clients Only) #
Accept — only the newest pending snapshot is applied per frame; snapshots at or below the last applied server tick are ignored. The host's embedded input (
hi) is recorded into the host peer's input history at the snapshot tick.Reset — ALL networked entities — including the local player — are hard-reset to the server state: position, rotation, linear and angular velocity.
Resimulate — the simulation replays from
serverTick + 1up to the current local tick, invokingonPhysicsStepwithisRollback = truefor each tick (replaying every peer's exact buffered per-tick inputs) and stepping the physics world.Smooth — the difference between each entity's pre-rollback visual pose and its post-resimulation pose becomes a per-entity error offset, applied at render time and decayed multiplicatively each frame (
errorDecay, default0.85). Rigid bodies are never offset — corrections are purely visual.Snap — if the local tick drifted more than
maxRewindTicks(15) fromserverTick + driftTargetTicks(4), the client hard-snaps its tick to the target instead of resimulating. Per-axis position jumps larger thansnapThreshold(150 units) suppress visual correction for that entity entirely (intentional teleports).
The host never rolls back — its simulation is the authority.
Determinism Checklist #
Rollback resimulation must reproduce what the forward simulation produced for the same inputs. Verify:
Fixed timestep only — use the
dtpassed toonPhysicsStep, never the render delta.No
Math.random()in the step — use a seeded RNG keyed by tick if you need randomness.No
Date.now(),performance.now(), or frame-rate dependent logic in the step.The game drives physics stepping itself — pass
stepWorldtouseMultiplayeror step insideonPhysicsStep. Physics that auto-steps outside the network tick loop cannot be resimulated.Non-simulation side effects (sounds, particles, analytics) are gated on
!isRollbackor idempotent per tick — rollback replays ticks that already ran.
Residual nondeterminism is corrected by the next rollback but shows up as visual corrections.
Input History and justPressed #
Each peer keeps a tick-indexed ring buffer of inputs (inputHistorySize, default 120 ticks — about 2 seconds at 60 Hz) for itself and for every remote peer (the InputBuffer). Forward simulation extrapolates remote players with their last-known input between packets; rollback resimulation replays the exact per-tick history. computeJustPressed is stateless rising-edge detection between consecutive ticks, so edges replay identically during resimulation: a jump that originally fired at tick K fires again at exactly tick K with isRollback = true, and at no other tick.
CPU and Player-Count Cost #
Rollback cost scales with player count times rewind depth — each accepted snapshot resimulates the entire world for every tick between the snapshot tick and the local tick. Input broadcast is O(n) per peer (O(n squared) total) at roughly 60 packets per second per peer. Prediction mode is recommended for 2-4 players and acceptable to about 8 for light simulations; larger rooms should use snapshot mode.
The debug overlay's prediction statistics (tick, server tick, drift) reflect this full-world model: drift is measured against the host's snapshot tick, and corrections you observe correspond to rollback error offsets, not per-entity mismatch events.
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("room-123", {
privacy: "relay",
iceServers: [
{ urls: "turn:your-turn-server.com:3478", username: "user", credential: "pass" },
],
});Only enable relay mode when IP privacy is a hard requirement (e.g., public lobbies where players don't trust each other).
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("room-123", {
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.
Test host migration by opening your game in multiple tabs and closing the host tab. The game should continue within ~500ms.
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} />;
}Reducing broadcastRate on poor connections prevents packet queue buildup that causes cascading lag spikes -- especially valuable for mobile games.
Type Definitions #
See Types for all type definitions including CarverTransport, CarverChannel, ChannelOptions, NetworkQuality, UseNetworkStateReturn, and InterestManagementOptions.