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.
What we’ll build:
- An
idleWorldActor 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
idleLeaderboardActor 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. initializeis safe to call on every visit. Theinitializedflag makes it a no-op after the first call, apart from sending the current state. The first call gives the player a free farm.buildlooks the building up inBUILDINGSand 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 astateUpdatewith 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.everyregisters a job namedcollect-production:<buildingId>that runs at the building’sproductionIntervalMs. 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.collectProductiondoes not assume it ran on time. It counts how many whole intervals passed sincelastCollectedAtand pays for all of them, so a late run still pays the right amount.- It sets
lastCollectedAtto now, which drops any partial interval. Advance it byintervals * productionIntervalMsinstead if you want to keep the remainder. maxHistory: 0turns 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:
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(() => {});
}
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)`,
);
}
updateLeaderboardsends anupdateScoremessage to the leaderboard’s queue and does not wait for it. A slow leaderboard never delays a payout.- The leaderboard’s
runloop handles one message at a time. Many worlds can report at the same moment, and each upsert into thescorestable still runs in order. - The score is
totalProduced, which only the world computes. Thescores_rank_idxindex keepsORDER BY total_produced DESCfast as the table grows. - After each upsert, the leaderboard broadcasts the top 10 as
leaderboardUpdateto everyone connected.
Register the Actors
Add both Actors to the registry and start it:
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,
},
});
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
stateUpdatebefore it callsinitialize, so it does not miss the first snapshot. It then callsgetStateonce to be sure it has one. - It reads the first leaderboard through the world’s
getLeaderboardaction, then connects toidleLeaderboarddirectly forleaderboardUpdateevents. - The countdown to the next payout runs in the browser from
lastCollectedAtandproductionIntervalMs. 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.stateis saved and restored.