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({
  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

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({
  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({
  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.