# Party Game

The host's game client sends createParty to the partyMatchmaker Actor, and friends send joinParty with the party code. The matchmaker keeps parties and join tickets in its SQLite tables. For createParty, it creates a partyMatch Actor. It replies with a matchId and a joinToken. The client connects to the match with its joinToken, and the match checks the ticket by sending verifyJoin to the matchmaker. Members then call toggleReady, and the host calls startGame. The match broadcasts the partyUpdate event to every member after each change. The numbers show the order of the calls. There is one matchmaker and one partyMatch Actor per match.

What we'll build:

- A `partyMatchmaker` Actor 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 `partyMatch` Actor 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

```sh
npm add rivetkit@next
```

### Create the matchmaker

One `partyMatchmaker` Actor, keyed `["main"]`, creates parties and hands out join tickets. Parties and tickets live in its SQLite database:

matchmaker.ts:

```ts
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 `run` handles one message at a time. Two friends joining the last open slot at once can't both get in. `createParty`, `joinParty`, `verifyJoin`, `updatePartySize`, and `closeParty` are all queues.
- `createParty` creates the `partyMatch` Actor with the party code and the host's player id, saves a `parties` row, and returns a `joinToken` along with the code. Codes leave out I, O, 0, and 1 so they are easy to read aloud, and the `party_code` column is `UNIQUE`.
- `joinParty` uppercases and trims the code, rejects an unknown code with `party_not_found` and a full party with `party_full` (`MAX_PARTY_SIZE` is 8), and issues a new ticket.
- `joinParty` counts the player as soon as it issues the ticket, so the slot is held while they connect. The party corrects the count later with `updatePartySize`.

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

match.ts:

```ts
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);
},
```

matchmaker.ts:

```ts
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,
});
```

- `createConnState` runs before the connection is accepted. It sends `verifyJoin` with the match id from `c.key[0]`, the `playerId`, and the `joinToken`, and throws `invalid_join_ticket` if 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.
- `onConnect` checks the size limit again and turns away a second connection for the same player with `duplicate_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`:

match.ts:

```ts
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;
	}
}
```

- `startGame` and `finishGame` throw `not_host` for anyone but the host, and check the phase: `waiting` to `playing` to `finished`. A second click gets `already_started` or `not_playing`.
- `toggleReady` flips the member's ready flag. The example lets the host start without everyone ready. Add a check in `startGame` if your game needs it.
- When a member leaves, `onDisconnect` removes them. If it was the host, `promoteNextHost` makes 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.

> **NOTE:** 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](/guides/arena-game) and send every member its id.

### Register the Actors

Add both Actors to the registry and start it:

actors/index.ts:

```ts
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,
	},
});
```

index.ts:

```ts
import { registry } from "./actors/index.ts";

export { registry };

registry.start();
```

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:

menu.tsx:

```tsx
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}>
				&larr; 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>
	);
}
```

game.tsx:

```tsx
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 })` and `mm.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 both `playerId` and `joinToken` in `params`, which is what `createConnState` checks.
- It subscribes to `partyUpdate` and also calls `getSnapshot` once, so the member list shows up even if no change has happened since it connected.
- The buttons call `toggleReady`, `startGame`, and `finishGame`. The UI only shows **Start Game** to the host, but the server's `not_host` check is what enforces it.

### Try it

```sh
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](/guides/arena-game): run a realtime match with teams and a tick loop.
- [Connections](/actors/docs/connections): `createConnState`, connection params, and per-connection state.
- [Authentication](/guides/authentication): replace join tickets with signed user tokens.
