Gardens
A slow idle game that works out exactly what happened while you were away, in milliseconds.
Live and playable at userland.sh, with no sign-up. Started September 2026.
| Role | Year | Kind | Try it |
|---|---|---|---|
| Game design, engineering | 2026 | Game, simulation | userland.sh |
| 387 | automated tests: 297 for the game, 90 for the server |
| 60 – 80 ms | to work out eight hours away, against a budget of 200 ms |
| 547 | commits |
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
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.
/* 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(): 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
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},Chance is handled the same way. Outcomes that matter are drawn from seeded dice: a pure function of the garden's seed, a key and a count. The same numbers come up live, away, and after a reload.
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.
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. Once a period is found, the remaining time is covered in whole periods at once. The same case now takes about 4 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.