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.
import { useMultiplayer, useNetworkEvents } from "@carverjs/multiplayer";Decision Guide #
Not sure which sync mode to use? Start here.
| Game Type | Recommended Mode | Why |
|---|---|---|
| Turn-based (chess, cards) | Events | Low frequency, discrete actions — no continuous state to sync |
| Chat / social | Events | Messages are one-off payloads, not continuous state |
| Casual / RPG | Snapshots | Moderate update rate, host-authoritative, simple to set up |
| Platformer / adventure | Snapshots | Smooth interpolation is enough at typical movement speeds |
| FPS / racing | Prediction | High update rate, client-side prediction hides latency |
| Competitive / esports | Prediction | Rollback 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 #
Turn-based games where players take actions one at a time
Chat systems and notification broadcasts
Any action that happens occasionally rather than every frame
API #
import { useNetworkEvents } from "@carverjs/multiplayer";
const { sendEvent, broadcast, onEvent } = useNetworkEvents();Return Value #
| Property | Type | Description |
|---|---|---|
sendEvent | (type: string, payload: unknown, target?: string) => void | Send an event to a specific peer (by peerId) or the host if no target is given |
broadcast | (type: string, payload: unknown) => void | Send an event to all connected peers |
onEvent | (type: string, callback: (payload: unknown, senderId: string) => void) => void | Register a listener for a specific event type |
Host Validation #
For authoritative games, route events through the host for validation before broadcasting:
// 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 #
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 #
Casual multiplayer, RPGs, adventure games
Games with moderate update frequency
When simplicity matters more than minimal latency
How It Works #
Host samples Actor positions, rotations, and custom state at the
broadcastRateHost broadcasts a snapshot (compressed state bundle) to all clients
Clients receive snapshots and interpolate between the two most recent ones
Rendering always trails the latest snapshot by one tick interval, producing smooth motion
Interpolation Modes #
| Mode | Description | Best For |
|---|---|---|
| Linear | Straight-line interpolation between two snapshots | Simple movement, UI elements |
| Hermite | Curve-fitting interpolation using velocity data | Characters, 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:
useMultiplayer({
mode: "snapshot",
broadcastRate: 20,
interpolation: {
method: "hermite",
extrapolateMs: 200, // max ms to extrapolate before freezing
},
});Options #
| Option | Type | Default | Description |
|---|---|---|---|
mode | 'snapshot' | — | Enable snapshot sync |
tickRate | number | 60 | Simulation tick rate (Hz) |
broadcastRate | number | 20 | Snapshots per second sent by the host |
keyframeInterval | number | 60 | Ticks between full (non-delta) snapshots |
interpolation.method | 'linear' | 'hermite' | 'hermite' | Interpolation algorithm |
interpolation.bufferSize | number | 120 | Number of snapshots to buffer |
interpolation.extrapolateMs | number | 250 | Maximum extrapolation time in ms (0 to disable) |
Example: Casual Multiplayer Game #
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 #
FPS, racing, fighting, and other competitive games
Any game where input latency is noticeable and frustrating
Scenarios with variable or high network latency
How It Works #
Client applies input locally and renders the predicted result immediately
Client sends the input (with a tick number) to the host
Host processes the input authoritatively and includes the result in the next snapshot
Client receives the snapshot and compares it to the predicted state
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:
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:
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 #
| Option | Type | Default | Description |
|---|---|---|---|
mode | 'prediction' | — | Enable prediction sync |
tickRate | number | 60 | Simulation ticks per second |
onPhysicsStep | (inputs: Map<string, unknown>, tick: number, isRollback: boolean) => void | — | Deterministic step function run on client and host |
prediction.maxRewindTicks | number | 15 | Maximum ticks of unacknowledged inputs to buffer |
prediction.errorSmoothingDecay | number | 0.85 | Exponential decay factor for visual correction (0-1) |
prediction.maxErrorPerFrame | number | 5 | Position error threshold to trigger rollback |
prediction.snapThreshold | number | 15 | Error distance that triggers hard snap instead of smooth correction |
prediction.lagCompensation | boolean | false | Enable host-side lag compensation for hit validation |
Example: Competitive Game with Prediction #
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 #
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 #
| Property | Type | Description |
|---|---|---|
isActive | boolean | true when the sync engine is running and connected |
networkQuality | 'good' | 'degraded' | 'poor' | Estimated network quality based on latency and packet loss |
tick | number | Current local tick number |
serverTick | number | Latest tick acknowledged by the host |
drift | number | Ticks ahead of server (positive = ahead, negative = behind) |
syncEngine | SyncMode | The active sync mode: 'events', 'snapshot', or 'prediction' |
useNetworkEvents Return Value #
| Property | Type | Description |
|---|---|---|
sendEvent | (type: string, payload: unknown, target?: string) => void | Send to a specific peer or the host |
broadcast | (type: string, payload: unknown) => void | Send to all peers |
onEvent | (type: string, callback: (payload: unknown, senderId: string) => void) => void | Listen for a specific event type |
Performance Tips #
Hermite interpolation: Almost always better than linear. The CPU cost is negligible compared to the visual improvement.
Prediction mode: Only use when your game genuinely needs it. The added complexity of deterministic step functions and rollback logic is not worth it for casual games.
Combine wisely: Use events for things that happen occasionally (chat, abilities, turns). Use snapshots or prediction for things that change every frame (positions, rotations, velocities). Sending per-frame data as events will flood the network.