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
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
worldtable for resources and totals plus abuildingstable, so progress survives sleep and restarts. Actions read and change those rows directly instead of keeping a copy in memory. initializeis safe to call on every visit. Theinitializedflag makes it a no-op after the first call, apart from sending the current snapshot. 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.- Actions can run at the same time. Each change runs in one
c.db.transactionand updates columns relative to their saved value, such asresources = resources - ?withWHERE resources >= ?, so two builds at once cannot spend the same resources. - 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 { 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.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 sincelast_collected_atand pays for all of them, so a late run still pays the right amount.- It sets
last_collected_atto now in the same transaction as the payout, which drops any partial interval and keeps two overlapping runs from paying twice. 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>,
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 });
});
}
import { type ActorContextOf, actor, event } 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,
}),
events: {
leaderboardUpdate: event<LeaderboardEntry[]>(),
},
actions: {
updateScore: async (
c,
input: {
playerId: string;
playerName: string;
totalProduced: number;
},
) => {
// Worlds do not wait for this call, so two reports from one world
// can arrive out of order. The total only grows, so an older report
// never replaces a newer one.
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 = excluded.player_name, total_produced = excluded.total_produced, updated_at = excluded.updated_at
WHERE excluded.total_produced >= scores.total_produced`,
input.playerId,
input.playerName,
input.totalProduced,
Date.now(),
);
const top = await getTop(c, 10);
c.broadcast("leaderboardUpdate", top);
},
getTopScores: async (c, input: { limit?: number }) => {
return await getTop(c, input.limit ?? 10);
},
},
});
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)`,
);
}
updateLeaderboardcalls the leaderboard’supdateScoreaction 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
scorestable, 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_producedis at least the saved one. - 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.
- SQLite: store durable data in each Actor’s own database.