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
Create the matchmaker
One partyMatchmaker Actor, keyed ["main"], creates parties and hands out join tickets. Parties and tickets live in its SQLite database:
createParty: async (c, { hostName }: { hostName?: string }) => {
const now = Date.now();
const matchId = crypto.randomUUID();
const partyCode = generatePartyCode();
const playerId = crypto.randomUUID();
const joinToken = crypto.randomUUID();
const playerName = hostName || generatePlayerName();
const client = c.client<typeof registry>();
await client.partyMatch.create([matchId], {
input: { partyCode },
});
// Save the party and the host's ticket together, so a party never
// exists without a ticket for its host.
await c.db.transaction(
async (tx) => {
await tx.execute(
`INSERT INTO parties (match_id, party_code, host_player_id, player_count, created_at) VALUES (?, ?, ?, ?, ?)`,
matchId,
partyCode,
playerId,
1,
now,
);
await tx.execute(
`INSERT INTO join_tickets (join_token, match_id, player_id, player_name, created_at) VALUES (?, ?, ?, ?, ?)`,
joinToken,
matchId,
playerId,
playerName,
now,
);
},
{ name: "create-party" },
);
return { matchId, playerId, partyCode, joinToken, playerName };
},
joinParty: async (
c,
input: { partyCode: string; playerName?: string },
) => {
const code = input.partyCode.toUpperCase().trim();
const now = Date.now();
const playerId = crypto.randomUUID();
const joinToken = crypto.randomUUID();
const playerName = input.playerName || generatePlayerName();
// Actions run concurrently, so check the size and take the slot in
// one transaction. Two friends joining the last open slot at once
// can't both get in.
const matchId = await c.db.transaction(
async (tx) => {
const [party] = await tx.execute<{
match_id: string;
player_count: number;
}>(
`SELECT match_id, player_count FROM parties WHERE party_code = ?`,
code,
);
if (!party) {
throw new UserError("Party not found", {
code: "party_not_found",
});
}
if (party.player_count >= MAX_PARTY_SIZE) {
throw new UserError("Party is full", {
code: "party_full",
});
}
await tx.execute(
`UPDATE parties SET player_count = player_count + 1 WHERE match_id = ?`,
party.match_id,
);
await tx.execute(
`INSERT INTO join_tickets (join_token, match_id, player_id, player_name, created_at) VALUES (?, ?, ?, ?, ?)`,
joinToken,
party.match_id,
playerId,
playerName,
now,
);
return party.match_id;
},
{ name: "join-party" },
);
return { matchId, playerId, joinToken, playerName };
},
- Every request is an action:
createParty,joinParty,verifyJoin,updatePartySize, andcloseParty. createPartycreates thepartyMatchActor with the party code as input, saves apartiesrow and the host’s ticket in one transaction, 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.- Actions run at the same time, so
joinPartychecks the size, counts the player, and saves the ticket in onec.db.transaction. Two friends joining the last open slot at once can’t both get in. 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:
onBeforeConnect: async (
c,
params: { playerId?: string; joinToken?: string },
) => {
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 ticket = await client.partyMatchmaker
.getOrCreate(["main"])
.verifyJoin({ matchId, playerId, joinToken });
if (!ticket.allowed || !ticket.playerName) {
throw new UserError("invalid join ticket", {
code: "invalid_join_ticket",
});
}
c.vars.verifiedTickets.set(playerId, {
playerId,
playerName: ticket.playerName,
isHost: ticket.isHost,
});
},
onConnect: async (c, conn) => {
const playerId = conn.params?.playerId;
const verified = playerId
? c.vars.verifiedTickets.get(playerId)
: undefined;
if (!playerId || !verified) {
conn.disconnect("invalid_join_ticket");
return;
}
const existing = c.vars.members[playerId];
if (
!existing &&
Object.keys(c.vars.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.vars.members).some(
(member) => member.isHost,
);
const isHost = verified.isHost || !hasHost;
if (isHost) {
for (const member of Object.values(c.vars.members)) {
member.isHost = false;
}
}
c.vars.members[playerId] = {
connId: conn.id,
name: verified.playerName,
color: existing?.color ?? getPlayerColor(playerId),
isHost,
isReady: existing?.isReady ?? false,
};
c.vars.players.set(conn.id, verified);
await broadcastSnapshot(c);
await updatePartySize(c);
},
verifyJoin: async (
c,
input: { matchId: string; playerId: string; joinToken: string },
) => {
const [row] = 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`,
input.joinToken,
input.matchId,
input.playerId,
);
if (!row) {
return { allowed: false, isHost: false };
}
return {
allowed: true,
playerName: row.player_name,
isHost: row.host_player_id === input.playerId,
};
},
onBeforeConnectruns before the connection is accepted. It callsverifyJoinon the matchmaker with 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.- An accepted ticket’s name and host flag go into
c.vars.verifiedTickets, keyed by player id, becauseonBeforeConnectruns before the connection has an id.onConnectreads them back with theplayerIdfrom the connection params and stores the verified player inc.vars.players, keyed byconn.id.onDisconnectdeletes that entry. - Actions find the caller with
c.vars.players.get(c.conn.id), 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.onCreatesaves the party code from the input and thewaitingphase to a one-rowpartytable, so both survive sleep. Members and ready flags only live inc.vars, because they belong to live connections.- 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 ActionContextOf,
type ActorContextOf,
actor,
event,
UserError,
} from "rivetkit";
import { db } from "rivetkit/db";
import type { registry } from "../index.ts";
import { getPlayerColor } from "../player-color.ts";
import { MAX_PARTY_SIZE, type PartyPhase } from "./config.ts";
interface VerifiedPlayer {
playerId: string;
playerName: string;
isHost: boolean;
}
interface MemberEntry {
connId: string;
name: string;
color: string;
isHost: boolean;
isReady: boolean;
}
interface PartyVars {
// Connected members, keyed by player id.
members: Record<string, MemberEntry>;
// Tickets the matchmaker accepted, keyed by player id.
verifiedTickets: Map<string, VerifiedPlayer>;
// The verified player behind each live connection, keyed by connection id.
players: Map<string, VerifiedPlayer>;
}
export const partyMatch = actor({
options: { name: "Party - Match", icon: "people-group" },
events: {
partyUpdate: event<PartySnapshot>(),
},
db: db({
onMigrate: async (db) => {
await db.execute(`
CREATE TABLE IF NOT EXISTS party (
id INTEGER PRIMARY KEY CHECK (id = 1),
party_code TEXT NOT NULL,
phase TEXT NOT NULL
)
`);
},
}),
// The party code and phase are saved in SQLite, so they survive sleep.
onCreate: async (c, input: { partyCode: string }) => {
await c.db.execute(
"INSERT INTO party (id, party_code, phase) VALUES (1, ?, 'waiting')",
input.partyCode,
);
},
// Members belong to live connections, which end when the Actor stops, so
// they only live in memory.
createVars: (): PartyVars => ({
members: {},
verifiedTickets: new Map(),
players: new Map(),
}),
onBeforeConnect: async (
c,
params: { playerId?: string; joinToken?: string },
) => {
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 ticket = await client.partyMatchmaker
.getOrCreate(["main"])
.verifyJoin({ matchId, playerId, joinToken });
if (!ticket.allowed || !ticket.playerName) {
throw new UserError("invalid join ticket", {
code: "invalid_join_ticket",
});
}
c.vars.verifiedTickets.set(playerId, {
playerId,
playerName: ticket.playerName,
isHost: ticket.isHost,
});
},
onConnect: async (c, conn) => {
const playerId = conn.params?.playerId;
const verified = playerId
? c.vars.verifiedTickets.get(playerId)
: undefined;
if (!playerId || !verified) {
conn.disconnect("invalid_join_ticket");
return;
}
const existing = c.vars.members[playerId];
if (
!existing &&
Object.keys(c.vars.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.vars.members).some(
(member) => member.isHost,
);
const isHost = verified.isHost || !hasHost;
if (isHost) {
for (const member of Object.values(c.vars.members)) {
member.isHost = false;
}
}
c.vars.members[playerId] = {
connId: conn.id,
name: verified.playerName,
color: existing?.color ?? getPlayerColor(playerId),
isHost,
isReady: existing?.isReady ?? false,
};
c.vars.players.set(conn.id, verified);
await broadcastSnapshot(c);
await updatePartySize(c);
},
onDisconnect: async (c, conn) => {
const player = c.vars.players.get(conn.id);
if (!player) return;
c.vars.players.delete(conn.id);
const playerId = player.playerId;
const member = c.vars.members[playerId];
if (!member || member.connId !== conn.id) return;
const removedHost = member.isHost;
delete c.vars.members[playerId];
if (removedHost) {
promoteNextHost(c.vars);
}
await broadcastSnapshot(c);
await updatePartySize(c);
},
onDestroy: async (c) => {
const client = c.client<typeof registry>();
await client.partyMatchmaker
.getOrCreate(["main"])
.closeParty({ matchId: c.key[0]! });
},
actions: {
setName: async (c, input: { name: string }) => {
const member = getCallerMember(c);
if (!member || member.connId !== c.conn.id) {
throw new UserError("member not found", {
code: "member_not_found",
});
}
member.name = input.name.trim() || "Player";
await broadcastSnapshot(c);
},
toggleReady: async (c) => {
const member = getCallerMember(c);
if (!member || member.connId !== c.conn.id) {
throw new UserError("member not found", {
code: "member_not_found",
});
}
member.isReady = !member.isReady;
await broadcastSnapshot(c);
},
startGame: async (c) => {
const member = getCallerMember(c);
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",
});
}
// The UPDATE only matches in the expected phase, so a second click
// changes nothing.
const started = await c.db.execute(
"UPDATE party SET phase = 'playing' WHERE id = 1 AND phase = 'waiting' RETURNING phase",
);
if (started.length === 0) {
throw new UserError("game already started", {
code: "already_started",
});
}
await broadcastSnapshot(c);
},
finishGame: async (c) => {
const member = getCallerMember(c);
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",
});
}
const finished = await c.db.execute(
"UPDATE party SET phase = 'finished' WHERE id = 1 AND phase = 'playing' RETURNING phase",
);
if (finished.length === 0) {
throw new UserError("game not in progress", {
code: "not_playing",
});
}
await broadcastSnapshot(c);
},
getSnapshot: (c): Promise<PartySnapshot> => buildSnapshot(c),
},
});
interface PartySnapshot {
matchId: string;
partyCode: string;
phase: PartyPhase;
members: Record<
string,
{
name: string;
color: string;
isHost: boolean;
isReady: boolean;
connected: boolean;
}
>;
}
async function buildSnapshot(
c: ActorContextOf<typeof partyMatch>,
): Promise<PartySnapshot> {
const [party] = await c.db.execute<{
party_code: string;
phase: PartyPhase;
}>("SELECT party_code, phase FROM party WHERE id = 1");
const members: PartySnapshot["members"] = {};
for (const [id, entry] of Object.entries(c.vars.members)) {
members[id] = {
name: entry.name,
color: entry.color,
isHost: entry.isHost,
isReady: entry.isReady,
connected: true,
};
}
return {
matchId: c.key[0]!,
partyCode: party!.party_code,
phase: party!.phase,
members,
};
}
function getCallerMember(
c: ActionContextOf<typeof partyMatch>,
): MemberEntry | undefined {
const player = c.vars.players.get(c.conn.id);
return player ? c.vars.members[player.playerId] : undefined;
}
async function broadcastSnapshot(c: ActorContextOf<typeof partyMatch>) {
c.broadcast("partyUpdate", await buildSnapshot(c));
}
async function updatePartySize(c: ActorContextOf<typeof partyMatch>) {
const client = c.client<typeof registry>();
await client.partyMatchmaker.getOrCreate(["main"]).updatePartySize({
matchId: c.key[0]!,
playerCount: Object.keys(c.vars.members).length,
});
}
function promoteNextHost(vars: PartyVars) {
for (const member of Object.values(vars.members)) {
member.isHost = false;
}
const next = Object.values(vars.members)[0];
if (next) {
next.isHost = true;
next.isReady = false;
}
}
startGameandfinishGamethrownot_hostfor anyone but the host, and move the phase fromwaitingtoplayingtofinished. Each is oneUPDATEthat only matches the expected phase, so a second click changes nothing and 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 calls
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 the phase is playing. For a realtime mini-game, start a tick loop from onWake that only steps 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 {
// A one-off action needs no connection to the matchmaker.
const mm = client.partyMatchmaker.getOrCreate(["main"]);
const party = await mm.createParty({});
onReady(party);
} 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"]);
const ticket = await mm.joinParty({ partyCode: joinCode.trim() });
onReady({ ...ticket, 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.createParty({})andmm.joinParty({ partyCode })are action calls that return the ticket. 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 whatonBeforeConnectchecks. - 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:
onBeforeConnect, connection params, and per-connection data. - Authentication: replace join tickets with signed user tokens.