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@3.0.0-preview.1

Create the world

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

initialize: async (c, input: { playerName: string; playerId?: string }) => {
	if (c.state.initialized) {
		broadcastState(c);
		return;
	}
	c.state.playerName = input.playerName;
	const keyPlayerId = Array.isArray(c.key) ? c.key[0] : c.key;
	if (input.playerId) {
		c.state.playerId = input.playerId;
	} else if (typeof keyPlayerId === "string" && keyPlayerId) {
		c.state.playerId = keyPlayerId;
	}
	c.state.initialized = true;

	const farmType = BUILDINGS.find((b) => b.id === "farm")!;
	const building: BuildingEntry = {
		id: crypto.randomUUID(),
		typeId: farmType.id,
		builtAt: Date.now(),
		lastCollectedAt: Date.now(),
	};
	c.state.buildings.push(building);
	await scheduleCollection(c, building.id, farmType.productionIntervalMs);
	updateLeaderboard(c);
	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");
	}
	if (c.state.resources < buildingType.cost) {
		throw new Error("Not enough resources");
	}

	c.state.resources -= buildingType.cost;
	const building: BuildingEntry = {
		id: crypto.randomUUID(),
		typeId: buildingType.id,
		builtAt: Date.now(),
		lastCollectedAt: Date.now(),
	};
	c.state.buildings.push(building);
	await scheduleCollection(
		c,
		building.id,
		buildingType.productionIntervalMs,
	);
	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.
  • 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 state. 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.
  • 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 type { registry } from "../index.ts";
import { BUILDINGS, type BuildingType, STARTING_RESOURCES } from "./config.ts";

interface BuildingEntry {
	id: string;
	typeId: string;
	builtAt: number;
	lastCollectedAt: number;
}

interface State {
	playerId: string;
	playerName: string;
	resources: number;
	buildings: BuildingEntry[];
	totalProduced: number;
	initialized: boolean;
}

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>(),
	},
	state: {
		playerId: "",
		playerName: "",
		resources: STARTING_RESOURCES,
		buildings: [] as BuildingEntry[],
		totalProduced: 0,
		initialized: false as boolean,
	} satisfies State,
	actions: {
		initialize: async (c, input: { playerName: string; playerId?: string }) => {
			if (c.state.initialized) {
				broadcastState(c);
				return;
			}
			c.state.playerName = input.playerName;
			const keyPlayerId = Array.isArray(c.key) ? c.key[0] : c.key;
			if (input.playerId) {
				c.state.playerId = input.playerId;
			} else if (typeof keyPlayerId === "string" && keyPlayerId) {
				c.state.playerId = keyPlayerId;
			}
			c.state.initialized = true;

			const farmType = BUILDINGS.find((b) => b.id === "farm")!;
			const building: BuildingEntry = {
				id: crypto.randomUUID(),
				typeId: farmType.id,
				builtAt: Date.now(),
				lastCollectedAt: Date.now(),
			};
			c.state.buildings.push(building);
			await scheduleCollection(c, building.id, farmType.productionIntervalMs);
			updateLeaderboard(c);
			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");
			}
			if (c.state.resources < buildingType.cost) {
				throw new Error("Not enough resources");
			}

			c.state.resources -= buildingType.cost;
			const building: BuildingEntry = {
				id: crypto.randomUUID(),
				typeId: buildingType.id,
				builtAt: Date.now(),
				lastCollectedAt: Date.now(),
			};
			c.state.buildings.push(building);
			await scheduleCollection(
				c,
				building.id,
				buildingType.productionIntervalMs,
			);
			broadcastState(c);
		},
		collectProduction: (c, input: { buildingId: string }) => {
			const building = c.state.buildings.find(
				(b: BuildingEntry) => b.id === input.buildingId,
			);
			if (!building) return;

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

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

			const produced = intervals * buildingType.productionRate;
			c.state.resources += produced;
			c.state.totalProduced += produced;
			building.lastCollectedAt = now;

			updateLeaderboard(c);
			broadcastState(c);
		},
		getState: (c): 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}`,
		intervalMs,
		action: "collectProduction",
		args: [{ buildingId }],
		maxHistory: 0,
	});
}

function updateLeaderboard(c: ActorContextOf<typeof idleWorld>) {
	const client = c.client<typeof registry>();
	client.idleLeaderboard
		.getOrCreate(["main"])
		.send("updateScore", {
			playerId: c.state.playerId,
			playerName: c.state.playerName,
			totalProduced: c.state.totalProduced,
		})
		.catch(() => {});
}

function buildSnapshot(c: ActorContextOf<typeof idleWorld>): IdleSnapshot {
	return {
		playerId: c.state.playerId,
		playerName: c.state.playerName,
		resources: c.state.resources,
		totalProduced: c.state.totalProduced,
		buildings: c.state.buildings.map((b: BuildingEntry) => {
			const bType = BUILDINGS.find(
				(bt: BuildingType) => bt.id === b.typeId,
			) as BuildingType;
			return {
				id: b.id,
				typeId: b.typeId,
				name: bType.name,
				productionRate: bType.productionRate,
				productionIntervalMs: bType.productionIntervalMs,
				builtAt: b.builtAt,
				lastCollectedAt: b.lastCollectedAt,
			};
		}),
	};
}

function broadcastState(c: ActorContextOf<typeof idleWorld>) {
	c.broadcast("stateUpdate", 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 lastCollectedAt and pays for all of them, so a late run still pays the right amount.
  • It sets lastCollectedAt to now, which drops any partial interval. 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 sends an updateScore message to the leaderboard’s queue and does not wait for it. A slow leaderboard never delays a payout.
  • The leaderboard’s run loop handles one message at a time. Many worlds can report at the same moment, and each upsert into the scores table still runs in order.
  • 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.
  • State: how c.state is saved and restored.