Skip to main content
Games

Idle Game

Build an idle game like Cookie Clicker, where each player's world Actor keeps producing resources while they are offline and a shared leaderboard ranks everyone.

See the full example on GitHub. View on GitHub
Playerbrowser clientidleWorldresources and buildingsc.cron.everyone job per buildingidleLeaderboardscores in SQLiteinitialize, build1schedule2collectProduction3updateScore4stateUpdate5leaderboardUpdate6One per playerRivet ActorCallReply

What we’ll build:

  • An idleWorld Actor per player that holds their resources, buildings, and total production.
  • A fixed-interval job per building that pays out production, even while the player is offline.
  • Catch-up from wall-clock time, so a payout that runs late still pays for every interval that passed.
  • An idleLeaderboard Actor that ranks players by total production and pushes changes live.

Install

npm add rivetkit

Create the world

Each player gets an idleWorld Actor, keyed by their player id. Its SQLite database is the whole save game:

initialize: async (c, input: { playerName: string; playerId?: string }) => {
	const keyPlayerId = Array.isArray(c.key) ? c.key[0] : c.key;
	const playerId = input.playerId || keyPlayerId || "";
	const farmType = BUILDINGS.find((b) => b.id === "farm")!;
	const farmId = crypto.randomUUID();
	const now = Date.now();

	// Set the initialized flag and add the free farm in one transaction,
	// so only the first call creates a farm.
	const world = await c.db.transaction(
		async (tx) => {
			const [claimed] = await tx.execute<ScoreRow>(
				"UPDATE world SET player_id = ?, player_name = ?, initialized = 1 WHERE id = 1 AND initialized = 0 RETURNING player_id, player_name, total_produced",
				playerId,
				input.playerName,
			);
			if (!claimed) return null;

			await tx.execute(
				"INSERT INTO buildings (id, type_id, built_at, last_collected_at) VALUES (?, ?, ?, ?)",
				farmId,
				farmType.id,
				now,
				now,
			);
			return claimed;
		},
		{ name: "initialize-world" },
	);
	if (!world) {
		await broadcastState(c);
		return;
	}

	await scheduleCollection(c, farmId, farmType.productionIntervalMs);
	updateLeaderboard(c, world);
	await broadcastState(c);
},
build: async (c, input: { buildingTypeId: string }) => {
	const buildingType = BUILDINGS.find(
		(b: BuildingType) => b.id === input.buildingTypeId,
	);
	if (!buildingType) {
		throw new Error("Unknown building type");
	}

	const buildingId = crypto.randomUUID();
	const now = Date.now();

	// Spend the cost and add the building in one transaction. The UPDATE
	// only matches when the player can afford the building, so two builds
	// at once cannot spend the same resources.
	await c.db.transaction(
		async (tx) => {
			const [paid] = await tx.execute(
				"UPDATE world SET resources = resources - ? WHERE id = 1 AND resources >= ? RETURNING resources",
				buildingType.cost,
				buildingType.cost,
			);
			if (!paid) {
				throw new Error("Not enough resources");
			}

			await tx.execute(
				"INSERT INTO buildings (id, type_id, built_at, last_collected_at) VALUES (?, ?, ?, ?)",
				buildingId,
				buildingType.id,
				now,
				now,
			);
		},
		{ name: "add-building" },
	);

	await scheduleCollection(
		c,
		buildingId,
		buildingType.productionIntervalMs,
	);
	await broadcastState(c);
},
  • There is no matchmaker. The client calls idleWorld.getOrCreate([playerId]), so the first visit creates the world and every later visit reaches the same one.
  • The save game is a one-row world table for resources and totals plus a buildings table, so progress survives sleep and restarts. Actions read and change those rows directly instead of keeping a copy in memory.
  • initialize is safe to call on every visit. The initialized flag makes it a no-op after the first call, apart from sending the current snapshot. The first call gives the player a free farm.
  • build looks the building up in BUILDINGS and checks the cost on the server, so a client cannot build something it cannot afford. Resources, production rates, and totals never come from the client.
  • Actions can run at the same time. Each change runs in one c.db.transaction and updates columns relative to their saved value, such as resources = resources - ? with WHERE resources >= ?, so two builds at once cannot spend the same resources.
  • Every change ends with broadcastState, which sends a stateUpdate with the full snapshot to every open tab.

