Skip to main content
Games

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.

See the full example on GitHub. View on GitHub
Game clientbrowser lobbypartyMatchmakerone, keyed mainMatchmaker SQLiteparties, join_ticketspartyMatchmembers and hostcreateParty, joinParty1create2matchId, joinToken3connect with joinToken4verifyJoin5c.db.executetoggleReady, startGame6partyUpdate events7One per matchRivet ActorCallReply

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

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:

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:

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

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.

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:

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:

  • 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

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.