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 | Full-world prediction hides input latency; every body collides on the same timeline on every peer |
| Competitive / esports | Prediction | Full-world rollback replays exact per-tick inputs, keeping all peers converged on the host's authoritative timeline under bad network conditions |
When in doubt, start with Snapshots. It covers most games well and is simpler than Prediction. You can switch later without changing your game logic — only the mode option changes.
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
/>
);
}In snapshot mode, only the host sends state. Clients send their input to the host, and the host incorporates it into the next snapshot. This keeps the game authoritative and prevents cheating.
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 #
FPS, racing, fighting, and other competitive games with small player counts
Any game where input latency is noticeable and frustrating
Physics-heavy games where players and shared objects must interact on one consistent timeline
How It Works #
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:inputschannel.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.
The host remains authoritative. It broadcasts delta-compressed, ACK-driven snapshots on the same pipeline and cadence as snapshot mode (
broadcastRate20 Hz, keyframes everykeyframeIntervalticks), with its own input embedded (thehifield) so clients can resimulate the host player accurately.Clients roll back the whole world. On every accepted snapshot, a client resets ALL networked entities to the server state, resimulates from
serverTick + 1up 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, default0.85). Physics state is never offset — corrections are applied to rendering only.Drift and teleports are handled separately. If the local tick drifts more than
maxRewindTicks(15) fromserverTick + driftTargetTicks(4), the client hard-snaps its tick instead of resimulating. Per-axis position jumps larger thansnapThreshold(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:
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:
Fixed timestep only — use the provided
dt, never the render frame delta.No
Math.random()without a seeded RNG (seed by tick if you need randomness).No
Date.now(),performance.now(), or any time/frame-rate dependence.The game MUST drive physics stepping itself — either pass
stepWorldin theuseMultiplayeroptions or step the world insideonPhysicsStep. Auto-stepping physics outside the network tick loop is incompatible with rollback resimulation.
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:
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 #
| Option | Type | Default | Description |
|---|---|---|---|
mode | 'prediction' | — | Enable prediction sync |
tickRate | number | 60 | Simulation ticks per second |
stepWorld | () => void | — | Steps the physics world one fixed tick. Used for both forward simulation and rollback resimulation |
onPhysicsStep | PhysicsStepCallback | — | (inputs, justPressed, tick, isRollback, dt) — deterministic simulation callback run on every peer |
prediction.maxRewindTicks | number | 15 | Max drift (ticks) between the local tick and serverTick + driftTargetTicks before a hard tick snap replaces resimulation |
prediction.snapThreshold | number | 150 | Per-axis position jump (world units) above which rollback visual correction is suppressed (intentional teleports) |
prediction.errorDecay | number | 0.85 | Multiplicative decay applied to per-entity error offsets each render frame |
prediction.maxErrorPerFrame | number | 0 | Max positional correction (units) applied per render frame. 0 disables the cap |
prediction.neutralInput | PlayerInput | {} | Fallback input payload for unknown ticks or peers |
prediction.inputHistorySize | number | 120 | Tick-history ring size for local and per-peer inputs (about 2 s at 60 Hz) |
prediction.driftTargetTicks | number | 4 | Tick snap target offset: snap target = serverTick + driftTargetTicks |
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, 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
/>
);
}A resimulated tick where an input edge originally fired WILL fire it again with isRollback === true. Keep simulation effects (forces, impulses) unconditional so the replay is correct, and gate non-simulation side effects (sounds, particles, events) on !isRollback or make them idempotent per tick.
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' |
setInput | (input: PlayerInput) => void | Set 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 #
| 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 #
Broadcast rate: Start at 20/s for snapshots. Increase only if motion looks choppy. Higher rates consume more bandwidth.
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.