build throws a plain Error. Clients only get a generic internal error for those. To show “Not enough resources” in the UI, throw a UserError with a code instead.

Produce while offline

An idle game has no tick loop. Each building gets its own fixed-interval job that calls collectProduction:

import { type ActorContextOf, actor, event } from "rivetkit";
import { db } from "rivetkit/db";
import type { registry } from "../index.ts";
import { BUILDINGS, type BuildingType, STARTING_RESOURCES } from "./config.ts";

// The world columns that the leaderboard ranks.
type ScoreRow = {
	player_id: string;
	player_name: string;
	total_produced: number;
};

interface IdleSnapshot {
	playerId: string;
	playerName: string;
	resources: number;
	totalProduced: number;
	buildings: Array<{
		id: string;
		typeId: string;
		name: string;
		productionRate: number;
		productionIntervalMs: number;
		builtAt: number;
		lastCollectedAt: number;
	}>;
}

export const idleWorld = actor({
	options: { name: "Idle - World", icon: "industry" },
	events: {
		stateUpdate: event<IdleSnapshot>(),
	},
	db: db({
		onMigrate: async (db) => {
			await db.execute(`
				CREATE TABLE IF NOT EXISTS world (
					id INTEGER PRIMARY KEY CHECK (id = 1),
					player_id TEXT NOT NULL,
					player_name TEXT NOT NULL,
					resources INTEGER NOT NULL,
					total_produced INTEGER NOT NULL,
					initialized INTEGER NOT NULL
				)
			`);
			await db.execute(`
				CREATE TABLE IF NOT EXISTS buildings (
					id TEXT PRIMARY KEY,
					type_id TEXT NOT NULL,
					built_at INTEGER NOT NULL,
					last_collected_at INTEGER NOT NULL
				)
			`);
			await db.execute(
				"INSERT OR IGNORE INTO world (id, player_id, player_name, resources, total_produced, initialized) VALUES (1, '', '', ?, 0, 0)",
				STARTING_RESOURCES,
			);
		},
	}),
	actions: {
		initialize: async (c, input: { playerName: string; playerId?: string }) => {
			const keyPlayerId = Array.isArray(c.key) ? c.key[0] : c.key;
			const playerId = input.playerId || keyPlayerId || "";
			const farmType = BUILDINGS.find((b) => b.id === "farm")!;
			const farmId = crypto.randomUUID();
			const now = Date.now();

			// Set the initialized flag and add the free farm in one transaction,
			// so only the first call creates a farm.
			const world = await c.db.transaction(
				async (tx) => {
					const [claimed] = await tx.execute<ScoreRow>(
						"UPDATE world SET player_id = ?, player_name = ?, initialized = 1 WHERE id = 1 AND initialized = 0 RETURNING player_id, player_name, total_produced",
						playerId,
						input.playerName,
					);
					if (!claimed) return null;

					await tx.execute(
						"INSERT INTO buildings (id, type_id, built_at, last_collected_at) VALUES (?, ?, ?, ?)",
						farmId,
						farmType.id,
						now,
						now,
					);
					return claimed;
				},
				{ name: "initialize-world" },
			);
			if (!world) {
				await broadcastState(c);
				return;
			}

			await scheduleCollection(c, farmId, farmType.productionIntervalMs);
			updateLeaderboard(c, world);
			await broadcastState(c);
		},
		build: async (c, input: { buildingTypeId: string }) => {
			const buildingType = BUILDINGS.find(
				(b: BuildingType) => b.id === input.buildingTypeId,
			);
			if (!buildingType) {
				throw new Error("Unknown building type");
			}

			const buildingId = crypto.randomUUID();
			const now = Date.now();

			// Spend the cost and add the building in one transaction. The UPDATE
			// only matches when the player can afford the building, so two builds
			// at once cannot spend the same resources.
			await c.db.transaction(
				async (tx) => {
					const [paid] = await tx.execute(
						"UPDATE world SET resources = resources - ? WHERE id = 1 AND resources >= ? RETURNING resources",
						buildingType.cost,
						buildingType.cost,
					);
					if (!paid) {
						throw new Error("Not enough resources");
					}

					await tx.execute(
						"INSERT INTO buildings (id, type_id, built_at, last_collected_at) VALUES (?, ?, ?, ?)",
						buildingId,
						buildingType.id,
						now,
						now,
					);
				},
				{ name: "add-building" },
			);

			await scheduleCollection(
				c,
				buildingId,
				buildingType.productionIntervalMs,
			);
			await broadcastState(c);
		},
		collectProduction: async (c, input: { buildingId: string }) => {
			// Read the building and pay out in one transaction, so two runs at
			// once cannot both pay for the same intervals.
			const world = await c.db.transaction(
				async (tx) => {
					const [building] = await tx.execute<{
						type_id: string;
						last_collected_at: number;
					}>(
						"SELECT type_id, last_collected_at FROM buildings WHERE id = ?",
						input.buildingId,
					);
					if (!building) return null;

					const buildingType = BUILDINGS.find(
						(b: BuildingType) => b.id === building.type_id,
					);
					if (!buildingType) return null;

					const now = Date.now();
					const elapsed = now - building.last_collected_at;
					const intervals = Math.floor(
						elapsed / buildingType.productionIntervalMs,
					);
					if (intervals <= 0) return null;

					const produced = intervals * buildingType.productionRate;
					await tx.execute(
						"UPDATE buildings SET last_collected_at = ? WHERE id = ?",
						now,
						input.buildingId,
					);
					const [updated] = await tx.execute<ScoreRow>(
						"UPDATE world SET resources = resources + ?, total_produced = total_produced + ? WHERE id = 1 RETURNING player_id, player_name, total_produced",
						produced,
						produced,
					);
					return updated;
				},
				{ name: "collect-production" },
			);
			if (!world) return;

			updateLeaderboard(c, world);
			await broadcastState(c);
		},
		getState: (c): Promise<IdleSnapshot> => buildSnapshot(c),
		getLeaderboard: async (c) => {
			const client = c.client<typeof registry>();
			return await client.idleLeaderboard
				.getOrCreate(["main"])
				.getTopScores({ limit: 10 });
		},
	},
});

