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:

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, and closeParty.
  • createParty creates the partyMatch Actor with the party code as input, saves a parties row and the host’s ticket in one transaction, 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.
  • Actions run at the same time, so joinParty checks the size, counts the player, and saves the ticket in one c.db.transaction. Two friends joining the last open slot at once can’t both get in.
  • 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:

  • onBeforeConnect runs before the connection is accepted. It calls verifyJoin on the matchmaker 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.
  • An accepted ticket’s name and host flag go into c.vars.verifiedTickets, keyed by player id, because onBeforeConnect runs before the connection has an id. onConnect reads them back with the playerId from the connection params and stores the verified player in c.vars.players, keyed by conn.id. onDisconnect deletes 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.
  • 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.
  • onCreate saves the party code from the input and the waiting phase to a one-row party table, so both survive sleep. Members and ready flags only live in c.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;
	}
}
  • startGame and finishGame throw not_host for anyone but the host, and move the phase from waiting to playing to finished. Each is one UPDATE that only matches the expected phase, so a second click changes nothing and 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 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:

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.createParty({}) and mm.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 both playerId and joinToken in params, which is what onBeforeConnect 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: onBeforeConnect, connection params, and per-connection data.
  • Authentication: replace join tickets with signed user tokens.
Edit this page Last updated November 10, 2026