Sync Modes

CarverJS multiplayer provides three sync layers that can be used independently or combined. Each layer targets a different update frequency and game type — pick the one that matches your needs, or stack them for complex scenarios.

tsx
import { useMultiplayer, useNetworkEvents } from "@carverjs/multiplayer";

Decision Guide #

Not sure which sync mode to use? Start here.

Game TypeRecommended ModeWhy
Turn-based (chess, cards)EventsLow frequency, discrete actions — no continuous state to sync
Chat / socialEventsMessages are one-off payloads, not continuous state
Casual / RPGSnapshotsModerate update rate, host-authoritative, simple to set up
Platformer / adventureSnapshotsSmooth interpolation is enough at typical movement speeds
FPS / racingPredictionFull-world prediction hides input latency; every body collides on the same timeline on every peer
Competitive / esportsPredictionFull-world rollback replays exact per-tick inputs, keeping all peers converged on the host's authoritative timeline under bad network conditions

Layer 1: Events (useNetworkEvents) #

Events are fire-and-forget messages sent between peers. They are ideal for infrequent, discrete actions like chat messages, turn submissions, emotes, or game-over signals.

When to Use #

API #

tsx
import { useNetworkEvents } from "@carverjs/multiplayer";

const { sendEvent, broadcast, onEvent } = useNetworkEvents();

Return Value #

PropertyTypeDescription
sendEvent(type: string, payload: unknown, target?: string) => voidSend an event to a specific peer (by peerId) or the host if no target is given
broadcast(type: string, payload: unknown) => voidSend an event to all connected peers
onEvent(type: string, callback: (payload: unknown, senderId: string) => void) => voidRegister a listener for a specific event type

Host Validation #

For authoritative games, route events through the host for validation before broadcasting:

tsx
// On every client — send moves to the host
const { sendEvent } = useNetworkEvents();
sendEvent("move", { x: 3, y: 5 });

// On the host — validate and rebroadcast
const { onEvent, broadcast } = useNetworkEvents();
onEvent("move", (payload, senderId) => {
  if (isValidMove(payload)) {
    broadcast("move:confirmed", { ...payload, playerId: senderId });
  } else {
    sendEvent("move:rejected", { reason: "invalid" }, senderId);
  }
});

Example: Chat + Turn-Based Game #

tsx
import { useState, useCallback } from "react";
import { useNetworkEvents } from "@carverjs/multiplayer";
import { usePlayers } from "@carverjs/multiplayer";

function TurnBasedGame() {
  const { sendEvent, broadcast, onEvent } = useNetworkEvents();
  const { self, players } = usePlayers();
  const [messages, setMessages] = useState<{ from: string; text: string }[]>([]);
  const [currentTurn, setCurrentTurn] = useState<string | null>(null);

  // Chat
  const sendChat = useCallback((text: string) => {
    broadcast("chat", { text, from: self?.displayName });
  }, [broadcast, self]);

  onEvent("chat", (payload) => {
    const { text, from } = payload as { text: string; from: string };
    setMessages((prev) => [...prev, { from, text }]);
  });

  // Turns
  const submitTurn = useCallback((action: unknown) => {
    sendEvent("turn:submit", { action, playerId: self?.peerId });
  }, [sendEvent, self]);

  onEvent("turn:result", (payload) => {
    const { nextPlayer } = payload as { nextPlayer: string };
    setCurrentTurn(nextPlayer);
  });

  return (
    <div>
      <div>
        {messages.map((m, i) => (
          <p key={i}><strong>{m.from}:</strong> {m.text}</p>
        ))}
      </div>
      <p>Current turn: {currentTurn}</p>
      <button
        onClick={() => submitTurn({ type: "roll-dice" })}
        disabled={currentTurn !== self?.peerId}
      >
        Roll Dice
      </button>
    </div>
  );
}

Layer 2: Snapshots (useMultiplayer with mode='snapshot') #