async function scheduleCollection(
	c: ActorContextOf<typeof idleWorld>,
	buildingId: string,
	intervalMs: number,
): Promise<void> {
	await c.cron.every({
		name: `collect-production:${buildingId}`,
		interval: intervalMs,
		action: "collectProduction",
		args: [{ buildingId }],
		maxHistory: 0,
	});
}

function updateLeaderboard(
	c: ActorContextOf<typeof idleWorld>,
	world: ScoreRow,
) {
	// Do not wait for the leaderboard, so a slow leaderboard never delays a
	// payout. A failed report is fixed by the next payout's total.
	const client = c.client<typeof registry>();
	client.idleLeaderboard
		.getOrCreate(["main"])
		.updateScore({
			playerId: world.player_id,
			playerName: world.player_name,
			totalProduced: world.total_produced,
		})
		.catch((error) => {
			c.log.warn({ msg: "leaderboard update failed", error });
		});
}

async function buildSnapshot(
	c: ActorContextOf<typeof idleWorld>,
): Promise<IdleSnapshot> {
	const [world] = await c.db.execute<{
		player_id: string;
		player_name: string;
		resources: number;
		total_produced: number;
	}>(
		"SELECT player_id, player_name, resources, total_produced FROM world WHERE id = 1",
	);
	const buildings = await c.db.execute<{
		id: string;
		type_id: string;
		built_at: number;
		last_collected_at: number;
	}>(
		"SELECT id, type_id, built_at, last_collected_at FROM buildings ORDER BY built_at",
	);
	return {
		playerId: world!.player_id,
		playerName: world!.player_name,
		resources: world!.resources,
		totalProduced: world!.total_produced,
		buildings: buildings.map((b) => {
			const bType = BUILDINGS.find(
				(bt: BuildingType) => bt.id === b.type_id,
			) as BuildingType;
			return {
				id: b.id,
				typeId: b.type_id,
				name: bType.name,
				productionRate: bType.productionRate,
				productionIntervalMs: bType.productionIntervalMs,
				builtAt: b.built_at,
				lastCollectedAt: b.last_collected_at,
			};
		}),
	};
}

