Core Concepts
This page covers the fundamental building blocks of CarverJS multiplayer: the host-client model, the networked prop, actor ownership, connection states, sync modes, and how multiplayer integrates with the game loop.
import { MultiplayerProvider, useRoom, useLobby, useMultiplayer, usePlayers } from "@carverjs/multiplayer";Host vs Client #
CarverJS multiplayer uses a peer-to-peer host-client model. There is no dedicated game server — one player acts as the host and all others connect as clients.
| Role | Responsibility |
|---|---|
| Host | Runs authoritative game state, resolves conflicts, relays snapshots to clients |
| Client | Sends inputs/requests to host, receives and applies state updates |
The first player to create a room becomes the host. When other players join, they connect directly to the host via WebRTC data channels.
import { useMultiplayer } from "@carverjs/multiplayer";
function DebugOverlay() {
const { isHost, connectionState, peerId } = useMultiplayer();
return (
<div style={{ position: "absolute", top: 8, left: 8, color: "#fff" }}>
<p>Role: {isHost ? "Host" : "Client"}</p>
<p>State: {connectionState}</p>
<p>Peer ID: {peerId}</p>
</div>
);
}The networked Prop #
The networked prop on Actor controls whether and how an actor's state is synchronized across peers. There are three usage patterns.
Simple — networked={true} #
The most common pattern. Syncs the actor's transform (position, rotation, scale) using the default sync mode (snapshot). The host owns the actor.
<Actor type="primitive" shape="box" color="red" position={[0, 1, 0]}
networked={true}
/>Use when: You have a shared object (a platform, a ball, an NPC) that the host controls and all clients should see.
Configured — networked={{ sync, owner }} #
Full control over sync mode and ownership. Use this for player-controlled actors or physics objects that need specific synchronization behavior.
<Actor type="model" src="/player.glb" position={[0, 0, 0]}
networked={{ sync: "prediction", owner: localPlayerId }}
physics={{ bodyType: "dynamic", mass: 1 }}
/>| Field | Type | Default | Description |
|---|---|---|---|
sync | "events" | "snapshot" | "prediction" | false | "snapshot" | Synchronization mode |
owner | string | host peer ID | Peer ID of the player who controls this actor |
priority | number | 1 | Update priority — higher values sync more frequently when bandwidth is limited |
interpolate | boolean | true | Smooth incoming state updates on non-owner clients |
Use when: A specific player owns the actor (their character, their vehicle) or you need a particular sync mode.
Registered but not synced — networked={{ sync: false }} #
The actor is registered with the network system (it gets a stable network ID) but no automatic state synchronization occurs. You handle all updates manually via events.
<Actor type="model" src="/chest.glb" position={[3, 0, 0]}
networked={{ sync: false }}
/>Use when: You want to trigger discrete actions on the actor (open a chest, toggle a switch) via events rather than continuous state sync. This keeps bandwidth minimal.
At a Glance #
| Pattern | Sync | Owner | Typical Use |
|---|---|---|---|
networked={true} | Snapshot (default) | Host | Shared objects, NPCs, world elements |
networked={{ sync: "prediction", owner: id }} | Prediction | Specific player | Player characters, vehicles |
networked={{ sync: "events" }} | Events | Host | Turn-based tokens, chat bubbles |
networked={{ sync: false }} | Manual | — | Chests, doors, switches |
Actor Ownership #
Every networked actor has an owner — the peer who has authority to update it. Other peers receive the owner's updates and apply them.
Default: Host-Owned #
When you use networked={true} or omit the owner field, the host owns the actor. Only the host can move it; clients see a synchronized copy.
{/* Host-owned — only the host can update this actor */}
<Actor type="primitive" shape="sphere" color="gold" position={[0, 2, 0]}
networked={true}
/>Player-Owned #
Assign ownership to a specific player by passing their peer ID:
const { players } = usePlayers();
{players.map((player) => (
<Actor
key={player.id}
type="model"
src="/character.glb"
position={player.spawnPosition}
networked={{ sync: "prediction", owner: player.id }}
/>
))}The owner has full authority to update the actor's transform, physics state, and custom properties. Non-owners see interpolated updates.
Ownership Transfer #
Ownership can be transferred at runtime. A common pattern is picking up an item — ownership moves from the host to the player who grabbed it:
import { useMultiplayer } from "@carverjs/multiplayer";
function GrabbableItem({ actorId }: { actorId: string }) {
const { transferOwnership, peerId } = useMultiplayer();
const handleGrab = () => {
transferOwnership(actorId, peerId);
};
return <mesh onClick={handleGrab}>{/* ... */}</mesh>;
}Connection States #
The multiplayer system follows a state machine. Use connectionState from useMultiplayer to drive your UI.
disconnected ──► connecting ──► connected ──► migrating ──► connected
▲ │
└──────────── error ◄──────────┘| State | Meaning | Recommended UI |
|---|---|---|
"disconnected" | Not connected to any room | Show lobby / main menu |
"connecting" | Establishing WebRTC connection to host | Show loading spinner with "Connecting..." |
"connected" | Fully connected, syncing state | Show the game |
"migrating" | Host left, electing new host | Show brief overlay "Host migrating..." |
"error" | Connection failed or lost unexpectedly | Show error message with retry button |
Handling States in Your UI #
import { useMultiplayer } from "@carverjs/multiplayer";
function ConnectionGate({ children }: { children: React.ReactNode }) {
const { connectionState, error, reconnect } = useMultiplayer();
switch (connectionState) {
case "disconnected":
return <MainMenu />;
case "connecting":
return <LoadingScreen message="Connecting to game..." />;
case "migrating":
return (
<div className="overlay">
<p>Host migrating... please wait</p>
</div>
);
case "error":
return (
<div>
<p>Connection error: {error?.message}</p>
<button onClick={reconnect}>Retry</button>
</div>
);
case "connected":
return <>{children}</>;
}
}Sync Modes #
CarverJS provides three synchronization modes, layered by complexity and latency tolerance. Choose the mode that fits your game's needs — different actors in the same game can use different modes.
Events (Layer 1) #
State changes are sent as discrete messages. No continuous sync — updates happen only when something fires an event.
import { useMultiplayer } from "@carverjs/multiplayer";
function TurnManager() {
const { sendEvent, onEvent } = useMultiplayer();
const endTurn = () => {
sendEvent("turn:end", { player: "player1", action: "move", tile: [3, 2] });
};
onEvent("turn:end", (data) => {
applyTurnAction(data);
});
return <button onClick={endTurn}>End Turn</button>;
}Best for: Turn-based games, card games, chat messages, infrequent interactions.
Snapshot (Layer 2) #
The host periodically sends a full or delta snapshot of each networked actor's state. Clients apply the snapshot and interpolate between updates for smooth visuals.
<Actor type="model" src="/npc.glb" position={[0, 0, 0]}
networked={{ sync: "snapshot" }}
/>Snapshots are sent at the tickRate configured on <MultiplayerProvider> (default 20 Hz). The system automatically computes deltas — only changed properties are transmitted.
Best for: RPGs, casual multiplayer, cooperative games, strategy games.
Prediction (Layer 3) #
The owner applies inputs immediately (client-side prediction) and sends them to the host. The host validates and broadcasts the authoritative result. Non-owner clients interpolate toward the latest confirmed state. If a mismatch is detected, the owner rolls back and replays.
<Actor type="model" src="/player.glb" position={[0, 0, 0]}
networked={{ sync: "prediction", owner: localPlayerId }}
physics={{ bodyType: "dynamic" }}
/>Best for: Fast-paced games where input latency must feel instant — FPS, racing, fighting, platformers.
Decision Table #
| Mode | Latency Feel | Bandwidth | Best For | Complexity |
|---|---|---|---|---|
| Events | N/A (discrete) | Very low | Turn-based, chat, menus | Low |
| Snapshot | Smooth (interpolated) | Moderate | RPG, casual, co-op | Medium |
| Prediction | Instant (predicted) | Higher | FPS, racing, competitive | High |
The Game Loop Integration #
Multiplayer hooks into the CarverJS game loop via R3F's useFrame at priority -55. This places the network tick between the InputFlush (priority -50) and the lateUpdate stage (priority -40).
Frame Execution Order #
| Priority | System | Description |
|---|---|---|
| -5 | GameLoopTick | Advances elapsed time and frame count |
| -10 | earlyUpdate | Input gathering, flag resets |
| -20 | fixedUpdate | Deterministic physics |
| -25 | CollisionFlush | Collision detection |
| -30 | update | General game logic |
| -40 | lateUpdate | Camera follow, trailing effects |
| -50 | InputFlush | Input system flush |
| -55 | MultiplayerTick | Network send/receive, state reconciliation |
| 0 | R3F Render | Scene render |
What Happens Each Network Tick #
Receive — Incoming messages from peers are dequeued and applied.
Reconcile — For prediction-mode actors, mismatch detection and rollback occur.
Send — Outgoing state updates (snapshots, events, or input frames) are batched and transmitted.
This ordering ensures that game logic has finished updating before the network system captures and sends the current frame's state, and that incoming remote state is applied before the next frame's logic runs.
// You don't need to call this manually — it's automatic.
// But you can read network stats in your own game loop callback:
import { useMultiplayer } from "@carverjs/multiplayer";
import { useGameLoop } from "@carverjs/core/hooks";
function NetworkStats() {
const { stats } = useMultiplayer();
useGameLoop(() => {
if (stats.roundTripTime > 200) {
console.warn("High latency:", stats.roundTripTime, "ms");
}
}, { stage: "lateUpdate" });
return null;
}What's Next #
Getting Started — installation, setup, and a quick-start example.