← Dev Log
The tick engine behind Inselnova: one loop, no resource writes

The tick engine behind Inselnova: one loop, no resource writes

game architecturetick systembrowser strategy gamebackend engineeringpersistent world browser strategy game

This dev log is about Inselnova: one island, sea wars and alliance politics in a world that never resets. Play free →

TL;DR: Inselnova runs on a single one-second loop and a sorted list of things that are due. Resources like wood, stone and food are never written to the database on a timer. They’re computed when someone actually looks, from the last snapshot plus the rate times the hours that have passed. An island nobody has touched for a month causes zero writes. And the timers that fire when a build finishes or a fleet lands don’t carry the game state with them, they pull fresh state from the database when they run, so nothing acts on stale numbers. This is what lets a persistent world browser strategy game keep hundreds of islands ticking along without the server doing constant work.

The one rule I started with

I wanted a persistent world where things happen in real time, builds finish, fleets travel, research completes, all while you’re offline. The obvious way to build that is a background job that runs every few seconds and adds resources to every island. That’s also the way that falls over. You’re doing work for every player whether or not anything is happening to them, and most of the time nothing is.

So the rule I started with was the opposite. Do no work unless something actually happens. An island that’s just sitting there producing wood shouldn’t cost the server a single write. The wood is still accruing, you just don’t find out the exact number until you ask.

Resources are computed, not stored

Every island stores a snapshot of its resources and a timestamp for when that snapshot was taken. When you need the current numbers, the game takes the snapshot, works out how many hours have passed, and adds the production for that time. That’s the whole model. The header comment on the calculator says it plainly:

This design means we never write resources on a timer. A player who
doesn't log in for a month causes zero DB writes until something
triggers materialization (login, attack, spy, trade, etc.).

The maths is snapshot plus rate times elapsed, clamped to the storage cap. From shared/resource_calculator.ts:

const rate = Math.round(baseRate * happinessModifier * taxMult * specMult * 100) / 100;
rates[resource] = (rates[resource] ?? 0) + rate;
gains[resource] = (gains[resource] ?? 0) + rate * elapsedHours;   // rate x elapsed
// ...
resources[res] = Math.min(cap, current + (gains[res] ?? 0));

Elapsed time is just now minus the last update, in hours (shared/clock.ts):

export function elapsedHours(since: string): number {
  const sinceDate = new Date(since + (since.endsWith('Z') ? '' : 'Z'));
  return Math.max(0, (now() - sinceDate.getTime()) / (1000 * 60 * 60));
}

The counter you see ticking up in the browser isn’t the server writing every second. The API hands the client the per-hour rate and the client extrapolates on screen. The real number only gets pinned down when something forces it.

The write only happens when you touch the island

When you do log in, attack someone, or trade, the game materialises the affected island: it computes the current resources and writes them back with a fresh timestamp. That reset-the-clock write happens once, and only if something actually changed (services/materialize/materialize_resources.ts):

