Gardens


A slow idle game that works out exactly what happened while you were away, in milliseconds.

Status

Live and playable at userland.sh, with no sign-up. Started September 2026.

RoleYearKindTry it
Game design, engineering2026Game, simulationuserland.sh
387automated tests: 297 for the game, 90 for the server
60 – 80 msto work out eight hours away, against a budget of 200 ms
547commits

The problem

An idle game runs while you are gone. When you come back it has to tell you what happened, and there are two easy ways to get that wrong.

Replaying every tick is exact and slow: eight hours is 144,000 ticks. Estimating from the current rates is fast and wrong, because rates change the moment a store runs dry or fills up. A wrong answer is also unfair: it can hand a player more than they would have earned by staying.

What I built

  • The game. You start with a small plot and build outward: garden, farming and mining, then research, trade and missions, and eventually stations in space. Eleven production tabs in all.
  • One economy for every kind of time. The live tick, time away, "skip ahead" and a starship's years at home all run through the same arithmetic.
  • A server. Saves, names, a shared world channel and chat with moderation.
  • A wiki generated from the game's own data, so it cannot fall out of date.

How it is structured

One shell runs every system. The browser plays the game; the server keeps saves and the shared world.

The game is plain browser JavaScript, about 4,000 lines, with no bundler and no build step: the page loads its script files in order. The server is Elixir and Phoenix on Postgres, about 2,000 lines.

Advancing time

advance never replays the tick. While nothing changes, a store moves in a straight line, so any number of ticks is one multiplication. The work is in finding where the line bends: the first tick at which a store crosses a value some rule reads, such as empty, full, or the 50 crops that happiness looks at. Time is advanced in stretches from one such boundary to the next.

js/sim.js
/* the first tick (1, 2, …) after which a store at `a`, moving at `r` a second, sits on another side of `c` than
   it started on (below, at, above), or Infinity when it never does (it moves away, or the clamp stops it first) */
crossing(a,r,dt,max,c){const side=x=>Math.sign(x-c),s0=side(a),a1=this.after(a,r,dt,max,1);
  if(side(a1)!==s0)return 1;if(!r)return Infinity;
  const s1=side(a1);if(side(r>0?max:0)===s1)return Infinity;
  const step=r*dt,at=t=>side(Math.max(0,Math.min(max,a1+step*t)));
  // the estimate, then exact on the same arithmetic as after(): the side changes once (the store moves one way), so
  // search for the first t on the other side (doubling past the estimate, then halving), in a few dozen steps even
  // for a rate so small that the estimate is far off
  // (past 1e15 ticks, 6 million years, is never: and past 2^53 the halving could no longer split the range)
  let hi=Math.max(1,Math.ceil((c-a1)/step));if(!(hi<=1e15))return Infinity;
  while(at(hi)===s1){hi*=2;if(hi>1e15)return Infinity}
  let lo=0;while(hi-lo>1){const mid=Math.floor((lo+hi)/2);if(at(mid)===s1)lo=mid;else hi=mid}
  return 1+hi},

That handles boundaries the rules declare. Some are not declared anywhere: livestock eat grain by the head, so grain's rate moves as the herd grows. steady is the net for those. It moves the stores forward as if nothing changed, recomputes every rate, and if they differ it searches for the first tick at which they do. Then it puts everything back.

js/game.js
/* The rates hold for a stretch of n ticks unless something else read from the stores changes them (livestock eat
   grain by the head, so grain's rate moves as the herd grows). Checked at the start of the stretch's last tick;
   if they differ there, the first tick at which they differ is found (doubling, then halving) and the stretch
   ends there. The stores and the effects are put back as they were. */
steady(rates,n,plan){const dt=TICK_MS/1000,ids=Object.keys(this.resources),R=this.resources;
  const from=ids.map(id=>R[id].amount),max=ids.map(id=>R[id].max),
    keep={effects:this.effects,energy:this.energy,unpaid:this.unpaid,upgradeFx:this.upgradeFx,techFx:this.techFx,convScale:this.convScale,convTh:this.convTh,convWhy:this.convWhy,convLim:this.convLim,manaPool:this.manaPool,genScale:this.genScale,essenceCap:this.essenceCap,claims:this.claims};
  // the same rates, to float noise (a converter settled on its inflow computes its share afresh each tick)
  const eq=(a,b)=>a===b||Math.abs(a-b)<=1e-9*Math.max(1,Math.abs(a),Math.abs(b));
  // (a cycling machine's stores stay put here: their jumps stay clear of every threshold, machinePlan)
  const same=j=>{ids.forEach((id,i)=>{if(!plan?.stores[id]&&(rates[id]||from[i]>max[i]))R[id].amount=Sim.after(from[i],rates[id]||0,dt,max[i],j)});
    this.recalcEffects();const x=this.calcRates();
    return ids.every((id,i)=>eq(x[id]||0,rates[id]||0)&&R[id].max===max[i])};
  const last=Math.ceil(n)-1;let out=n;
  if(last>=1&&!same(last)){let lo=0,hi=1;while(hi<last&&same(hi)){lo=hi;hi=Math.min(last,hi*2)}
    while(hi-lo>1){const mid=Math.floor((lo+hi)/2);if(same(mid))lo=mid;else hi=mid}
    out=hi}
  ids.forEach((id,i)=>{R[id].amount=from[i];R[id].max=max[i]});Object.assign(this,keep);
  return out},

