Engineering
Browser strategy game backend: no tick loop

Old Light is a browser strategy game: a whole galaxy you play in a tab, with star systems to claim, an economy to grow, fleets to build, and other players (plus the AI empires that live alongside them) to fight for territory. This post is about what you don't see while playing: the backend that keeps it all running.
Three things make that backend tricky, and almost every decision below comes from one of them.
- It's real-time. There are no turns. Your economy keeps growing and your fleets keep moving even after you close the tab, so the server has to keep the world running on its own.
- The browser is the client, and you can't trust it. Anything the browser sends could be faked, so the server, not the client, decides what is true.
- It has to scale. I want it to hold around 1000 players at once. Each player is also surrounded by AI empires (roughly three per human), so 1000 players really means about 4000 active "actors" in the world. Every feature has to survive being multiplied by that number.
Here are five decisions that shaped the backend. The whole stack is TypeScript: Express, Postgres (via TypeORM) and socket.io on the server, Pixi.js on the client.
The economy has no tick loop#
The obvious way to run a live economy is a timer that fires every second and adds resources to every star. That approach falls over once the world gets big: 4000 actors, each with several stars, updated every single second, forever, even for players who are offline and not looking.
So there's no timer. Instead, resources are calculated when someone asks for them. Each star saves two things: its last known balance, and the time that balance was saved. When you need the current value, the server does simple arithmetic:
current = saved balance + (rate per minute × minutes since it was saved)
capped so storage can't overflow. Stripped to its core, the function is:
project(star, now = new Date()) {
const cap = storageCap(star);
const ratePerMin = productionRate(star); // summed from the star's buildings
const minutesElapsed = (now.getTime() - star.settledAt.getTime()) / 60_000;
const balance = Math.min(cap, star.balance + ratePerMin * minutesElapsed);
return { balance, settledAt: now };
}
No timer touches the database. We only save a new balance on the rare occasions when something actually changes the rate, like finishing a building upgrade. Reading the value never writes anything.
There's a catch hiding in that formula. "Balance plus rate times time" is only correct if the rate held steady for the whole stretch of time, and in Old Light it often doesn't. Finishing a building raises its energy upkeep, and if that tips a system into an energy deficit, its income rate drops until you build enough power to recover. So the rate can fall partway through the very window you're projecting across.
If you naively multiply the current rate across the whole gap, you paint over that slow period as if it never happened and hand the player resources they never actually earned. The fix is to never let one projection span a rate change. The moment anything changes the rate, the server first settles: it banks the accrual at the old rate, up to that exact instant, and saves it. Only then does it apply the change. Every projection after that starts fresh from the boundary, so each stretch of time is only ever multiplied by the rate that was really in effect.
The client is hostile#
The frontend runs on the player's own machine, so I don't really control it. Anyone can open the browser's dev tools, read how it works, change values, and fire off whatever requests they like. The server treats every message as if it came from someone trying to cheat.
It never trusts what a request claims. A "build this on my star" message doesn't get to say who is sending it; the server takes the identity from the connection's own login, never from a field in the message:
// trusts the client to say who it is -> forgeable
const player = lookup(request.playerId);
// the player is whoever this authenticated connection belongs to
const player = connection.player;
Get that wrong and anyone can act as anyone else just by editing a number.
It also re-checks everything the client already checked, and confirms each request makes sense for the state of the game right now:
- Can you afford it? The greyed-out button is a convenience, not a guard. The server works out the cost again before it builds.
- Is the move even legal? You can't skip ahead and queue a jump to level 15 on a command center that's only level 10. The server accepts the next level up and nothing else.
And it decides what you're allowed to know. Public facts like who owns which star go to everyone, but your fleet size and your income are sent only to you, and to a rival only once they've scouted you. Hiding a number in the client is not hiding it: the only number a rival can't read is one the server never sent.
Combat is a pure function#
When two fleets fight, the whole battle is decided by one plain function. No database, no shared state, just inputs and outputs:
function resolveBattle(input, rng) {
/* returns who survived */
}
input is the ship counts on each side. rng is the function that supplies randomness. That second argument matters more than it looks: in the real game we pass in Math.random, and in tests we pass in a fake "random" that returns a fixed, known sequence.
Since randomness is the only thing coming from outside, the function is completely predictable once you control it.
That makes tests that never flake. Feed in a known sequence and the exact survivor counts come out every time, so a failing test means a real bug, not bad luck.
It also makes a balance simulator possible. I can run the same function ten thousand times with real randomness to see how often each side wins, then tune the game before shipping a change. The test suite and the simulator run the identical battle logic.
Each shot is two coin flips, did it hit, then did it win:
if (rng() >= hitChance) continue; // missed
if (rng() >= winChance) continue; // hit, but lost the exchange
target.dead = true; // hit and won
And a scouting fleet with no warships skips combat entirely:
if (!hasCombatShips(input.attackerShips)) {
return {/* nobody fought, both sides unchanged */};
}
The AI empires are predictable on purpose#
Old Light fills the galaxy with AI empires. They run through the exact same code as human players: same actions, same rules, same limits. The server never asks "is this a human or a bot?" because they are the same kind of thing.
Each AI's personality (offensive or defensive) and its speed aren't random and aren't stored in a column. They're computed from the world's seed number with a hash:
function empireIdentity(worldSeed, empireId) {
const coin = (hash(`${worldSeed}:identity:${empireId}`) % 10_000) / 10_000; // a number 0-1
return coin < 0.5 ? "offensive" : "defensive";
}
A hash turns text into a number that looks random but is always the same for the same input. So a given empire always lands on the same personality, with no clock, no live dice roll and no database lookup involved. Its character is fixed the moment it's born.
That same idea fixes a scaling problem. Updating every AI once a minute would mean a big spike of work all at the top of each minute. Instead, each AI is sorted into one of 60 buckets using the same hashing trick, and only one bucket runs each minute:
const thisMinutesAIs = allAIs.filter(
(id) => hash(`${worldSeed}:bucket:${id}`) % 60 === currentBucket,
);
Every AI still gets a turn about once an hour, but the work is spread smoothly across the whole hour instead of arriving all at once.
Every code path gets multiplied by 4000#
My rule of thumb: take any new piece of code, imagine ~4000 actors hitting it, plus the biggest input a player could send. If that math is scary, the code is wrong. Three rules follow from that:
Don't load a whole table and filter in code. Ask the database for only the rows you need. To find jobs that are finished, query for "finished before now," which the database has an index for and can skip the rest:
findDue(now) {
return this.repo.find({ where: { completesAt: LessThanOrEqual(now) } });
}
Don't run a query inside a loop. To load data for 100 stars, make one query for all 100 and then look them up in memory, not 100 separate queries:
const resourcesByStar = await loadResourcesFor(starIds); // one query
return stars.map((s) => enrich(s, resourcesByStar.get(s.id))); // fast memory lookups
Don't tell everyone about everything. Sending a message to every connected player is expensive. Private updates (your money, your buildings) go only to you, and the server doesn't even build that update if you're offline:
if (playerIsOffline(player.id)) return; // nobody listening, skip the work
sendTo(player.id, buildPrivateUpdate(player));
Public news, like who now owns which star, goes out as a single shared message, never one message per tiny change.
What ties it together#
The server owns the truth and stays cheap while thousands of actors lean on it, and it never takes the client's word for any of it. In practice that meant calculating values instead of ticking them, keeping the core logic in plain functions I could test and reuse, and treating randomness as an input rather than reaching for it mid-function.
You never see any of this while playing; it's all plumbing. The one piece you can feel is the last one: claim a star, build something, then close the tab, and your economy keeps accruing while you're gone. Come back tomorrow and the galaxy moved without you.