Snapshot sync is a host-authoritative model where the host reads actor state each tick, broadcasts it to all clients, and clients interpolate between received snapshots for smooth rendering.

When to Use #

How It Works #

  1. Host samples Actor positions, rotations, and custom state at the broadcastRate

  2. Host broadcasts a snapshot (compressed state bundle) to all clients

  3. Clients receive snapshots and interpolate between the two most recent ones

  4. Rendering always trails the latest snapshot by one tick interval, producing smooth motion

Interpolation Modes #

ModeDescriptionBest For
LinearStraight-line interpolation between two snapshotsSimple movement, UI elements
HermiteCurve-fitting interpolation using velocity dataCharacters, vehicles, anything with momentum

Hermite interpolation uses the velocity at each snapshot to construct a smooth curve, eliminating the "jagged" look that linear interpolation can produce when entities change direction.

Extrapolation #

When a snapshot arrives late, clients can extrapolate — continue the last known trajectory until the next snapshot arrives. Extrapolation prevents entities from freezing but may cause visible corrections when the real snapshot finally arrives.

Configure extrapolation via the interpolation.extrapolateMs option:

tsx
useMultiplayer({
  mode: "snapshot",
  broadcastRate: 20,
  interpolation: {
    method: "hermite",
    extrapolateMs: 200, // max ms to extrapolate before freezing
  },
});

Options #

OptionTypeDefaultDescription
mode'snapshot'Enable snapshot sync
tickRatenumber60Simulation tick rate (Hz)
broadcastRatenumber20Snapshots per second sent by the host
keyframeIntervalnumber60Ticks between full (non-delta) snapshots
interpolation.method'linear' | 'hermite''hermite'Interpolation algorithm
interpolation.bufferSizenumber120Number of snapshots to buffer
interpolation.extrapolateMsnumber250Maximum extrapolation time in ms (0 to disable)

Example: Casual Multiplayer Game #

tsx
import { useMultiplayer } from "@carverjs/multiplayer";
import { usePlayers } from "@carverjs/multiplayer";
import { Game, World, Actor } from "@carverjs/core/components";
import { useGameLoop, useInput } from "@carverjs/core/hooks";
import { useRef } from "react";
import type { Group } from "@carverjs/core/types";

function MultiplayerGame() {
  const { isActive, networkQuality, tick } = useMultiplayer({
    mode: "snapshot",
    broadcastRate: 20,
    interpolation: { method: "hermite" },
  });

  const { players } = usePlayers();

  return (
    <Game>
      <World>
        {players.map((player) => (
          <PlayerActor key={player.peerId} player={player} isLocal={player.isSelf} />
        ))}
        <Actor
          type="primitive"
          shape="plane"
          color="#4a7c59"
          size={50}
          rotation={[-Math.PI / 2, 0, 0]}
          receiveShadow
        />
      </World>
    </Game>
  );
}

function PlayerActor({ player, isLocal }: { player: { peerId: string; isSelf: boolean }; isLocal: boolean }) {
  const ref = useRef<Group>(null);
  const { isPressed } = useInput();

  useGameLoop((delta) => {
    if (!isLocal || !ref.current) return;
    const speed = 5;
    if (isPressed("KeyW")) ref.current.position.z -= speed * delta;
    if (isPressed("KeyS")) ref.current.position.z += speed * delta;
    if (isPressed("KeyA")) ref.current.position.x -= speed * delta;
    if (isPressed("KeyD")) ref.current.position.x += speed * delta;
  });

  return (
    <Actor
      ref={ref}
      type="primitive"
      shape="box"
      color={isLocal ? "blue" : "red"}
      position={[0, 0.5, 0]}
      networked={true}
      castShadow
    />
  );
}

Layer 3: Prediction (useMultiplayer with mode='prediction') #

Prediction mode is full-world prediction with full-world rollback — a GGPO-style model running over the same host-authoritative star topology as snapshot mode. It is not single-entity reconciliation: every networked entity — the local player, remote players, and physics props — is simulated dynamically on every peer, every fixed tick, so all bodies collide on the same timeline.