What a reviewer would say: the rollback is a hand-kept list of fields, so anything the recalculation writes that is not on the list would leak. The search assumes rates change once and stay changed. And it is dense; the game is written to load without a build step, and this is the cost.

Chance is handled the same way. Outcomes that matter are drawn from seeded dice. There is no generator state to advance, so the result does not depend on the order the machines are stepped in: the same numbers come up live, away, and after a reload.

js/sim.js
/* The seeded dice for outcomes (magitech M3, #193; docs/ARCHITECTURE.md "Infusion, stability and risk"). A pure function
   of (the garden's seed, a key, a count): the same numbers live, away and after a reload, in any order the machines are
   stepped, and nothing is drawn from Math.random. Game.roll(key) keeps the count per key in the run save. The events'
   and names' dice (Game.rng) stay Math.random: they are flavour, and only the live tick draws them. */
const Rng={
  // FNV-1a of a string, 32 bits
  hash(s){let h=2166136261;for(let i=0;i<s.length;i++)h=Math.imul(h^s.charCodeAt(i),16777619);return h>>>0},
  // a number in [0, 1): mulberry32's mix of seed ^ hash(key) ^ the n-th step of the golden-ratio sequence
  at(seed,key,n){let t=((seed>>>0)^this.hash(String(key))^Math.imul((n>>>0)+1,0x9E3779B1))>>>0;
    t=(t+0x6D2B79F5)|0;let r=Math.imul(t^(t>>>15),1|t);r=(r+Math.imul(r^(r>>>7),61|r))^r;return((r^(r>>>14))>>>0)/4294967296},
};

How it is tested

297 tests for the game and 90 for the server.

The one that matters most runs two identical gardens. One plays N ticks live; the other is advanced by the same amount in one call. Every store must agree, and the garden that was away may never hold more than the one that stayed.

server/test/client/advance.test.mjs
async function equivalent(run, ticks, { tol = 1e-6, check } = {}) {
  const a = await boot(run), b = await boot(run);
  a.G(`for (let i = 0; i < ${ticks}; i++) Game.tick()`);
  b.G(`Game.advance(${ticks} * TICK_MS / 1000)`);
  const x = amounts(a), y = amounts(b);
  for (const id of Object.keys(x)) {
    const eps = tol * Math.max(1, Math.abs(x[id]));
    assert.ok(Math.abs(x[id] - y[id]) <= eps, `${id}: live ${x[id]}, advance ${y[id]}`);
    assert.ok(y[id] <= x[id] + eps, `${id}: advance made more than live (${y[id]} > ${x[id]})`);
  }
  check?.(a, b, x, y);
  return { a, b, x, y };
}

What broke, and the fix

A machine that draws on a store which another building keeps topping up never settles: it crosses a boundary every few ticks, for ever. Eight hours of one such machine took about a second, five times the whole budget.

The fix was to notice when the state repeats. At each restart of the machine the stores are noted. Once two periods in a row took the same number of ticks, left every machine in the same state and moved every store by the same amount, the periods after them are the same again, and are added in one step.

js/game.js
if(snaps.length<3)return null;
const[a,b,c]=snaps,P=c.t-b.t;
if(!eq(P,b.t-a.t)||!(P>0)||!b.lo||!c.lo)return null;
if(!ids.every((k,i)=>b.max[i]===c.max[i]&&a.max[i]===b.max[i]&&eq(c.amt[i]-b.amt[i],b.amt[i]-a.amt[i])))return null;
const ea=Object.keys({...a.ess,...b.ess,...c.ess});if(a.cap!==c.cap||!ea.every(x=>eq((c.ess[x]||0)-(b.ess[x]||0),(b.ess[x]||0)-(a.ess[x]||0))))return null;
if(JSON.stringify(b.runs.map(r=>r.slice(0,3)))!==JSON.stringify(c.runs.map(r=>r.slice(0,3)))||!b.runs.every((r,i)=>eq(r[3],c.runs[i]?.[3]??NaN)&&r[4]===c.runs[i]?.[4]))return null;
// how many more periods fit: the time left (one kept to finish normally), the clocks, and every drifting store's thresholds
let m=Math.floor(left/P)-1;if(clocks)m=Math.min(m,Math.floor((G.clockTicks()-1)/P));
if(!(m>=1))return null;

Skipping is only allowed while no store would reach a threshold some rule reads and no clock comes due. A test runs 15,000 ticks both ways, live and skipped, and requires the same stores in fewer than 60 stretches; another holds the eight-hour case under 50 ms.

What it does not do

The tests are run by hand before a release; nothing runs them automatically on every change. The repository is private, so the code shown here is all there is to read.

More

Play it
Open ↗
The wiki
Open ↗
Project notes on the weblog
Open ↗