Getting Started with Multiplayer

CarverJS Multiplayer adds peer-to-peer networking to any CarverJS game. One player acts as the host, relaying state to all connected clients. Add a provider, mark actors as networked, and you have a multiplayer game.

tsx
import { MultiplayerProvider, useRoom, useLobby, useMultiplayer, usePlayers } from "@carverjs/multiplayer";

Installation #

bash
pnpm add @carverjs/multiplayer

Setup #

Wrap your scene with <MultiplayerProvider> inside your <Game>. Pass a unique appId to namespace your rooms on the signaling network.

tsx
import { Game, World } from "@carverjs/core/components";
import { MultiplayerProvider } from "@carverjs/multiplayer";

function App() {
  return (
    <Game>
      <MultiplayerProvider appId="my-game">
        <World>
          {/* your scene */}
        </World>
      </MultiplayerProvider>
    </Game>
  );
}
PropTypeDefaultDescription
appIdstring--Required. Unique app identifier. Namespaces rooms across the signaling network.
strategyStrategyConfig{ type: 'mqtt' }Signaling strategy. MQTT (free, zero config) or Firebase RTDB.
iceServersRTCIceServer[]Google/Cloudflare STUNCustom STUN/TURN servers for WebRTC.
childrenReactNode--Scene content (Worlds, Actors, etc.)

Signaling Strategies #

CarverJS multiplayer is serverless -- peer discovery and WebRTC handshakes happen through existing networks, not a custom server. You bring your own infrastructure:

tsx
// Free with MQTT (zero config, default)
<MultiplayerProvider appId="my-game">

// Firebase Realtime Database (your own project)
<MultiplayerProvider
  appId="my-game"
  strategy={{ type: 'firebase', databaseURL: 'https://my-project.firebaseio.com' }}
>

// With custom TURN server (e.g. Cloudflare TURN)
<MultiplayerProvider
  appId="my-game"
  iceServers={[
    { urls: 'stun:stun.cloudflare.com:3478' },
    { urls: 'turn:turn.cloudflare.com:3478', username: '...', credential: '...' },
  ]}
>

Quick Start #

Below is a minimal multiplayer game with a lobby screen and a networked scene. Players join a room, and once the host starts the game, everyone sees a shared world with networked actors.

tsx
import { useState } from "react";
import { Game, World, Actor } from "@carverjs/core/components";
import { MultiplayerProvider, useRoom, useLobby, useMultiplayer, usePlayers } from "@carverjs/multiplayer";

function Lobby() {
  const { rooms, createRoom } = useLobby();
  const { room, join, leave } = useRoom();
  const { players } = usePlayers();
  const { start } = useMultiplayer();

  if (!room) {
    return (
      <div>
        <h2>Open Rooms</h2>
        <ul>
          {rooms.map((r) => (
            <li key={r.id}>
              {r.name} ({r.playerCount}/{r.maxPlayers})
              <button onClick={() => join(r.id)}>Join</button>
            </li>
          ))}
        </ul>
        <button onClick={() => createRoom({ name: "My Room", maxPlayers: 4 })}>
          Create Room
        </button>
      </div>
    );
  }

  return (
    <div>
      <h2>{room.name}</h2>
      <p>Players: {players.map((p) => p.name).join(", ")}</p>
      <button onClick={start}>Start Game</button>
      <button onClick={leave}>Leave</button>
    </div>
  );
}

function NetworkedScene() {
  const { players } = usePlayers();

  return (
    <World>
      {/* Shared environment -- not networked, rendered locally */}
      <Actor type="primitive" shape="plane" color="#eee" size={20}
        rotation={[-Math.PI / 2, 0, 0]} receiveShadow />

      {/* Each player gets a networked actor */}
      {players.map((player) => (
        <Actor
          key={player.id}
          type="primitive"
          shape="box"
          color={player.isLocal ? "blue" : "red"}
          position={player.spawnPosition}
          networked={{ sync: "snapshot", owner: player.id }}
          castShadow
        />
      ))}
    </World>
  );
}