async function broadcastState(c: ActorContextOf<typeof idleWorld>) {
	c.broadcast("stateUpdate", await buildSnapshot(c));
}
  • c.cron.every registers a job named collect-production:<buildingId> that runs at the building’s productionIntervalMs. Schedules are stored with the Actor, so they keep firing after the player closes the tab. A schedule wakes the Actor if it is asleep.
  • collectProduction does not assume it ran on time. It counts how many whole intervals passed since last_collected_at and pays for all of them, so a late run still pays the right amount.
  • It sets last_collected_at to now in the same transaction as the payout, which drops any partial interval and keeps two overlapping runs from paying twice. Advance it by intervals * productionIntervalMs instead if you want to keep the remainder.
  • maxHistory: 0 turns off run history, so jobs that never end do not pile up records.

The farm pays every 10 seconds, so the example wakes the world every 10 seconds per farm. For a real game, use coarse intervals such as 5 to 15 minutes and let the elapsed-time math pay for everything in between. You can also run the same catch-up in initialize so a returning player sees their earnings right away.

Add the leaderboard

One idleLeaderboard Actor, keyed ["main"], ranks every player. The world reports its total after each payout:

  • updateLeaderboard calls the leaderboard’s updateScore action and does not wait for it, so a slow leaderboard never delays a payout. A failed call is logged, and the next payout reports the new total.
  • Many worlds can report at the same moment, and actions run at the same time. Each report is a single upsert into the scores table, so two reports can’t overwrite each other halfway.
  • Because the world does not wait, two reports from one world can arrive out of order. The total only grows, so the upsert only replaces a row when the new total_produced is at least the saved one.
  • The score is totalProduced, which only the world computes. The scores_rank_idx index keeps ORDER BY total_produced DESC fast as the table grows.
  • After each upsert, the leaderboard broadcasts the top 10 as leaderboardUpdate to everyone connected.

Register the Actors

Add both Actors to the registry and start it:

The example registers every game type in one registry. An idle game only needs idleWorld and idleLeaderboard. Each Actor file imports type { registry } so that c.client<typeof registry>() is typed when the world calls the leaderboard.

Connect the player

The game screen connects to the player’s world and to the leaderboard:

useEffect(() => {
	const world = client.idleWorld
		.getOrCreate([matchInfo.playerId])
		.connect();
	worldRef.current = world;

	world.on("stateUpdate", (snap) => {
		setState(snap);
	});

	// Initialize the actor.
	world
		.initialize({
			playerName: matchInfo.playerName,
			playerId: matchInfo.playerId,
		})
		.then(() => {
			return world.getState();
		})
		.then((snap) => {
			setState(snap);
		});

	// Fetch initial leaderboard.
	world.getLeaderboard().then((scores) => {
		setLeaderboard(scores);
	});

	// Connect to leaderboard for live updates.
	const lb = client.idleLeaderboard.getOrCreate(["main"]).connect();
	lbRef.current = lb;

	lb.on("leaderboardUpdate", (scores) => {
		setLeaderboard(scores);
	});

	return () => {
		cleanup();
	};
}, [client, matchInfo, cleanup]);

// Timer for countdown display.
useEffect(() => {
	const timer = setInterval(() => setNow(Date.now()), 1000);
	return () => clearInterval(timer);
}, []);

const buildBuilding = (typeId: string) => {
	worldRef.current?.build({ buildingTypeId: typeId }).catch(() => {});
};
  • The client listens for stateUpdate before it calls initialize, so it does not miss the first snapshot. It then calls getState once to be sure it has one.
  • It reads the first leaderboard through the world’s getLeaderboard action, then connects to idleLeaderboard directly for leaderboardUpdate events.
  • The countdown to the next payout runs in the browser from lastCollectedAt and productionIntervalMs. The server never sends timer ticks.
  • The menu creates a new random player id each time you click Start Playing, so each session starts a new world. To bring a player back to the same world, store the id, for example in localStorage, or key the world by the user id from your sign-in.

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 Idle, and click Start Playing. You start with 10 resources and a farm that pays 5 every 10 seconds. Build a mine once you have 50. Your name shows up on the leaderboard right away and climbs with each payout. Open a second tab and start another player to see both on the leaderboard.

Next steps

  • Schedule & Cron: one-shot, cron, and fixed-interval jobs that survive sleep.
  • Ranked Matchmaking: a per-player rating Actor and a leaderboard fed by matches.
  • SQLite: store durable data in each Actor’s own database.
Edit this page Last updated November 10, 2026