When to Use #

How It Works #

  1. Inputs are broadcast all-to-all. Every peer (host included) sends a tick-stamped input packet to every other peer each fixed tick on the reliable, ordered carver:inputs channel.

  2. Every peer simulates the full world. Remote players are simulated with each peer's last-known input between packets (hold-last-input extrapolation), and with their exact per-tick inputs during rollback.

  3. The host remains authoritative. It broadcasts delta-compressed, ACK-driven snapshots on the same pipeline and cadence as snapshot mode (broadcastRate 20 Hz, keyframes every keyframeInterval ticks), with its own input embedded (the hi field) so clients can resimulate the host player accurately.

  4. Clients roll back the whole world. On every accepted snapshot, a client resets ALL networked entities to the server state, resimulates from serverTick + 1 up to its local tick replaying the buffered per-tick inputs of every peer, and converts the resulting visual discontinuity into per-entity error offsets that decay multiplicatively each render frame (errorDecay, default 0.85). Physics state is never offset — corrections are applied to rendering only.

  5. Drift and teleports are handled separately. If the local tick drifts more than maxRewindTicks (15) from serverTick + driftTargetTicks (4), the client hard-snaps its tick instead of resimulating. Per-axis position jumps larger than snapThreshold (150 units) suppress visual correction entirely, so intentional teleports stay instant.

setInput and the onPhysicsStep Callback #

In prediction mode you feed the local player's input to the engine with setInput and implement the simulation in onPhysicsStep. The same callback runs on host and clients, for forward ticks and rollback resimulation:

tsx
const { setInput } = useMultiplayer({
  mode: "prediction",
  tickRate: 60,
  stepWorld: () => world.step(),   // the game drives physics stepping
  onPhysicsStep: (inputs, justPressed, tick, isRollback, dt) => {
    // inputs:      Map<peerId, PlayerInput> — every player's input for this tick
    //              (the local player is keyed by your own peerId)
    // justPressed: Map<peerId, PlayerInput> — rising edges: booleans are true only
    //              on the tick they transition from false to true
    // tick:        the fixed-step tick number
    // isRollback:  true during rollback resimulation of a past tick
    // dt:          the fixed tick delta (1 / tickRate)

    for (const [peerId, input] of inputs) {
      const body = getBodyForPlayer(peerId);
      if (!body) continue;

      // Held inputs: continuous forces
      if (input.left) body.applyForce({ x: -MOVE_FORCE * dt, y: 0, z: 0 });
      if (input.right) body.applyForce({ x: MOVE_FORCE * dt, y: 0, z: 0 });

      // Edge-triggered inputs: use justPressed, not inputs
      const pressed = justPressed.get(peerId);
      if (pressed?.jump) {
        body.applyImpulse({ x: 0, y: JUMP_IMPULSE, z: 0 });
        // Non-simulation side effects MUST be gated on !isRollback (or be
        // idempotent per tick): rollback replays this exact tick again.
        if (!isRollback) playJumpSound();
      }
    }
  },
});

// In your input-gathering code (e.g. a game loop callback).
// The input persists across ticks until replaced — hold semantics.
setInput({ left, right, jump });

PlayerInput is a flat Record<string, boolean | number | undefined>. Booleans get edge detection via justPressed; numbers (axes, aim angles) pass through unchanged.

Determinism Requirement #

Rollback resimulation replays past ticks and expects to reproduce the same result the forward simulation produced. Your onPhysicsStep plus the physics step must therefore be deterministic for a given input sequence:

Residual nondeterminism does not desync the game — the host snapshot corrects it on the next rollback — but it shows up as visible visual corrections.

Error Smoothing #

After a rollback, snapping every entity to its corrected pose looks jarring. CarverJS converts the discontinuity into per-entity error offsets and decays them each render frame:

tsx
useMultiplayer({
  mode: "prediction",
  tickRate: 60,
  prediction: {
    errorDecay: 0.85, // retain 85% of the remaining error offset per frame
  },
});

A higher value (e.g. 0.9) smooths more aggressively but takes longer to converge. A lower value (e.g. 0.5) converges faster but corrections may be visible. The offsets adjust rendering only — rigid bodies are never touched by error smoothing.

Player-Count Guidance #

Input broadcast is O(n) per peer — O(n squared) total — at roughly 60 packets per second per peer, and every peer simulates the full world; rollback CPU cost additionally scales with player count times rewind depth. Prediction mode is recommended for 2-4 players and acceptable up to about 8 for light simulations. Larger rooms should use snapshot mode.

Options #

OptionTypeDefaultDescription
mode'prediction'Enable prediction sync
tickRatenumber60Simulation ticks per second
stepWorld() => voidSteps the physics world one fixed tick. Used for both forward simulation and rollback resimulation
onPhysicsStepPhysicsStepCallback(inputs, justPressed, tick, isRollback, dt) — deterministic simulation callback run on every peer
prediction.maxRewindTicksnumber15Max drift (ticks) between the local tick and serverTick + driftTargetTicks before a hard tick snap replaces resimulation
prediction.snapThresholdnumber150Per-axis position jump (world units) above which rollback visual correction is suppressed (intentional teleports)
prediction.errorDecaynumber0.85Multiplicative decay applied to per-entity error offsets each render frame
prediction.maxErrorPerFramenumber0Max positional correction (units) applied per render frame. 0 disables the cap
prediction.neutralInputPlayerInput{}Fallback input payload for unknown ticks or peers
prediction.inputHistorySizenumber120Tick-history ring size for local and per-peer inputs (about 2 s at 60 Hz)
prediction.driftTargetTicksnumber4Tick snap target offset: snap target = serverTick + driftTargetTicks

Example: Competitive Game with Prediction #

tsx
import { useMultiplayer } from "@carverjs/multiplayer";
import { usePlayers } from "@carverjs/multiplayer";
import { Game, World, Actor } from "@carverjs/core/components";
import { useInput, useGameLoop } from "@carverjs/core/hooks";
import { useRef } from "react";
import type { Group } from "@carverjs/core/types";

function CompetitiveGame() {
  const { isActive, tick, serverTick, drift, setInput } = useMultiplayer({
    mode: "prediction",
    tickRate: 60,
    prediction: {
      maxRewindTicks: 15,
      errorDecay: 0.85,
      snapThreshold: 150,
    },
    stepWorld: () => stepPhysicsWorld(),
    onPhysicsStep: (inputs, justPressed, tick, isRollback, dt) => {
      // Apply every player's input to the physics simulation
      for (const [peerId, input] of inputs) {
        applyMovementForces(peerId, input, dt);
      }
      for (const [peerId, pressed] of justPressed) {
        if (pressed.jump) applyJumpImpulse(peerId, isRollback);
      }
    },
  });

  const { players } = usePlayers();
  const { isPressed } = useInput();

  useGameLoop(() => {
    // Gather local input and hand it to the prediction engine.
    setInput({
      left: isPressed("KeyA"),
      right: isPressed("KeyD"),
      jump: isPressed("Space"),
    });
  });

  return (
    <Game>
      <World>
        {players.map((player) => (
          <CompetitivePlayer key={player.peerId} player={player} />
        ))}
        <Actor
          type="primitive"
          shape="plane"
          color="#2a2a3e"
          size={100}
          rotation={[-Math.PI / 2, 0, 0]}
          receiveShadow
        />
      </World>
    </Game>
  );
}

function CompetitivePlayer({ player }: { player: { peerId: string; isSelf: boolean } }) {
  const ref = useRef<Group>(null);

  // All players — local and remote — are real dynamic bodies driven by
  // onPhysicsStep on every peer. Rendering is adjusted by decaying error
  // offsets after each rollback; rigid bodies are never visually offset.
  return (
    <Actor
      ref={ref}
      type="primitive"
      shape="sphere"
      color={player.isSelf ? "blue" : "red"}
      position={[0, 0.5, 0]}
      networked={true}
      physics={{ bodyType: "dynamic" }}
      castShadow
    />
  );
}