function App() {
  const [started, setStarted] = useState(false);

  return (
    <Game>
      <MultiplayerProvider appId="my-game">
        {!started ? <Lobby /> : <NetworkedScene />}
      </MultiplayerProvider>
    </Game>
  );
}

How It Works #

P2P Host-Client Model #

CarverJS multiplayer uses a peer-to-peer architecture where one player is the host and all others are clients. The host runs the authoritative game state and relays it to clients. There is no dedicated server -- the host is the server.

text
 ┌────────┐       ┌────────┐
 │ Client │◄─────►│  Host  │◄─────►┌────────┐
 └────────┘       │(authority)│     │ Client │
                  └────────┘       └────────┘

Peer discovery happens through a signaling strategy (MQTT brokers or Firebase RTDB). After peers find each other, all game data flows directly peer-to-peer over WebRTC data channels. The signaling network is only used for the initial handshake.

Three Sync Modes #

CarverJS provides three layers of synchronization, each suited to a different type of game:

ModeLayerBest For
Events1Turn-based games, chat, infrequent updates
Snapshot2RPGs, casual games, moderate update frequency
Prediction3FPS, racing, competitive games requiring low-latency feel

You choose the mode per actor via the networked prop. Different actors in the same game can use different modes.

Host Election #

Host assignment is deterministic and serverless: peers are sorted by their ID, and the lowest ID becomes host. If the host disconnects, the next peer automatically takes over. State is preserved through the migration -- clients experience a brief pause (the "migrating" connection state) and then resume under the new host.


Migrating from Single-Player #

Already have a working CarverJS game? You can add multiplayer in four steps.

Step 1 -- Add MultiplayerProvider #

Wrap your existing scene with <MultiplayerProvider>. You can place it inside or outside <Game>:

tsx
// Option A: Provider inside Game (simplest)
<Game>
  <MultiplayerProvider appId="my-game">
    <World>
      <MyScene />
    </World>
  </MultiplayerProvider>
</Game>

// Option B: Provider outside Game (recommended for lobby UI)
// Requires MultiplayerBridge inside Game to bridge the context into the R3F Canvas
<MultiplayerProvider appId="my-game">
  <Game>
    <MultiplayerBridge>
      <World>
        <MyScene />
      </World>
    </MultiplayerBridge>
  </Game>
</MultiplayerProvider>

Step 2 -- Mark actors as networked #

Add the networked prop to any <Actor> whose state should be shared across players:

tsx
// Before
<Actor type="model" src="/player.glb" position={[0, 0, 0]} />

// After
<Actor type="model" src="/player.glb" position={[0, 0, 0]} networked={true} />

Actors without networked remain local -- they render on each client independently (great for particles, UI elements, and static scenery).

Step 3 -- Add useMultiplayer hook #

Use useMultiplayer in a component to control the connection lifecycle:

tsx
import { useMultiplayer } from "@carverjs/multiplayer";

function GameManager() {
  const { connectionState, start, stop } = useMultiplayer();

  useEffect(() => {
    start();
    return () => stop();
  }, [start, stop]);

  if (connectionState === "connecting") {
    return <LoadingScreen message="Connecting..." />;
  }

  return null;
}

Step 4 -- Add a lobby UI #

Add useLobby and useRoom to build a room browser so players can find each other:

tsx
import { useLobby, useRoom } from "@carverjs/multiplayer";

function SimpleLobby() {
  const { rooms, createRoom } = useLobby();
  const { join } = useRoom();

  return (
    <div>
      {rooms.map((r) => (
        <button key={r.id} onClick={() => join(r.id)}>
          {r.name} ({r.playerCount}/{r.maxPlayers})
        </button>
      ))}
      <button onClick={() => createRoom({ name: "New Game", maxPlayers: 4 })}>
        Host Game
      </button>
    </div>
  );
}

What's Next #