Party Game
Build a party game with private lobbies, where a host creates a room, friends join with a party code, and the host starts the game.
What we’ll build:
- A
partyMatchmakerActor that creates parties with six-character codes and lets friends join by code. - Join tickets: tokens the matchmaker issues and the party checks before it lets anyone in.
- A
partyMatchActor per party that tracks members, the host, ready flags, and the phase. - Host-only actions to start and finish the game, with no tick loop.
Install
npm add rivetkit@3.0.0-preview.1
Create the matchmaker
One partyMatchmaker Actor, keyed ["main"], creates parties and hands out join tickets. Parties and tickets live in its SQLite database:
if (message.name === "createParty") {
const now = Date.now();
const matchId = crypto.randomUUID();
const partyCode = generatePartyCode();
const playerId = crypto.randomUUID();
const joinToken = crypto.randomUUID();
const playerName =
message.body.hostName || generatePlayerName();
const client = c.client<typeof registry>();
await client.partyMatch.create([matchId], {
input: { matchId, partyCode, hostPlayerId: playerId },
});
await c.db.execute(
`INSERT INTO parties (match_id, party_code, host_player_id, player_count, created_at) VALUES (?, ?, ?, ?, ?)`,
matchId,
partyCode,
playerId,
1,
now,
);
await c.db.execute(
`INSERT INTO join_tickets (join_token, match_id, player_id, player_name, created_at) VALUES (?, ?, ?, ?, ?)`,
joinToken,
matchId,
playerId,
playerName,
now,
);
await message.complete({
matchId,
playerId,
partyCode,
joinToken,
playerName,
});
} else if (message.name === "joinParty") {
const code = message.body.partyCode.toUpperCase().trim();
const rows = await c.db.execute<{
match_id: string;
player_count: number;
}>(
`SELECT match_id, player_count FROM parties WHERE party_code = ?`,
code,
);
const row = rows[0];
if (!row) {
throw new UserError("Party not found", {
code: "party_not_found",
});
}
if (row.player_count >= MAX_PARTY_SIZE) {
throw new UserError("Party is full", {
code: "party_full",
});
}
const now = Date.now();
const playerId = crypto.randomUUID();
const joinToken = crypto.randomUUID();
const playerName =
message.body.playerName || generatePlayerName();
await c.db.execute(
`UPDATE parties SET player_count = player_count + 1 WHERE match_id = ?`,
row.match_id,
);
await c.db.execute(
`INSERT INTO join_tickets (join_token, match_id, player_id, player_name, created_at) VALUES (?, ?, ?, ?, ?)`,
joinToken,
row.match_id,
playerId,
playerName,
now,
);
await message.complete({
matchId: row.match_id,
playerId,
joinToken,
playerName,
});
- Every request is a queue message, and
runhandles one message at a time. Two friends joining the last open slot at once can’t both get in.createParty,joinParty,verifyJoin,updatePartySize, andclosePartyare all queues. createPartycreates thepartyMatchActor with the party code and the host’s player id, saves apartiesrow, and returns ajoinTokenalong with the code. Codes leave out I, O, 0, and 1 so they are easy to read aloud, and theparty_codecolumn isUNIQUE.joinPartyuppercases and trims the code, rejects an unknown code withparty_not_foundand a full party withparty_full(MAX_PARTY_SIZEis 8), and issues a new ticket.joinPartycounts the player as soon as it issues the ticket, so the slot is held while they connect. The party corrects the count later withupdatePartySize.
Create the match
Each party is its own partyMatch Actor, keyed by the match id. A player gets in only with a ticket the matchmaker accepts:
createConnState: async (
c,
params: { playerId?: string; joinToken?: string },
): Promise<PartyConnState> => {
const playerId = params?.playerId;
const joinToken = params?.joinToken;
const matchId = c.key[0];
if (!matchId || !playerId || !joinToken) {
throw new UserError("invalid join params", {
code: "invalid_join_params",
});
}
const client = c.client<typeof registry>();
const result = await client.partyMatchmaker.getOrCreate(["main"]).send(
"verifyJoin",
{
matchId,
playerId,
joinToken,
},
{ wait: true, timeout: 3_000 },
);
if (result.status !== "completed") {
throw new UserError("join verification timed out", {
code: "join_verification_timed_out",
});
}
const response = (
result as {
response?: {
allowed?: boolean;
playerName?: string;
isHost?: boolean;
};
}
).response;
if (!response?.allowed || !response.playerName) {
throw new UserError("invalid join ticket", {
code: "invalid_join_ticket",
});
}
return {
playerId,
playerName: response.playerName,
isHost: response.isHost === true,
};
},
onConnect: async (c, conn) => {
const playerId = conn.state.playerId;
const existing = c.state.members[playerId];
if (
!existing &&
Object.keys(c.state.members).length >= MAX_PARTY_SIZE
) {
conn.disconnect("party_full");
return;
}
if (existing && existing.connId !== conn.id) {
conn.disconnect("duplicate_player");
return;
}
const hasHost = Object.values(c.state.members).some(
(member) => member.isHost,
);
const isHost = conn.state.isHost || !hasHost;
if (isHost) {
for (const member of Object.values(c.state.members)) {
member.isHost = false;
}
}
c.state.members[playerId] = {
connId: conn.id,
name: conn.state.playerName,
color: existing?.color ?? getPlayerColor(playerId),
isHost,
isReady: existing?.isReady ?? false,
};
broadcastSnapshot(c);
await updatePartySize(c);
},
const rows = await c.db.execute<{
player_name: string;
host_player_id: string;
}>(
`SELECT jt.player_name, p.host_player_id
FROM join_tickets jt
INNER JOIN parties p ON p.match_id = jt.match_id
WHERE jt.join_token = ? AND jt.match_id = ? AND jt.player_id = ?
LIMIT 1`,
message.body.joinToken,
message.body.matchId,
message.body.playerId,
);
const row = rows[0];
if (!row) {
await message.complete({ allowed: false, isHost: false });
continue;
}
await message.complete({
allowed: true,
playerName: row.player_name,
isHost: row.host_player_id === message.body.playerId,
});
createConnStateruns before the connection is accepted. It sendsverifyJoinwith the match id fromc.key[0], theplayerId, and thejoinToken, and throwsinvalid_join_ticketif the matchmaker has no such ticket. A rejected player never gets a connection.- The verified name and host flag become connection state. Actions read the player from
c.conn.state.playerId, never from their arguments, so a member can’t act as someone else. onConnectchecks the size limit again and turns away a second connection for the same player withduplicate_player. If the party has no host, the new member becomes the host.- Tickets stay valid until
closeParty, so a player who drops can reconnect with the same ticket.
Add host controls
Nothing in a party lobby changes on its own, so there is no tick loop. Every change comes from an action or a connection, and each one broadcasts partyUpdate:
import { type ActorContextOf, actor, event, UserError } from "rivetkit";
import type { registry } from "../index.ts";
import { getPlayerColor } from "../player-color.ts";
import { MAX_PARTY_SIZE, type PartyPhase } from "./config.ts";
interface PartyConnState {
playerId: string;
playerName: string;
isHost: boolean;
}
interface MemberEntry {
connId: string;
name: string;
color: string;
isHost: boolean;
isReady: boolean;
}
interface State {
matchId: string;
partyCode: string;
phase: PartyPhase;
members: Record<string, MemberEntry>;
}
export const partyMatch = actor({
options: { name: "Party - Match", icon: "people-group" },
events: {
partyUpdate: event<PartySnapshot>(),
},
createState: (
_c,
input: { matchId: string; partyCode: string; hostPlayerId: string },
): State => ({
matchId: input.matchId,
partyCode: input.partyCode,
phase: "waiting",
members: {},
}),
createConnState: async (
c,
params: { playerId?: string; joinToken?: string },
): Promise<PartyConnState> => {
const playerId = params?.playerId;
const joinToken = params?.joinToken;
const matchId = c.key[0];
if (!matchId || !playerId || !joinToken) {
throw new UserError("invalid join params", {
code: "invalid_join_params",
});
}
const client = c.client<typeof registry>();
const result = await client.partyMatchmaker.getOrCreate(["main"]).send(
"verifyJoin",
{
matchId,
playerId,
joinToken,
},
{ wait: true, timeout: 3_000 },
);
if (result.status !== "completed") {
throw new UserError("join verification timed out", {
code: "join_verification_timed_out",
});
}
const response = (
result as {
response?: {
allowed?: boolean;
playerName?: string;
isHost?: boolean;
};
}
).response;
if (!response?.allowed || !response.playerName) {
throw new UserError("invalid join ticket", {
code: "invalid_join_ticket",
});
}
return {
playerId,
playerName: response.playerName,
isHost: response.isHost === true,
};
},
onConnect: async (c, conn) => {
const playerId = conn.state.playerId;
const existing = c.state.members[playerId];
if (
!existing &&
Object.keys(c.state.members).length >= MAX_PARTY_SIZE
) {
conn.disconnect("party_full");
return;
}
if (existing && existing.connId !== conn.id) {
conn.disconnect("duplicate_player");
return;
}
const hasHost = Object.values(c.state.members).some(
(member) => member.isHost,
);
const isHost = conn.state.isHost || !hasHost;
if (isHost) {
for (const member of Object.values(c.state.members)) {
member.isHost = false;
}
}
c.state.members[playerId] = {
connId: conn.id,
name: conn.state.playerName,
color: existing?.color ?? getPlayerColor(playerId),
isHost,
isReady: existing?.isReady ?? false,
};
broadcastSnapshot(c);
await updatePartySize(c);
},
onDisconnect: async (c, conn) => {
const playerId = conn.state.playerId;
const member = c.state.members[playerId];
if (!member || member.connId !== conn.id) return;
const removedHost = member.isHost;
delete c.state.members[playerId];
if (removedHost) {
promoteNextHost(c.state);
}
broadcastSnapshot(c);
await updatePartySize(c);
},
onDestroy: async (c) => {
const client = c.client<typeof registry>();
await client.partyMatchmaker
.getOrCreate(["main"])
.send("closeParty", { matchId: c.state.matchId });
},
actions: {
setName: (c, input: { name: string }) => {
const member = c.state.members[c.conn.state.playerId];
if (!member || member.connId !== c.conn.id) {
throw new UserError("member not found", {
code: "member_not_found",
});
}
member.name = input.name.trim() || "Player";
broadcastSnapshot(c);
},
toggleReady: (c) => {
const member = c.state.members[c.conn.state.playerId];
if (!member || member.connId !== c.conn.id) {
throw new UserError("member not found", {
code: "member_not_found",
});
}
member.isReady = !member.isReady;
broadcastSnapshot(c);
},
startGame: (c) => {
const member = c.state.members[c.conn.state.playerId];
if (!member || member.connId !== c.conn.id) {
throw new UserError("member not found", {
code: "member_not_found",
});
}
if (!member.isHost) {
throw new UserError("only host can start", {
code: "not_host",
});
}
if (c.state.phase !== "waiting") {
throw new UserError("game already started", {
code: "already_started",
});
}
c.state.phase = "playing";
broadcastSnapshot(c);
},
finishGame: (c) => {
const member = c.state.members[c.conn.state.playerId];
if (!member || member.connId !== c.conn.id) {
throw new UserError("member not found", {
code: "member_not_found",
});
}
if (!member.isHost) {
throw new UserError("only host can finish", {
code: "not_host",
});
}
if (c.state.phase !== "playing") {
throw new UserError("game not in progress", {
code: "not_playing",
});
}
c.state.phase = "finished";
broadcastSnapshot(c);
},
getSnapshot: (c) => buildSnapshot(c),
},
});
interface PartySnapshot {
matchId: string;
partyCode: string;
phase: PartyPhase;
members: Record<
string,
{
name: string;
color: string;
isHost: boolean;
isReady: boolean;
connected: boolean;
}
>;
}
function buildSnapshot(c: ActorContextOf<typeof partyMatch>): PartySnapshot {
const members: PartySnapshot["members"] = {};
for (const [id, entry] of Object.entries(c.state.members)) {
members[id] = {
name: entry.name,
color: entry.color,
isHost: entry.isHost,
isReady: entry.isReady,
connected: true,
};
}
return {
matchId: c.state.matchId,
partyCode: c.state.partyCode,
phase: c.state.phase,
members,
};
}
function broadcastSnapshot(c: ActorContextOf<typeof partyMatch>) {
c.broadcast("partyUpdate", buildSnapshot(c));
}
async function updatePartySize(c: ActorContextOf<typeof partyMatch>) {
const client = c.client<typeof registry>();
await client.partyMatchmaker.getOrCreate(["main"]).send("updatePartySize", {
matchId: c.state.matchId,
playerCount: Object.keys(c.state.members).length,
});
}
function promoteNextHost(state: State) {
for (const member of Object.values(state.members)) {
member.isHost = false;
}
const next = Object.values(state.members)[0];
if (next) {
next.isHost = true;
next.isReady = false;
}
}
startGameandfinishGamethrownot_hostfor anyone but the host, and check the phase:waitingtoplayingtofinished. A second click getsalready_startedornot_playing.toggleReadyflips the member’s ready flag. The example lets the host start without everyone ready. Add a check instartGameif your game needs it.- When a member leaves,
onDisconnectremoves them. If it was the host,promoteNextHostmakes the next member the host and clears their ready flag, so the party never gets stuck without one. - After each join and leave the match sends
updatePartySize, so the matchmaker turns joiners away only when the party is really full.
The playing phase is where your game goes. For a quiz or drawing game, add actions that only work while phase === "playing". For a realtime mini-game, add a run loop that ticks only during playing, or create a separate match Actor such as an arena match and send every member its id.
Register the Actors
Add both Actors to the registry and start it:
import { setup } from "rivetkit";
import { arenaMatch } from "./arena/match.ts";
import { arenaMatchmaker } from "./arena/matchmaker.ts";
import { battleRoyaleMatch } from "./battle-royale/match.ts";
import { battleRoyaleMatchmaker } from "./battle-royale/matchmaker.ts";
import { idleLeaderboard } from "./idle/leaderboard.ts";
import { idleWorld } from "./idle/world.ts";
import { ioStyleMatch } from "./io-style/match.ts";
import { ioStyleMatchmaker } from "./io-style/matchmaker.ts";
import { openWorldChunk } from "./open-world/chunk.ts";
import { partyMatch } from "./party/match.ts";
import { partyMatchmaker } from "./party/matchmaker.ts";
import { physics2dWorld } from "./physics-2d/world.ts";
import { physics3dWorld } from "./physics-3d/world.ts";
import { rankedLeaderboard } from "./ranked/leaderboard.ts";
import { rankedMatch } from "./ranked/match.ts";
import { rankedMatchmaker } from "./ranked/matchmaker.ts";
import { rankedPlayer } from "./ranked/player.ts";
import { turnBasedMatch } from "./turn-based/match.ts";
import { turnBasedMatchmaker } from "./turn-based/matchmaker.ts";
export {
arenaMatch,
arenaMatchmaker,
battleRoyaleMatch,
battleRoyaleMatchmaker,
idleLeaderboard,
idleWorld,
ioStyleMatch,
ioStyleMatchmaker,
openWorldChunk,
partyMatch,
partyMatchmaker,
physics2dWorld,
physics3dWorld,
rankedLeaderboard,
rankedMatch,
rankedMatchmaker,
rankedPlayer,
turnBasedMatch,
turnBasedMatchmaker,
};
export const registry = setup({
use: {
arenaMatch,
arenaMatchmaker,
battleRoyaleMatch,
battleRoyaleMatchmaker,
idleLeaderboard,
idleWorld,
ioStyleMatchmaker,
ioStyleMatch,
openWorldChunk,
partyMatch,
partyMatchmaker,
physics2dWorld,
physics3dWorld,
rankedLeaderboard,
rankedMatch,
rankedMatchmaker,
rankedPlayer,
turnBasedMatch,
turnBasedMatchmaker,
},
});
The example registers every game type in one registry. A party game only needs partyMatchmaker and partyMatch. Each Actor file imports type { registry } so that c.client<typeof registry>() is typed when the matchmaker creates a party and the party checks tickets.
Connect players
The menu creates or joins a party, then the lobby connects to it with the ticket it got back:
import { useState } from "react";
import type { GameClient } from "../../client.ts";
export interface PartyMatchInfo {
matchId: string;
playerId: string;
partyCode: string;
joinToken: string;
playerName: string;
}
export function PartyMenu({
client,
onReady,
onBack,
}: {
client: GameClient;
onReady: (info: PartyMatchInfo) => void;
onBack: () => void;
}) {
const [joinCode, setJoinCode] = useState("");
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
const [error, setError] = useState("");
const createParty = async () => {
setStatus("loading");
setError("");
try {
const mm = client.partyMatchmaker.getOrCreate(["main"]).connect();
const result = await mm.send(
"createParty",
{},
{ wait: true, timeout: 10_000 },
);
mm.dispose();
const response = result.response;
if (!response?.matchId) throw new Error("Failed to create party");
onReady(response);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setStatus("error");
}
};
const joinParty = async () => {
if (!joinCode.trim()) return;
setStatus("loading");
setError("");
try {
const mm = client.partyMatchmaker.getOrCreate(["main"]).connect();
const result = await mm.send(
"joinParty",
{ partyCode: joinCode.trim() },
{ wait: true, timeout: 10_000 },
);
mm.dispose();
const response = result.response;
if (!response?.matchId) throw new Error("Failed to join party");
onReady({ ...response, partyCode: joinCode.trim().toUpperCase() });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setStatus("error");
}
};
return (
<div className="app">
<button className="back-link" onClick={onBack}>
← Back
</button>
<div className="menu-container">
<h2>Party</h2>
<p className="menu-description">
Create or join a party lobby with invite codes. Host
controls the game flow.
</p>
<button
className="btn btn-primary"
onClick={() => void createParty()}
disabled={status === "loading"}
style={{ width: "100%", marginBottom: 20 }}
>
{status === "loading" ? "Loading..." : "Create Party"}
</button>
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
marginBottom: 20,
}}
>
<div
style={{ flex: 1, height: 1, background: "#2c2c2e" }}
/>
<span style={{ color: "#8e8e93", fontSize: 12 }}>
OR JOIN WITH CODE
</span>
<div
style={{ flex: 1, height: 1, background: "#2c2c2e" }}
/>
</div>
<input
type="text"
placeholder="Party code"
value={joinCode}
onChange={(e) => setJoinCode(e.target.value.toUpperCase())}
className="text-input"
maxLength={6}
style={{
width: "100%",
marginBottom: 12,
fontFamily:
"ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace",
fontSize: 18,
letterSpacing: 4,
textAlign: "center",
}}
/>
<button
className="btn btn-primary"
onClick={() => void joinParty()}
disabled={status === "loading" || !joinCode.trim()}
style={{ width: "100%" }}
>
{status === "loading" ? "Loading..." : "Join Party"}
</button>
{status === "error" && (
<div className="status-box">
<p className="status-error">{error}</p>
</div>
)}
</div>
</div>
);
}
import { useCallback, useEffect, useRef, useState } from "react";
import type { PartyMatchConn } from "../../actor-types.ts";
import type { GameClient } from "../../client.ts";
import { PartyBot } from "./bot.ts";
import type { PartyMatchInfo } from "./menu.tsx";
interface PartySnapshot {
matchId: string;
partyCode: string;
phase: "waiting" | "playing" | "finished";
members: Record<
string,
{
name: string;
color: string;
isHost: boolean;
isReady: boolean;
connected: boolean;
}
>;
}
export function PartyGame({
client,
matchInfo,
onLeave,
}: {
client: GameClient;
matchInfo: PartyMatchInfo;
onLeave: () => void;
}) {
const [snapshot, setSnapshot] = useState<PartySnapshot | null>(null);
const [nameInput, setNameInput] = useState(
matchInfo.playerName || "Player",
);
const connRef = useRef<PartyMatchConn | null>(null);
const botsRef = useRef<PartyBot[]>([]);
const nameTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const cleanup = useCallback(() => {
const conn = connRef.current;
connRef.current = null;
conn?.dispose();
}, []);
useEffect(() => {
const conn = client.partyMatch
.get([matchInfo.matchId], {
params: {
playerId: matchInfo.playerId,
joinToken: matchInfo.joinToken,
},
})
.connect();
connRef.current = conn;
conn.on("partyUpdate", (snap) => {
setSnapshot(snap);
});
conn.getSnapshot().then((snap) => {
setSnapshot(snap);
const myName = snap.members[matchInfo.playerId]?.name;
if (myName) setNameInput(myName);
});
return () => {
cleanup();
for (const bot of botsRef.current) bot.destroy();
botsRef.current = [];
};
}, [client, matchInfo, cleanup]);
const onNameChange = (value: string) => {
setNameInput(value);
if (nameTimeoutRef.current) clearTimeout(nameTimeoutRef.current);
nameTimeoutRef.current = setTimeout(() => {
connRef.current?.setName({ name: value }).catch(() => {});
}, 300);
};
const addBot = () => {
botsRef.current.push(new PartyBot(client, matchInfo.partyCode));
};
const toggleReady = () => {
connRef.current?.toggleReady().catch(() => {});
};
const startGame = () => {
connRef.current?.startGame().catch(() => {});
};
const finishGame = () => {
connRef.current?.finishGame().catch(() => {});
};
const myMember = snapshot?.members[matchInfo.playerId];
const isHost = myMember?.isHost ?? false;
const memberList = snapshot ? Object.entries(snapshot.members) : [];
return (
<div className="app">
<div className="game-header">
<h2>Party</h2>
<div className="btn-row">
<button className="btn btn-secondary" onClick={addBot}>
Add Bot
</button>
<button
className="btn btn-secondary"
onClick={() => {
cleanup();
onLeave();
}}
>
Leave
</button>
</div>
</div>
<div className="menu-container">
<div className="party-code-display">
<div className="party-code-label">Party Code</div>
<div className="party-code">{matchInfo.partyCode}</div>
</div>
{snapshot && (
<div className="party-phase-badge">
<span className={`badge badge-${snapshot.phase}`}>
{snapshot.phase.toUpperCase()}
</span>
</div>
)}
<div style={{ marginBottom: 16 }}>
<label
style={{
display: "block",
color: "#8e8e93",
fontSize: 12,
marginBottom: 4,
}}
>
Your Name
</label>
<input
type="text"
placeholder="Your name"
value={nameInput}
onChange={(e) => onNameChange(e.target.value)}
className="text-input"
style={{ width: "100%" }}
/>
</div>
<div className="party-members">
<div className="party-members-header">
Members ({memberList.length})
</div>
{memberList.map(([id, member]) => (
<div key={id} className="party-member-row">
<span
className="party-member-name"
style={{ color: member.color }}
>
{member.name}
{id === matchInfo.playerId ? " (You)" : ""}
</span>
<span className="party-member-badges">
{member.isHost && (
<span className="badge badge-live">
Host
</span>
)}
{member.isReady && (
<span className="badge badge-live">
Ready
</span>
)}
{!member.connected && (
<span className="badge badge-finished">
Offline
</span>
)}
</span>
</div>
))}
</div>
{snapshot?.phase === "waiting" && (
<div className="btn-row" style={{ marginTop: 16 }}>
<button
className={`btn ${myMember?.isReady ? "btn-secondary" : "btn-success"}`}
onClick={toggleReady}
>
{myMember?.isReady ? "Unready" : "Ready"}
</button>
{isHost && (
<button
className="btn btn-primary"
onClick={startGame}
>
Start Game
</button>
)}
</div>
)}
{snapshot?.phase === "playing" && (
<div style={{ marginTop: 16, textAlign: "center" }}>
<p
style={{
color: "#8e8e93",
fontSize: 14,
marginBottom: 12,
}}
>
Game is in progress
</p>
{isHost ? (
<button
className="btn btn-primary"
onClick={finishGame}
>
Finish Game
</button>
) : (
<p style={{ color: "#6e6e73", fontSize: 12 }}>
The host can finish the game when ready.
</p>
)}
</div>
)}
{snapshot?.phase === "finished" && (
<div
className="match-found-text"
style={{ textAlign: "center", marginTop: 16 }}
>
Game Complete!
</div>
)}
</div>
</div>
);
}
mm.send("createParty", {}, { wait: true })andmm.send("joinParty", { partyCode }, { wait: true })wait for the queue’s reply. A joiner doesn’t get the code back, so the menu adds the code the player typed.- The lobby connects with
partyMatch.get(...)and puts bothplayerIdandjoinTokeninparams, which is whatcreateConnStatechecks. - It subscribes to
partyUpdateand also callsgetSnapshotonce, so the member list shows up even if no change has happened since it connected. - The buttons call
toggleReady,startGame, andfinishGame. The UI only shows Start Game to the host, but the server’snot_hostcheck is what enforces it.
Try it
git clone https://github.com/rivet-dev/rivet.git
cd rivet/examples/multiplayer-game-patterns
pnpm install
pnpm dev
Open http://localhost:5173, pick Party, and click Create Party. The lobby shows the party code and lists you as Host. Open a second tab, enter the code, and click Join Party, or click Add Bot. Click Ready, then Start Game as the host, and the phase changes to PLAYING. Close the host’s tab and the other member becomes the host.
Next steps
- Arena Game: run a realtime match with teams and a tick loop.
- Connections:
createConnState, connection params, and per-connection state. - Authentication: replace join tickets with signed user tokens.