Advanced Topics

Deep dives into custom transports, dynamic spawning, interest management, performance tuning, and other advanced multiplayer patterns.

tsx
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 #

ts
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 #

ts
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:

tsx
const transport = useMemo(() => new MyWebSocketTransport(), []);

const room = useRoom("room-123", {
  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 #

MethodCallerDescription
spawn(id, state)Host onlyCreate a new networked entity with initial state
despawn(id)Host onlyRemove a networked entity
requestSpawn(id, config)Any peerRequest the host to spawn an entity. Host validates and calls spawn
getState(id)Any peerRead the current state of an entity
setState(id, partial)Owner onlyUpdate fields on an entity (merges with existing state)

Example: Dynamic Projectile Spawning #

tsx
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 #

tsx
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 #

  1. The world is divided into a grid of cells (cellSize x cellSize).

  2. Each entity is assigned to the cell containing its position.

  3. For each client, the host only sends entities in cells within viewDistance of that client's position.

  4. The hysteresis buffer prevents entities from flickering in and out when a player moves along a cell boundary.

Performance Implications #

ScenarioWithout Interest MgmtWith 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.

ValueBandwidthSmoothnessBest For
10LowAcceptable with interpolationTurn-based, slow-paced games
20MediumGoodMost action games (default)
30–60HighVery smoothCompetitive shooters, racing
tsx
const multiplayer = useMultiplayer({ broadcastRate: 30 });

tickRate #

The simulation rate affects physics precision. Separate from broadcastRate.

quantize #

Reducing floating-point precision is the lowest-effort bandwidth optimization.

tsx
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.

tsx
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.

ValueTrade-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

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) #

  1. 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.

  2. Reset — ALL networked entities — including the local player — are hard-reset to the server state: position, rotation, linear and angular velocity.

  3. Resimulate — the simulation replays from serverTick + 1 up to the current local tick, invoking onPhysicsStep with isRollback = true for each tick (replaying every peer's exact buffered per-tick inputs) and stepping the physics world.

  4. 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, default 0.85). Rigid bodies are never offset — corrections are purely visual.

  5. Snap — if the local tick drifted more than maxRewindTicks (15) from serverTick + driftTargetTicks (4), the client hard-snaps its tick to the target instead of resimulating. Per-axis position jumps larger than snapThreshold (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:

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.


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.

tsx
const room = useRoom("room-123", {
  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 #

  1. Detection — peers detect host disconnection via heartbeat timeout.

  2. Election — the peer with the lowest sorted peer ID is elected as the new host. This deterministic rule ensures all peers agree without coordination.

  3. Grace period — a 500ms grace period allows the old host to reconnect before migration proceeds.

  4. State transfer — the new host already has the latest state from prior snapshots. It immediately begins broadcasting.

Configuration #

tsx
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.


Network Quality #

CarverJS continuously monitors connection quality and exposes a three-tier signal for adaptive gameplay.

Quality Levels #

LevelRTTPacket LossTypical Scenario
good< 100ms< 2%Wired or strong Wi-Fi
degraded100–250ms2–10%Mobile data, congested Wi-Fi
poor> 250ms> 10%Weak signal, international relay

Reading Network Quality #

tsx
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:

tsx
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.