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.
import { MultiplayerProvider, useRoom, useLobby, useMultiplayer, usePlayers } from "@carverjs/multiplayer";Installation #
pnpm add @carverjs/multiplayer@carverjs/multiplayer requires @carverjs/core and react as peer dependencies. If you already have a CarverJS game running, you're all set. For Firebase signaling, also install firebase.
Setup #
Wrap your scene with <MultiplayerProvider> inside your <Game>. Pass a unique appId to namespace your rooms on the signaling network.
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>
);
}| Prop | Type | Default | Description |
|---|---|---|---|
appId | string | -- | Required. Unique app identifier. Namespaces rooms across the signaling network. |
strategy | StrategyConfig | { type: 'mqtt' } | Signaling strategy. MQTT (free, zero config) or Firebase RTDB. |
iceServers | RTCIceServer[] | Google/Cloudflare STUN | Custom STUN/TURN servers for WebRTC. |
children | ReactNode | -- | 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:
MQTT (default) -- Free, zero config. Uses public MQTT brokers. Good for prototyping and small games.
Firebase RTDB -- Bring your own Firebase project. More reliable for production. Set up a Firebase Realtime Database, enable test-mode rules, and pass the URL.
TURN server (optional) -- Only needed when peers are behind restrictive NAT/firewalls. Cloudflare TURN or any TURN provider. For same-network testing, STUN alone is sufficient.
// 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.
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.
┌────────┐ ┌────────┐
│ 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:
| Mode | Layer | Best For |
|---|---|---|
| Events | 1 | Turn-based games, chat, infrequent updates |
| Snapshot | 2 | RPGs, casual games, moderate update frequency |
| Prediction | 3 | FPS, 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>:
// 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>R3F's <Canvas> (used by <Game>) creates a separate React reconciler. React contexts from the parent tree are not automatically available inside it. <MultiplayerBridge> re-provides the multiplayer context inside the Canvas. See MultiplayerBridge for details.
Step 2 -- Mark actors as networked #
Add the networked prop to any <Actor> whose state should be shared across players:
// 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:
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:
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 #
Core Concepts -- deep dive into host/client architecture, sync modes, actor ownership, and connection states.