if (opts.persist) {
  const needsWrite = opts.elapsedHours > 0 || Object.keys(currentResources).some(
    (res) => (currentResources[res] ?? 0) !== (snapshot.resources[res] ?? 0),
  );
  if (needsWrite) {
    await placeRepo.setResources(place.id, snapshot.resources);
  }

So the precise version of “I never write production to the database” is this. Never on a schedule. Never for an island nobody has touched. At most once per action, on the island that action touched. There’s also a read-only path used for previews and aggregate reads that computes the same numbers with persist: false, so counting or displaying resources across the whole world writes nothing at all.

The tick engine is one loop

There’s no cron, no queue, no Redis, no external scheduler. It’s one setInterval running once a second, started once when the server boots. Every action that takes time writes an absolute completion timestamp to the database, and the engine keeps an in-memory list of what’s due, sorted by time. The file’s own header explains the shape:

Every game action that takes time (building, research, training, travel,
attacks) stores an absolute completion timestamp in the database. The
tick engine runs every 1 second and checks its in-memory event queue
for anything that's due. This avoids scanning the database every tick.

The loop itself is tiny. It drains anything that’s due, and every 30 ticks it reconciles with the database as a safety net. It never touches resources (services/tick/tick_engine.ts):

async function runTick(): Promise<void> {
  if (tickInProgress) return; // skip if previous tick hasn't finished
  tickInProgress = true;
  try {
    if (scheduler.hasEventsDue()) {
      await processEventQueue();
    }
    // Resources are materialized on demand — no periodic writes needed.
    totalTicksProcessed++;
    if (totalTicksProcessed % RECONCILE_INTERVAL_TICKS === 0) {
      await reconcileWithDB();
    }

Most seconds, hasEventsDue() is a single comparison against the front of a sorted array and the loop does nothing. The work is proportional to how many things are due this second, not how many players exist. An idle world is almost free.

Because every completion time is an absolute timestamp, crash recovery is automatic. On boot the engine loads all pending events from the database and immediately processes anything already overdue. Nothing is lost if the process restarts, the timestamps are the source of truth, not the in-memory list.

The tick engine is only half the server

I should be clear that this loop isn’t the whole backend. The same process is also an ordinary API server, handling requests from the client all day: log in, queue a build, launch a fleet, send a trade, read a panel. That’s the side players actually touch. The tick engine runs alongside it, resolving the things that were set in motion earlier.

The two halves share the same trick. When a request comes in, the controller pulls the affected island up to now before it does anything, exactly the way a timer pulls fresh state when it fires. So a build you queue and a fleet that lands are both acting on resources computed at the moment they run, not on a stale number written earlier. The request side handles what you’re doing right now, the tick side handles what’s already due, and neither trusts a cached copy of the world.

Timers pull fresh state, they don’t carry it

This is the part I’d argue about with other developers. A scheduled event in Inselnova carries almost nothing: a type, an id, a due time, and which place it belongs to. It does not carry a snapshot of the game state. When the timer fires, the handler goes and reads the current state from the database.

Take an attack landing. The tick doesn’t hand the handler a frozen copy of the battle from when the fleet launched. It just triggers a sweep, and the sweep queries for every attack that has actually arrived (services/attack/attack_service.ts):

const arrivedAttacks = await attackRepo.getArrivedUnresolvedAttacks();
for (const attack of arrivedAttacks) {
  if (await attackRepo.isCancelled(attack.id)) continue;
  await resolveAttackBattle(attack);

And when the battle resolves, it materialises the defender’s resources up to the moment of arrival before working out the plunder (services/attack/attack_launch.ts):

await placeService.materializeResources(placeId);   // bring target up to "now"
const targetResources = await placeRepo.getResources(placeId);

That has a nice consequence that falls straight out of the design. A fleet that launched three hours ago plunders the resources the island has when the fleet arrives, including everything it produced mid-flight. I never had to write code to “keep producing during the attack”. Production is derived from elapsed time, so it’s just there.

The reason I went this way is staleness. An event that carries a payload is a photograph of the world taken when the event was scheduled. By the time it fires, the photo can be wrong: the player cancelled, got attacked by someone else, spent the resources. Pulling fresh state at fire time means the handler always acts on what’s true now, not what was true when the timer was set.

It also makes the handlers safe to run twice. Because every handler re-reads state and gates its write behind an atomic claim, a duplicate or replayed timer just no-ops instead of crediting a build twice or resolving a battle twice. There’s a comment in the attack service about a stale-payload double-resolve bug this design killed off. That class of bug mostly can’t happen when the event carries no state.

The event-based parts I did keep

It’s not pure timers. There are a couple of genuinely event-driven pieces, and here’s where.

  • World events (conflict, hold, flag, the tide) are durable rows with their own config and status, driven by the same scheduler. That’s real event-mode game content, not a message bus.
  • Achievements are the one place that looks like classic events-with-a-payload. When a build finishes, the tick fires a signal like building_completed with the ids of what happened, and the achievement service reacts. The difference that matters: that payload describes a fact that already happened and can’t change, not a snapshot of mutable state. A fact doesn’t go stale. A resource count does.

There’s no message broker, no pub/sub, no event store. Two durable event tables and one in-process signal fan-out. That’s the whole of the “event-based” side.

What this buys at scale

A tick system is only as good as it is fast. This one runs every second, so it has to finish in a small fraction of a second, or the work backs up and the next tick starts before the last one has finished. My target is to be done in well under a tenth of the budget, somewhere around a hundred milliseconds, so there’s always headroom. That’s why everything the tick touches is optimised. The due-check is a single comparison. The sweeps are single queries. Nothing scans the whole database on a normal tick. A slow tick doesn’t just lag, it crunches the entire game, because the work keeps arriving whether or not the last batch cleared. A fast one you never notice.

On top of that speed, the design gives me:

  • No per-player work. One loop for the whole server. Idle players contribute nothing. Cost scales with events due per second, not with headcount.
  • Zero writes for inactive islands. An island nobody touches is pure computation-on-read. No timer is grinding through it.
  • Heavy jobs stay off the hot path. Ranking, expiry, the bazaar refresh and so on each run on their own independent timer with overlap protection, so a slow ranking pass can’t stack up or stall event processing.
  • Waves collapse. A hundred fleets landing in the same second become one sweep query and a loop, not a hundred separate dispatches.

Here’s what a normal day looks like on the live server. The whole game server runs on half a CPU and a 512MB memory cap and sits at a fraction of both, with the odd spike when a lot resolves at once. The database is on half a CPU too. Hundreds of islands are ticking away the whole time, and the machines stay close to idle, because the tick does the minimum and does it fast.

Game server CPU across a day, capped at half a CPU, mostly between 10 and 30 percent with occasional spikes Game server CPU across a day. The cap is half a CPU, and it mostly sits between 10 and 30 percent of that.

Game server memory against a 512MB cap, holding around a quarter to a third all day Memory against a 512MB cap. It barely moves off a third.

Database CPU over the same day, capped at half a CPU, low with one spike to 100 percent Database CPU, also on half a CPU.

Database disk operations over the day, writes and reads, mostly low with occasional spikes Disk operations. They stay low because the tick does the minimum and only writes when something has actually changed.

The queue itself is a sorted array, which is the right call at this scale and would want to become a heap at a much larger one. So “a thousand players without the server twitching” is the design target the architecture is built for, not a number I’ve benchmarked yet. It might want a proper queue system one day. It doesn’t need one now, and I’m not going to build one until it does.

The shape every domain follows

One more thing, because it’s what keeps all of the above readable. Every domain has the same three layers. A slim controller that validates the request and delegates. A fat service that owns the logic and the transaction. A thin repository that just talks to the database, with either no logic in it or very little, so the logic all lives in the services.

The split on validation matters more than it looks. User input is checked at the controller, not in the service. The services keep their own validation slim, which means one service can call another without re-running a pile of input checks that were only ever meant for a raw request off the wire. Controllers guard the front door. Services trust each other.

Here’s a building upgrade. The controller validates ownership, pulls resources up to now, flushes anything already due, then validates you can afford it, then hands off (controllers/place/place_building_controller.ts):

const ownedErr = await validatePlaceOwned(placeId, req.userId!, worldId);
if (ownedErr) return sendValidationError(req, res, ownedErr);

await placeService.materializeResources(placeId);   // pull resources up to "now"
await buildingService.processQueue(placeId);         // finalize anything already due

const upgradeErr = await validateCanUpgrade(placeId, buildingId);
if (upgradeErr) return sendValidationError(req, res, upgradeErr);

const result = await buildingService.enqueueUpgrade(placeId, buildingId, parseGoodsBody(req.body));

The service does the cost, the transaction and the queue insert, then registers the completion with the tick engine as an absolute timestamp:

await getDb().transaction(async () => {
  await placeRepo.deductResources(placeId, upgrade.cost);
  queueId = await buildingRepo.enqueueBuild(placeId, buildingTypeId, upgrade.nextLevel, completesAt);
  await spendHastenGoods(placeId, goods, 'build_hasten', queueId);
});
tickEngine.registerEvent('building_complete', queueId, completesAt, placeId);

The controller charges you against freshly-derived resources, the service writes the transaction and schedules the timer, the timer later pulls fresh state to finish the job. There are no hidden callbacks firing somewhere else, you can read a request straight through.

Why I like it

There’s nothing clever in here. One loop, absolute timestamps, resources computed on read, and timers that pull fresh state when they fire. Each of those takes a category of bug off the table: no per-player load, no stale-payload bugs, no double-crediting, no resource drift, and it recovers on its own after a crash. It does as little as possible and it does it fast, which is most of why I’ve barely had to touch it.