# Idle Game

The player's browser calls initialize and build on the idleWorld Actor for that player. Each building registers a fixed-interval job with c.cron.every. The job calls collectProduction on the world when it is due, even if the player is offline and the Actor is asleep. The world sends updateScore to the idleLeaderboard Actor and broadcasts stateUpdate to the browser. The leaderboard broadcasts leaderboardUpdate to every connected player. The numbers show the order of the calls. There is one idleWorld Actor per player.

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

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

### Create the world

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

world.ts:

```ts
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.

> **NOTE:** `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`](/actors/docs/errors) 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`:

world.ts:

```ts
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](/actors/docs/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.

> **NOTE:** 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:

world.ts:

```ts
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(() => {});
}
```

leaderboard.ts:

```ts
import { type ActorContextOf, actor, event, queue } from "rivetkit";
import { db, type RawAccess } from "rivetkit/db";

export interface LeaderboardEntry {
	playerId: string;
	playerName: string;
	totalProduced: number;
}

export const idleLeaderboard = actor({
	options: { name: "Idle - Leaderboard", icon: "industry" },
	db: db({
		onMigrate: migrateTables,
	}),
	queues: {
		updateScore: queue<{
			playerId: string;
			playerName: string;
			totalProduced: number;
		}>(),
	},
	events: {
		leaderboardUpdate: event<LeaderboardEntry[]>(),
	},
	actions: {
		getTopScores: async (c, input: { limit?: number }) => {
			return await getTop(c, input.limit ?? 10);
		},
	},
	run: async (c) => {
		for await (const message of c.queue.iter()) {
			if (message.name !== "updateScore") continue;

			await c.db.execute(
				`INSERT INTO scores (player_id, player_name, total_produced, updated_at) VALUES (?, ?, ?, ?)
					 ON CONFLICT(player_id) DO UPDATE SET player_name = ?, total_produced = ?, updated_at = ?`,
				message.body.playerId,
				message.body.playerName,
				message.body.totalProduced,
				Date.now(),
				message.body.playerName,
				message.body.totalProduced,
				Date.now(),
			);

			const top = await getTop(c, 10);
			c.broadcast("leaderboardUpdate", top);
		}
	},
});

async function getTop(
	c: ActorContextOf<typeof idleLeaderboard>,
	limit: number,
): Promise<LeaderboardEntry[]> {
	const rows = await c.db.execute<{
		player_id: string;
		player_name: string;
		total_produced: number;
	}>(
		`SELECT player_id, player_name, total_produced FROM scores ORDER BY total_produced DESC LIMIT ?`,
		limit,
	);
	return rows.map((r) => ({
		playerId: r.player_id,
		playerName: r.player_name,
		totalProduced: r.total_produced,
	}));
}

async function migrateTables(dbHandle: RawAccess) {
	await dbHandle.execute(`
		CREATE TABLE IF NOT EXISTS scores (
			player_id TEXT PRIMARY KEY,
			player_name TEXT NOT NULL,
			total_produced INTEGER NOT NULL DEFAULT 0,
			updated_at INTEGER NOT NULL
		)
	`);
	await dbHandle.execute(
		`CREATE INDEX IF NOT EXISTS scores_rank_idx ON scores (total_produced DESC)`,
	);
}
```

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

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

game.tsx:

```tsx
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

```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 **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](/actors/docs/schedule): one-shot, cron, and fixed-interval jobs that survive sleep.
- [Ranked Matchmaking](/guides/ranked-game): a per-player rating Actor and a leaderboard fed by matches.
- [State](/actors/docs/state): how `c.state` is saved and restored.
