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 / racingPredictionHigh update rate, client-side prediction hides latency
Competitive / esportsPredictionRollback correction keeps gameplay fair 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 sync adds client-side prediction and server reconciliation on top of the snapshot model. The client immediately applies inputs locally, then corrects if the host disagrees.

When to Use #

How It Works #

  1. Client applies input locally and renders the predicted result immediately

  2. Client sends the input (with a tick number) to the host

  3. Host processes the input authoritatively and includes the result in the next snapshot

  4. Client receives the snapshot and compares it to the predicted state

  5. If they match — no correction needed. If they differ — the client rolls back to the authoritative state and replays all unacknowledged inputs

onPhysicsStep Callback #

For deterministic prediction, provide an onPhysicsStep callback. This function runs on both client (for prediction) and host (for authority), ensuring identical simulation:

tsx
useMultiplayer({
  mode: "prediction",
  tickRate: 60,
  onPhysicsStep: (inputs, tick, isRollback) => {
    // inputs: Map<peerId, inputData> — all player inputs for this tick
    // tick: the fixed-step tick number
    // isRollback: true if this is a re-simulation during reconciliation

    for (const [peerId, input] of inputs) {
      const data = input as { left?: boolean; right?: boolean; jump?: boolean };
      const actor = getActorForPlayer(peerId);
      if (!actor) continue;

      const dt = 1 / 60;
      if (data.left) actor.position.x -= 5 * dt;
      if (data.right) actor.position.x += 5 * dt;
      if (data.jump) actor.position.y += 10 * dt;
    }
  },
});

Error Smoothing #

When a rollback correction occurs, snapping the entity to the corrected position looks jarring. CarverJS applies error smoothing — the visual position blends toward the corrected position over several frames using an exponential decay factor:

tsx
useMultiplayer({
  mode: "prediction",
  tickRate: 60,
  prediction: {
    errorSmoothingDecay: 0.85, // retain 85% of correction 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) snaps faster but may be visible.

Options #

OptionTypeDefaultDescription
mode'prediction'Enable prediction sync
tickRatenumber60Simulation ticks per second
onPhysicsStep(inputs: Map<string, unknown>, tick: number, isRollback: boolean) => voidDeterministic step function run on client and host
prediction.maxRewindTicksnumber15Maximum ticks of unacknowledged inputs to buffer
prediction.errorSmoothingDecaynumber0.85Exponential decay factor for visual correction (0-1)
prediction.maxErrorPerFramenumber5Position error threshold to trigger rollback
prediction.snapThresholdnumber15Error distance that triggers hard snap instead of smooth correction
prediction.lagCompensationbooleanfalseEnable host-side lag compensation for hit validation

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 } = useMultiplayer({
    mode: "prediction",
    tickRate: 60,
    prediction: {
      maxRewindTicks: 15,
      errorSmoothingDecay: 0.85,
      maxErrorPerFrame: 5,
      snapThreshold: 15,
    },
    onPhysicsStep: (inputs, tick, isRollback) => {
      // Apply all player inputs to the physics simulation
      for (const [peerId, input] of inputs) {
        const data = input as { dx: number; dz: number };
        // Apply forces / move actors based on input
      }
    },
  });

  const { players } = usePlayers();

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

  useGameLoop(() => {
    if (!player.isSelf || !ref.current) return;
    // Input is captured automatically by the prediction engine.
    // The onPhysicsStep callback handles movement.
    // Visual position is managed by the sync engine with error smoothing.
  });

  return (
    <Actor
      ref={ref}
      type="primitive"
      shape="sphere"
      color={player.isSelf ? "blue" : "red"}
      position={[0, 0.5, 0]}
      networked={{ sync: "physics", owner: player.peerId }}
      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'

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 #