Notes from building Galactic Idle, a browser/mobile idle empire builder
Every idle game starts as a spreadsheet: resource in, resource out, multiply
by upgrade, repeat. The genre's real trick isn't the spreadsheet — it's what
happens when you stack five or six spreadsheets on top of each other and let
them read from the same state. That's where the interesting behavior lives,
and almost none of it is behavior you designed. It's behavior you
discovered.
Here's what that's actually looked like for us, with the real code behind
each example.
The setup: independent systems, shared state
Galactic Idle runs on five resources (energy, minerals, credits, research,
plasma), a building economy, a research tree, a conquest layer, a diplomacy
layer, and — the piece that turned out to matter most — an Upkeep
system: ongoing drain across four facets (Power, Fleet, Research, Colony)
that scales as a capped percentage of gross production, growing with empire
size.
None of these were designed together as one mechanic. Upkeep was bolted on
specifically to solve a problem: once a player had enough buildings,
production compounded without resistance and the back half of the game
turned into watching numbers go up with no decisions left to make. Upkeep
was the fix — friction that scales with success.
What we didn't fully predict was what Upkeep would do once it started
talking to the other systems.
Emergent tension #1: conquest now competes with itself
Conquering planets is supposed to feel unambiguously good — more territory,
more resources, more empire. But Upkeep's Colony facet reads how many
planets you've conquered as one of its inputs:
let flatColony = 0
for (const p of state.planets) if (p.conquered) flatColony += p.strain
flatColony *= fx.colonyStrainMult
// Admin load grows with colony structures, not just planet count, so
// stacking them to maxCount is self-limiting on the minerals side.
const colonyAxis = conqueredCount.value + totalColonyBuildings.value * 0.35
And the generic upkeep function that consumes it doesn't know or care that
colonyAxis came from Conquest — it just sees a number that scales drain:
function applyUpkeep(
facet: UpkeepFacetId,
gross: number, flat: number, axis: number, reductionMult: number,
skimSurplus = false,
) {
const t = UPKEEP_DRAG[facet]
const dragPct = Math.min(t.cap, Math.max(0, axis) * t.perUnit) * reductionMult
const base = skimSurplus ? Math.max(0, gross - flat) : Math.max(0, gross)
const f = upkeep[facet]
f.flat = flat
f.dragPct = dragPct
f.drain = flat + base * dragPct
return f.drain
}
dragPct is a percentage of gross production — not just the new
planet's production, all of it. So every planet you take doesn't just add
resources; it adds to the denominator that skims off the top of your
entire empire.
That means aggressive early conquest — which looks correct from a "bigger
number is better" instinct — can actively work against a player who hasn't
built up the production base to absorb the extra drain. Nobody wrote a rule
that says "don't over-expand." It fell out of two systems (Conquest,
Upkeep) that were never designed to talk to each other, connected only by
reusing the same empire-size variable.
We only found this by running it, not by reading the code. Once
conquest-heavy playtests started stalling out, we traced it back to
colonyAxis reading conqueredCount as an input it was never explicitly
balanced against. That's the emergent-depth story working in our favor: a
genuine strategic trade-off (expand fast vs. expand sustainably) we get
credit for without having designed it directly.
Emergent tension #2: the fix and the disease share a cause
Command Points — earned from conquest and mid-tier goals — fund a Doctrine
tree gated by planets conquered. On paper, this is a completely separate
progression track: spend a currency, pick a branch, get a bonus.
In practice, Doctrines became the system that makes emergent tension #1
survivable. A player who over-expands and gets hit by rising Upkeep has, by
virtue of having conquered those same planets, also unlocked Doctrine tiers
that can offset the drain (de.colonyStrainMult, de.fleetUpkeepMult,
etc., feeding straight back into applyUpkeep's reductionMult argument
above). The two systems weren't built as a matched pair, but because they
both key off the same underlying variable — planets conquered — they ended
up in a feedback loop: the thing that causes the pain and the thing that
funds the cure grow at the same rate.
That's not something a design doc predicted. It's something that fell out
of reusing one game-state variable as an input to two unrelated systems —
which is, in miniature, the entire argument for why emergence tends to come
from shared state, not from any single system's internal complexity.
Emergent tension #3: the one that was a bug, not a feature
Not every emergent interaction is a gift. A player on itch.io reported that
building costs "don't go up until they're actually completed, so you can
queue up a ton of them at a really low cost." Here's why:
function getCost(b: Building): Partial<Resource> {
const out: Partial<Resource> = {}
for (const [r, v] of Object.entries(b.baseCost)) {
if (v !== undefined) {
out[r as keyof Resource] = Math.ceil(v * Math.pow(b.mult, b.count))
}
}
return out
}
getCost scales off b.count — units actually completed. Compare that to
buyBuilding, which queues a purchase:
function buyBuilding(b: Building) {
const cost = getCost(b)
if (!spend(cost)) return
// ...
if (buildQueues[b.id]) {
buildQueues[b.id]!.count++ // <- a SEPARATE counter from b.count
} else {
buildQueues[b.id] = { progress: 0, timeLeft: t, totalTime: t, count: 1 }
}
}
buildQueues[b.id].count and b.count are two different numbers, and
getCost only ever reads the second one. Queue ten of the same building
and every one of them prices at the first unit's cost, because none of
them have "completed" yet as far as the cost function is concerned. Neither
function is wrong in isolation — the interaction between them is wrong.
The interesting part: we'd already solved this exact shape of problem
somewhere else in the codebase. buyColonyBuilding (a different building
category, with hard caps) already accounts for owned plus queued:
function buyColonyBuilding(cb: ColonyBuilding) {
// Built + already-queued must never exceed the cap — otherwise queuing
// a batch up front keeps constructing past maxCount as each one finishes.
if (cb.count + (buildQueues[cb.id]?.count ?? 0) >= cb.maxCount) return
// ...
}
The fix pattern existed in the codebase before the bug was even reported —
it just hadn't been applied everywhere the same category of interaction
could occur. The practical lesson: once you know two of your systems share
state in a way you didn't fully map, go looking for every other place the
same shape of interaction could be silently happening. It usually is.
How we actually find this stuff
We don't rely on hunches. The game exposes a debug hook that lets an
external harness drive the real simulation loop, not a separate model of
it:
// Balance-sim hook — only active when the URL contains ?__sim.
// Lets an offline harness fast-forward the real economy and read/drive it.
if (typeof window !== 'undefined' && /(\?|&)__sim\b/.test(window.location?.search ?? '')) {
;(window as unknown as { __SIM: unknown }).__SIM = {
store: useGameStore(),
tick, recalc, cleanup, fireRandomEvent,
setDtCap: (v: number) => { _tickDtCap = v },
}
}
A headless script loads the page with ?__sim, waits for window.__SIM,
and then calls tick() in a tight loop with an inflated dt cap — the
exact same tick/recalc functions a real browser session calls once a
frame, just run thousands of times a second with no rendering in between.
When we tune one system, we re-run the harness across the entire game
arc, not just the system we touched, specifically because the interesting
failures show up two or three systems away from the one we edited.
The most recent finding from that harness: a tuning pass on research costs
eliminated a "front-loading" exploit where the early game moved faster than
intended — but in fixing it, produced a new credits-negative trough three
to eight hours in, a dip that existed in neither the pre-nerf nor the
intended post-nerf design. Nobody wrote that trough into a spec. It emerged
from two changes that were each individually correct.
The actual takeaway
"Emergence" gets talked about like a feature you add. It isn't. It's a
cost you pay for having systems that share state at all, and the only
lever you have is how fast you notice it — through simulation, through
player reports, through deliberately stress-testing the seams between
systems instead of just the systems themselves.
If your game has more than two or three systems reading the same
underlying numbers, you don't get to choose whether emergent behavior
happens. You only get to choose whether you find it before your players do.
Top comments (0)