Combining Layers #

Sync layers are designed to stack. A common pattern is Snapshots + Events: use snapshots for continuous state (positions, health, scores) and events for discrete actions (chat messages, item pickups, ability triggers).

Example: Snapshot Sync + Event Chat #

tsx
import { useMultiplayer, useNetworkEvents } from "@carverjs/multiplayer";
import { usePlayers } from "@carverjs/multiplayer";
import { Game, World, Actor } from "@carverjs/core/components";
import { useState, useCallback } from "react";

function CombinedGame() {
  // Layer 2 — continuous state sync
  const { isActive, networkQuality, tick } = useMultiplayer({
    mode: "snapshot",
    broadcastRate: 20,
    interpolation: { method: "hermite" },
  });

  // Layer 1 — discrete events
  const { broadcast, onEvent } = useNetworkEvents();
  const { players, self } = usePlayers();

  // Chat state
  const [messages, setMessages] = useState<{ from: string; text: string }[]>([]);

  const sendChat = useCallback((text: string) => {
    broadcast("chat", { text, from: self?.displayName });
  }, [broadcast, self]);

  onEvent("chat", (payload) => {
    const { text, from } = payload as { text: string; from: string };
    setMessages((prev) => [...prev, { from, text }]);
  });

  // Item pickup event
  onEvent("item:pickup", (payload) => {
    const { itemId, playerId } = payload as { itemId: string; playerId: string };
    console.log(`${playerId} picked up ${itemId}`);
  });

  return (
    <div>
      <Game>
        <World>
          {players.map((player, i) => (
            <Actor
              key={player.peerId}
              type="primitive"
              shape="box"
              color={player.isSelf ? "blue" : "red"}
              position={[i * 2, 0.5, 0]}
              networked={true}
              castShadow
            />
          ))}
          <Actor
            type="primitive"
            shape="plane"
            color="#eee"
            size={30}
            rotation={[-Math.PI / 2, 0, 0]}
            receiveShadow
          />
        </World>
      </Game>
      <ChatOverlay messages={messages} onSend={sendChat} />
    </div>
  );
}

function ChatOverlay({
  messages,
  onSend,
}: {
  messages: { from: string; text: string }[];
  onSend: (text: string) => void;
}) {
  const [input, setInput] = useState("");

  return (
    <div style={{ position: "absolute", bottom: 0, left: 0, padding: 16 }}>
      {messages.slice(-5).map((m, i) => (
        <p key={i}><strong>{m.from}:</strong> {m.text}</p>
      ))}
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        onKeyDown={(e) => {
          if (e.key === "Enter" && input.trim()) {
            onSend(input.trim());
            setInput("");
          }
        }}
        placeholder="Type a message..."
      />
    </div>
  );
}

useMultiplayer Return Value #

PropertyTypeDescription
isActivebooleantrue when the sync engine is running and connected
networkQuality'good' | 'degraded' | 'poor'Estimated network quality based on latency and packet loss
ticknumberCurrent local tick number
serverTicknumberLatest tick acknowledged by the host
driftnumberTicks ahead of server (positive = ahead, negative = behind)
syncEngineSyncModeThe active sync mode: 'events', 'snapshot', or 'prediction'
setInput(input: PlayerInput) => voidSet the local player's input for prediction mode. The input persists across ticks until replaced. No-op in events and snapshot modes

useNetworkEvents Return Value #

PropertyTypeDescription
sendEvent(type: string, payload: unknown, target?: string) => voidSend to a specific peer or the host
broadcast(type: string, payload: unknown) => voidSend to all peers
onEvent(type: string, callback: (payload: unknown, senderId: string) => void) => voidListen for a specific event type

Performance Tips #