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.

tsx
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.

RoleResponsibility
HostRuns authoritative game state, resolves conflicts, relays snapshots to clients
ClientSends 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.

tsx
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.

tsx
<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.

tsx
<Actor type="model" src="/player.glb" position={[0, 0, 0]}
  networked={{ sync: "prediction", owner: localPlayerId }}
  physics={{ bodyType: "dynamic", mass: 1 }}
/>
FieldTypeDefaultDescription
sync"events" | "snapshot" | "prediction" | false"snapshot"Synchronization mode
ownerstringhost peer IDPeer ID of the player who controls this actor
prioritynumber1Update priority — higher values sync more frequently when bandwidth is limited
interpolatebooleantrueSmooth 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.

tsx
<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 #

PatternSyncOwnerTypical Use
networked={true}Snapshot (default)HostShared objects, NPCs, world elements
networked={{ sync: "prediction", owner: id }}PredictionSpecific playerPlayer characters, vehicles
networked={{ sync: "events" }}EventsHostTurn-based tokens, chat bubbles
networked={{ sync: false }}ManualChests, 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.

tsx
{/* 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:

tsx
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:

tsx
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.

text
disconnected ──► connecting ──► connected ──► migrating ──► connected
      ▲                              │
      └──────────── error ◄──────────┘
StateMeaningRecommended UI
"disconnected"Not connected to any roomShow lobby / main menu
"connecting"Establishing WebRTC connection to hostShow loading spinner with "Connecting..."
"connected"Fully connected, syncing stateShow the game
"migrating"Host left, electing new hostShow brief overlay "Host migrating..."
"error"Connection failed or lost unexpectedlyShow error message with retry button

Handling States in Your UI #

tsx
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.

tsx
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.

tsx
<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.

tsx
<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 #

ModeLatency FeelBandwidthBest ForComplexity
EventsN/A (discrete)Very lowTurn-based, chat, menusLow
SnapshotSmooth (interpolated)ModerateRPG, casual, co-opMedium
PredictionInstant (predicted)HigherFPS, racing, competitiveHigh

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 #

PrioritySystemDescription
-5GameLoopTickAdvances elapsed time and frame count
-10earlyUpdateInput gathering, flag resets
-20fixedUpdateDeterministic physics
-25CollisionFlushCollision detection
-30updateGeneral game logic
-40lateUpdateCamera follow, trailing effects
-50InputFlushInput system flush
-55MultiplayerTickNetwork send/receive, state reconciliation
0R3F RenderScene render

What Happens Each Network Tick #

  1. Receive — Incoming messages from peers are dequeued and applied.

  2. Reconcile — For prediction-mode actors, mismatch detection and rollback occur.

  3. 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.

tsx
// 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 #