I make Last Thaw, a free browser strategy game. Up to a hundred players start with five settlers on a real map of Earth, cut into 408 regions, and have 30 real days to build an empire while an ice age advances from both poles. It's in early access and I'm looking for people to test it.
The ice is the clock of the whole game. It is also the one system every player feels whether they log in or not, so it had to be boring in the right ways: predictable, fair, and impossible to argue with. This post is how it works, from one table of numbers down to the transaction that freezes a region, and the parts I got wrong first.
Rule one: the schedule is public
I wanted the ice to be something you plan around, not something that ambushes you. So the schedule is not random, and it is not hidden. It is a list of points in the shared balance config, and the same list drives the server, the map overlay and the player-facing how to play page:
ice: { /** Production multiplier left when the ice line reaches a region's centre. */ taperFloor: 0.25, /** Absolute latitude of the ice edge by match day; interpolated between points. */ schedule: [ { day: 0, latitude: 72 }, { day: 7, latitude: 62 }, { day: 14, latitude: 52 }, { day: 21, latitude: 42 }, { day: 28, latitude: 32 }, { day: 30, latitude: 28 }, ], }, The line is an absolute latitude, so one number covers both hemispheres: on day 14 everything beyond 52 degrees north and 52 degrees south is under the ice. Ten degrees a week for four weeks, then a slower last stretch. By the end the survivors are crowded into the band around the equator, which is where the fighting gets interesting.
Turning that into a latitude for any moment is plain linear interpolation:
export function iceLatitudeOnDay(day: number): number { const s = balance.ice.schedule; if (day <= s[0]!.day) return s[0]!.latitude; for (let i = 1; i < s.length; i++) { const a = s[i - 1]!; const b = s[i]!; if (day <= b.day) { const t = (day - a.day) / (b.day - a.day); return a.latitude + (b.latitude - a.latitude) * t; } } return s[s.length - 1]!.latitude; } Because it is a pure function in a shared package, the browser can call it too. The map's "Ice forecast" overlay asks the same function which day each region freezes, so what you see is exactly what the server will do. There is no second implementation to drift.
The inverse is just as useful: "on which day does this latitude freeze?" I loop over days rather than inverting the piecewise function, because a match is 30 days long and a loop of 30 is not worth being clever about:
export function freezeDayForLatitude(absLat: number): number | null { for (let day = 1; day <= balance.match.lengthDays; day++) { if (absLat >= iceLatitudeOnDay(day)) return day; } return null; } null means "never": the tropics survive the month.
Regions are not points
My first version froze a region when the line passed its centre. It was simple and, once I played against it, it felt awful. A big region like a slice of Canada or Siberia would produce at full rate right up to the moment it vanished, and then everything in it was gone in one step. Players had no signal that they should be leaving.
Regions in Last Thaw are generated from real borders (I wrote about that in the first post in this series), so each one knows its centre and how far it reaches north and south. That gave me a better rule: the ice touches a region when the line reaches its pole-facing tip, production tapers as the line moves from the tip to the centre, and the region freezes when the line passes the centre.
export function iceExposure(r: IceShape, day: number) { const line = iceLatitudeOnDay(day); const centre = Math.abs(r.lat); const tip = Math.max(centre, poleTipLatitude(r)); if (centre >= line) return { frozen: true, factor: 0, touched: true }; if (tip < line || tip <= centre) return { frozen: false, factor: 1, touched: false }; const t = (tip - line) / (tip - centre); // 0 at the tip, 1 at the centre const factor = 1 - (1 - balance.ice.taperFloor) * Math.min(1, Math.max(0, t)); return { frozen: false, factor, touched: true }; } poleTipLatitude picks the northern edge for a northern region and the southern edge for a southern one, so the same code mirrors itself across the equator. There is a test for exactly that, because a sign error here would have quietly made Patagonia safe:
it("mirrors for the southern hemisphere", () => { const south = { lat: -55, latNorth: -49, latSouth: -60 }; expect(iceDaysFor(south)).toEqual(iceDaysFor(region)); }); The taper goes from 1.0 down to 0.25 at the centre, then to zero. In play it reads as a warning: your northern farms start producing less a few days before they go, and the region's panel shows the day the ice touches it, the day it freezes and what it still produces.
One job per day, scheduled at the start
The game server does not tick the world. Everything that happens at a moment is a row in a jobs table with an ends_at, and a worker picks rows up in order (that was the previous post). The ice fits that model perfectly, because I know every moment it will move before the match even starts:
export async function scheduleIce(tx: Tx, matchId: string, startsAt: Date, speed: number) { const dayMs = (86400 * 1000) / speed; for (let day = 1; day <= balance.match.lengthDays; day++) { const at = new Date(startsAt.getTime() + day * dayMs); await tx.insert(jobs).values({ matchId, kind: "ice_step", payload: { day }, startsAt, endsAt: at }); } } Thirty rows, inserted in the same transaction that starts the match. speed exists so I can run a whole 30-day match in a few minutes for testing; the ice just runs faster with everything else.
Doing it this way has a nice property: if the worker is down for an hour, nothing is lost. When it comes back the overdue ice_step rows are simply the oldest jobs in the queue, and they run in order with the ends_at they always had. Production is credited up to that timestamp, not up to "now".
The freeze itself
The step is one function inside one transaction. In order, it:
- works out which regions are tapering and which are freezing today,
- locks every region it will touch, in ascending id order,
- settles each one up to the job's
ends_at, so the food and wood earned until the freeze are credited, - writes the new taper factors, and wipes the frozen regions.
Step 2 is the one I got wrong. Every other system in the game that touches more than one region locks them in ascending id order, which is the standard way to keep two transactions from waiting on each other forever. When I added the taper, the ice step started touching two lists of regions (tapering and freezing) and locked them as it went, in whatever order the lists came out. That is exactly the pattern that deadlocks against a battle or a convoy holding the same regions in the other order. Postgres would detect it and roll one side back, and the job would retry, but "it retries" is not a lock strategy. The fix, the same day, is one line and a comment so I never undo it:
// Lock every region we touch in ascending order (the lock order used everywhere) before changing any. const touched = [...new Set([...taperChanges.map((t) => t.regionId), ...freezing.map((r) => r.regionId)])].sort(); for (const id of touched) settled.set(id, await settleAndLockRegion(tx, m.id, id, job.endsAt, m.speed, resourceMultOf(m))); Then the freeze is deliberately brutal:
// Fully frozen: nothing and nobody survives. Stores, people and units all go to zero. await tx .update(matchRegions) .set({ frozenAt: job.endsAt, iceFactor: 0, food: 0, wood: 0, stone: 0, special: emptySpecial(), popWorkers: 0, popSoldiers: 0, popOccupiers: 0, units: {} }) .where(and(eq(matchRegions.matchId, m.id), eq(matchRegions.regionId, r.regionId))); I went back and forth on this. A softer ice (people slowly die, stores slowly rot) is more "realistic", but it removes the decision. With a hard freeze on a schedule everybody can read, leaving late is a choice you made, not bad luck.
Making a hard rule feel less cruel
A hard rule needs a few release valves, or the late game is just a spreadsheet of who moved south first. Three small systems hang off the same step, and all three are described on the public how-to-play page:
Migrant columns. Before a region is wiped, a share of its people get out. The server walks the region graph breadth first, up to two hops, looking for the nearest empire that is still above water:
for (let h = 0; h < c.hops && !dest; h++) { const next: string[] = []; for (const id of ring) for (const n of regionById(id)?.neighbors ?? []) if (!seen.has(n)) { seen.add(n); next.push(n); } const here = next.filter((n) => ownerOf.has(n)); if (here.length) dest = here[0]!; ring = next; } That owner gets a day to take them in for food or turn them away. It turns somebody else's disaster into your decision, which is the kind of thing a slow game needs more of.
Thaw pockets. Once or twice a match, a valuable region that should freeze within two days is spared for three more. The pick uses a random number generator seeded with the match seed and the day, so a replay of the match gives the same pocket, and every human player is told where it is. It is a short, public reason for people to fight over the north one last time.
Moving capitals. If the ice takes your capital, the capital moves at once to your most populous region that is still warm. If you have no warm land left, you are out of the match. That last rule came out of the nightly simulated matches I run against the live server: before it, a tribe whose last land froze could sit on in the standings with nothing, which helps nobody.
What I'd do differently
- Write the taper first. The point-freeze version gave no warning at all, and adding the taper later cost a migration to put an
ice_factoron every region row. - Decide the lock order on day one, and check it every time a function grows. The ice step was fine until it touched a second list of regions.
- Keep the schedule as data. Tuning the ice has been a config change every time, never a code change, and the tests read the same config, so they keep me honest: "most of the map is frozen by week four, the tropics never freeze."
If you want to see the line move, every match has a public page with the map, and a new match starts every week at lastthaw.com. It's in early access, so if you try it and something feels wrong, I'd like to hear about it.



