Skip to content

Doomsday Clock: territory rot, so a doomed side actually dies - #4893

Open
Zixer1 wants to merge 3 commits into
openfrontio:mainfrom
Zixer1:feat/doomsday-territory-rot
Open

Doomsday Clock: territory rot, so a doomed side actually dies#4893
Zixer1 wants to merge 3 commits into
openfrontio:mainfrom
Zixer1:feat/doomsday-territory-rot

Conversation

@Zixer1

@Zixer1 Zixer1 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Problem

The Doomsday Clock can't end a game. The drain stops at the troop floor, so a doomed side is crippled but never eliminated, and nothing ever removes territory. R4 of the last Major ended on the clock with 16 players alive, three of them holding 4, 22 and 64 tiles.

Change

Territory rot. A doomed side loses land until it holds none. Paced to a deadline (rotDeathSeconds from the skull appearing), each second taking ceil(tilesLeft / secondsLeft) — self-correcting, so the finish time is the same for a sliver and an empire. Rot spreads from interior seeds rather than picking scattered tiles, and takes any structure with the ground. Nothing is credited: no conqueror is passed, so no kill and no captured gold.

Decaying troop floor. The floor starts high and decays to drainFloorPercent, giving one window with a usable army to climb back above the bar. A permanently high floor would make a doomed side unconquerable (maxTroops is sublinear, ~100k at a single tile).

Wave schedule re-cut from 85 tournament games: seven small steps to 35% (2/4/7/11/17/25/35) instead of six accelerating ones to 55%. The runner-up's share at game end has never exceeded 21.6%, so any bar above ~16% catches the same players — a higher ceiling only climbed past the leader's own share (median 41%) and handed the game to the crown exemption. Small steps matter because half the players alive at the end hold under 0.4% of the map, so a big jump debuffed most of the field at once.

Timings

from skull skull
0s warn countdown blinking white
30s troops draining steady white
120s territory rotting steady red, "Decaying"
180s eliminated

Verified in-sim at 150 / 1,200 / 2,000 tiles: dead at 179s with zero tiles in every case.

Notes

  • Opt-in per lobby — doomsdayClock.enabled still defaults to false. rotDeathSeconds: 0 disables rot alone.
  • Deterministic: integer-only, rot's choices via PseudoRandom seeded per (tick, player).
  • The red skull is a shader tint of the existing asset, no new image. isDecaying comes from the sim rather than being re-derived client-side, because the phase test is a knife-edge equality that flickers when recomputed.
  • Spreading is O(quota) per second, not a per-tick territory scan — 10k ticks of a 3,000-tile player in 48ms.
  • tests/GameUpdateUtils.test.ts now asserts every scalar PlayerUpdate field survives diffPlayerUpdate, which transmits only changed fields and is easy to half-wire.

2642 tests pass (291 server), tsc/oxlint/eslint clean.

Caveat: on a 25-minute fast round the 35% bar lands at the buzzer, so a player caught in the last two minutes won't finish dying before the cap. Fix is pulling the schedule earlier, not shortening the 180s.


Moved from #4874 (same commits, same head SHA 1be7633). The branch lived on the main repo, which auto-deploys every branch to staging, so it now sits on the fork instead. Review history is on #4874.

Zixer1 added 3 commits August 5, 2026 14:55
The Doomsday Clock cannot currently end a game. The troop drain stops at
drainFloorPercent of max, which cripples a doomed side without ever eliminating
it, and relinquish is the only thing that removes territory -- nothing calls it.
So a stalemate can outlive even the final wave with every challenger parked at
the floor, crippled but unkillable. From the last Major: R4 ended on the clock
with 16 players alive and the leader on 15.5% of the map, three of them holding
4, 22 and 64 tiles.

TERRITORY ROT is the finisher, and it is paced to a DEADLINE rather than a rate.
rotDeathSeconds is measured from the moment the skull appears, and each second
takes ceil(tilesLeft / secondsLeft). That quota is self-correcting, which is what
makes the guarantee hold:

  - size stops mattering. Measured skull-to-dead at 200, 1,200 and 3,000 tiles:
    199s in all three, on a 200s deadline.
  - the dead time stops mattering. The warn, the comeback window and the drain
    grinding to the floor all eat into the budget, and the quota inflates to
    cover it. A slow start means a faster finish.
  - there is no tail. On the deadline tick secondsLeft is 1, so the quota is
    everything left. Nobody survives on 4 tiles.

It spreads rather than picking scattered tiles: a share of the territory is
peppered with pinholes over rotGrainSeconds (density from the territory, not the
second's quota -- quota-sized speckling is one or two holes a second on anything
but a huge empire), then the holes grow outward through their neighbours. Seeds
prefer the INTERIOR so decay starts inside rather than nibbling the frontier as if
someone were pushing on it. A blob walled in by an island edge falls back to
speckling, so islands still die on time.

WHAT IT LOOKS LIKE got as much attention as the timing, since a mechanic people
like and one they merely tolerate differ mostly in how they read.

Selecting perimeter tiles uniformly is precisely the Eden growth model, which is
provably compact -- fractal dimension 2, "roughly circular with a rough
circumference". Measured on an 80x80 field it filled 48% of its bounding box with
only 35% of its tiles on the boundary: a solid disc with a fuzzy rim.

Two changes fix that, neither of which touches the deadline:

  - the front is ranked by how many of a tile's neighbours have already rotted,
    fewest first, so TIPS outrun flat edges. Dielectric-breakdown growth makes the
    rate proportional to the field gradient, which needs a Laplace solve; a tile's
    rotted-neighbour count is a local stand-in for the same quantity, and it is
    tallied incrementally as rot advances rather than recomputed, so ranking on it
    costs nothing.
  - ties break on an integer hash of the tile, and pinholes are placed from a
    separate R2 low-discrepancy field.

Together those take the same field to 19% fill with 98% of tiles on the boundary --
lacy tendrils instead of a disc, confirmed at 99% through the real simulation. The
number of tiles removed each second is unchanged, so this alters WHICH tiles go,
never how many or how fast.

The two noise fields are not interchangeable, and each is wrong for the other's
job, which is worth knowing before merging them:

  - the R2 lattice spaces picks near-perfectly -- 0% of picks touch a neighbour
    against 32% for a hash -- because its low values form a CRYSTAL. Avalanching
    its output destroys that and measures worse than white noise (36% touching).
  - that same crystal is useless for growth: every pick's nearest neighbour sits at
    one of six fixed offsets, so a front that always eats its lowest-valued tile
    marches along them and grows a straight 80x20 filament. The front therefore
    ranks on a hash, which has 41 distinct offsets and no preferred axis.

Both properties are pinned by tests, each having been broken once in development.

Dropping the shuffle removed the last PRNG from rot: tile choice is now pure
integer hashing, so no floating-point arithmetic remains in this path.

Only seeding scans a player's tiles; spreading is local, O(quota) per second
however large the empire. Measured 10,000 ticks of a 3,000-tile player in 48ms.

Nothing is credited: no conqueror is passed to relinquish, so there is no kill,
no killedBy and no captured gold, and the land simply becomes unowned. Structures
left standing on rotted ground need no handling here -- PlayerExecution already
deletes structures whose tile has no owner. Death falls out of the existing machinery (isAlive() is tiles > 0, and
PlayerExecution already stamps a death position for non-conquest deaths).

A DECAYING TROOP FLOOR makes it fair. Today's flat 5% leaves a doomed side no
army to fight back with, so rot would be a death sentence handed out slowly.
Raising the floor permanently is worse: maxTroops is sublinear in territory with
a ~100k floor at a single tile, so a fixed 40% would leave ~40k troops standing
on one tile and make a doomed side near-unconquerable. So the floor decays from
floorStartPercent to drainFloorPercent over floorDecaySeconds -- one genuine
window with a usable army, then it closes. Rot only begins after that window, so
the two stages do not overlap. Climbing back above the bar clears everything;
holes already rotted are not restored.

THE WAVE SCHEDULE is re-cut from 85 tournament games: seven small steps to a 35%
ceiling (2/4/7/11/17/25/35) instead of six accelerating ones to 55%.

  - the ceiling. The runner-up's share at game end is median 8% and has never
    once exceeded 21.6%, so any bar above ~16% catches exactly the same players.
    A higher ceiling only climbed past the LEADER's own share (median 41%; 55%
    exceeded everyone in 79% of games), handing the game to the crown exemption
    instead of to territory.
  - the step size. The field is bottom-heavy -- half the players alive at the end
    hold under 0.4% of the map -- so a big jump sweeps that whole cluster into
    the debuff at once, which is how games reached the state where 8 of 10
    survivors were crippled simultaneously and nobody could do anything. Small
    steps catch them a few at a time and each pause is a real chance to recover.

Note this changes what the last wave promises: floor(100/35) is two, so the bar
narrows the field but no longer forces a single winner by arithmetic alone. Rot
is what closes out the last pair.

ON THE CLIENT the decaying phase gets its own cue: a steady RED skull and a
"Decaying" caption, alongside the existing blinking (warn) and steady white
(draining) states. The sim stamps the tick it takes land and exposes isDecaying
over the same wire fields the doomsday mark already uses, with a short grace so
the cue cannot strobe.

Adding that field means wiring it into diffPlayerUpdate, which transmits only
CHANGED fields and keeps two independent lists of them: a fast-path early return
that ANDs every field and bails out with null, and the setIfDifferent block that
builds the payload. A field missing from either is silently never transmitted --
the client keeps its initial value forever -- and a field missing from the fast
path is worse, because a tick where only that field changed is judged "nothing
changed" and emits no update at all. tests/GameUpdateUtils.test.ts now walks every
scalar field on a PlayerUpdate, flips it, and asserts the diff carries it, so the
next field cannot ship half-wired. (tilesOwned/gold/troops are skipped there by
design -- they ride the packed transferable channel.) It is deliberately NOT re-derived on the client: the test
is a knife-edge equality (the drain lands exactly ON the floor) and the floor
moves as rot shrinks the troop cap, so a client-side copy flickers. The skull is
tinted in the shader from the existing white asset -- no new image. The danger
value the status shader reads was already an encoding (0 clear, 1.0-1.49 blinking
with the warn progress in the fraction, 2.0 steady), so decaying is 3.0; icon
visibility is a > 0.5 test and nothing compares it for equality.

TIMINGS, measured from a side's skull first appearing:

  0s    skull starts blinking, warn countdown
  30s   skull holds steady, troops start draining      (warnSeconds 30)
  120s  floor bottoms out at 5%, territory starts rotting, skull turns RED
  180s  nothing left, side eliminated                  (rotDeathSeconds 180)

Verified in the real simulation at 150, 1,200 and 2,000 tiles: steady at 30s, red
at 120-131s (a sliver is a few seconds later, because maxTroops bottoms out near
100k however small the territory, so its drain takes longer to reach the floor),
dead at 179s with zero tiles left in every case. A test pins these numbers so
retuning them has to be deliberate rather than a side effect of touching the
drain or the floor.

The Doomsday Clock as a whole is still opt-in per lobby (GameConfig.doomsdayClock
.enabled defaults to false), so this only changes games that asked for it.
rotDeathSeconds 0 switches rot off while leaving the rest of the clock alone.

Tests: 63 in the doomsday suite. The floor's exact ramp, clamping, monotonicity
and inertness at the defaults; rot's gates, dying completely and on time, the
same deadline across sizes, finishing on time when rot starts late, not dumping
everything in one second, many scattered holes during the grainy phase, spreading
after it, structures deleted without credit, islands dying via the speckle
fallback, no tile taken twice, the leader never rotted, recovery restoring a full
budget, and determinism both ways (same seed identical, different player
different). A real-simulation test rots a live player off the map and asserts the
land ends unowned, the leader gains nothing, and isDecaying was true while it
happened. Four more cover the three skull phases through the status deriver.
180s from the skull was too long a death. The rot window (deadline minus
warn minus floor decay) drops from 60s to 30s, so a doomed side is gone
two and a half minutes after it is first marked instead of three.

rotGrainSeconds comes down 20 -> 10 with it. The grainy opening is meant
to be the first third of the rot window; left at 20s it would have been
two thirds of a 30s window, so the speckle would read as the whole death
rather than its opening.

The rot quota is a self-correcting ceil(tilesLeft / secondsLeft), so the
shorter window needs no other change: it simply takes more tiles per
second. The timeline test now derives the window instead of hardcoding
it, and pins the grain to a minority of it.
The panel told you the rate while troops were draining ("Collapsing
-{rate}/s") but went silent about the rate once territory started rotting,
which is the phase that actually kills you. It just said "Decaying".

Now it reads "Decaying -{rate} tiles/s", using the same quota the sim
applies. Because rot is a deadline rather than a rate, that number climbs
as the deadline nears, which is the honest thing to show.

The quota moves into doomsdayClockRotQuota() in DoomsdayClock.ts and both
the execution and the panel call it, so the number on screen cannot drift
from the number being applied. Keyed on the player's own tile count, since
rot is per player while the percentage above it is the team's share.

Note for translators: doomsday_clock.decaying takes a {rate} param now.
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The Doomsday Clock now uses dynamic troop-floor decay and deterministic territory rot. Player decay state propagates through updates to rendering and the HUD. Wave schedules, configuration, quotas, noise fields, and tests were updated.

Changes

Doomsday Clock decay

Layer / File(s) Summary
Clock rules and decay calculations
src/core/configuration/Config.ts, src/core/game/DoomsdayClock.ts, tests/DoomsdayClockExecution.test.ts
Wave thresholds, troop-floor decay, deterministic noise, rot quotas, and related tests were added or updated.
Player decay state and updates
src/core/game/Game.ts, src/core/game/PlayerImpl.ts, src/core/game/GameUpdates.ts, src/core/game/GameUpdateUtils.ts, src/client/view/PlayerView.ts, src/client/render/types/Renderer.ts, tests/GameUpdateUtils.test.ts, tests/util/viewStubs.ts
Players record rot timestamps and expose isDecaying(). Partial updates carry the state to client views.
Territory rot execution
src/core/execution/DoomsdayClockExecution.ts, tests/DoomsdayClockExecution.test.ts
Execution drains troops, seeds and spreads rot, relinquishes tiles, protects the leader, and clears rot during recovery.
Decay status presentation
src/client/render/frame/derive/PlayerStatus.ts, src/client/render/gl/passes/name-pass/*, src/client/components/DoomsdayClockPanel.ts, resources/lang/en.json, tests/client/render/*
The client displays decaying status, rot rate, and a steady red skull state.

Estimated code review effort: 5 (Critical) | ~90+ minutes

Sequence Diagram(s)

sequenceDiagram
  participant DoomsdayClockExecution
  participant PlayerImpl
  participant ClientRenderer
  DoomsdayClockExecution->>PlayerImpl: markRotted()
  DoomsdayClockExecution->>PlayerImpl: relinquish rotted tiles
  PlayerImpl-->>ClientRenderer: isDecaying in player update
  ClientRenderer->>ClientRenderer: pack decaying red-skull status
Loading

Possibly related PRs

Suggested labels: UI/UX

Suggested reviewers: evanpelle, variablevince, ryanbarlow97

Poem

Waves descend, the red skull wakes,
Rot moves through the numbered lakes.
Floors fall low, then tiles depart,
Salted noise gives rot its chart.
The clock now fades with measured art.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 61.90% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: adding territory rot so doomed sides lose all territory and are eliminated.
Description check ✅ Passed The description directly explains territory rot, decaying troop floors, schedule changes, deterministic behavior, and validation results.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
src/core/execution/DoomsdayClockExecution.ts (1)

350-353: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid allocating a neighbor array for every consumed tile.

consume() calls mg.neighbors(tile) once per removed tile, and neighbors() returns a new TileRef[] each time. Use mg.forEachNeighbor() in this hot path to preserve the existing ownership check without the allocation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/execution/DoomsdayClockExecution.ts` around lines 350 - 353, Update
the neighbor traversal in consume() to use mg.forEachNeighbor() instead of
mg.neighbors(tile), preserving the existing tiles.has(neighbour) ownership check
and front count increment for each neighbor while avoiding per-tile array
allocation.
tests/GameUpdateUtils.test.ts (1)

460-469: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The walk only checks fields that makePlayerUpdate sets.

scalars is derived from Object.keys(base), so a field that makePlayerUpdate omits is invisible to this test. team and spawnTile are optional on PlayerUpdate and absent from the stub today. clanTag is present but null, so the !== null filter drops it too. A new field added to PlayerUpdate and to diffPlayerUpdate but not to the stub would still slip through the exact hole this block exists to close.

The round-trip guard is still valuable. Please state the dependency in the comment so the next person updates both files together.

♻️ Proposed comment clarifying the stub dependency
   const base = makePlayerUpdate({ id: "p1" });
+  // NOTE: this walk can only see keys that makePlayerUpdate() actually sets.
+  // Add every new scalar PlayerUpdate field to that stub, or this guard skips it.
+  // Fields left null in the stub (clanTag) are also skipped and need their own test.
   const asRecord = base as unknown as Record<string, unknown>;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/GameUpdateUtils.test.ts` around lines 460 - 469, Update the comment for
the scalar-field round-trip guard near scalars and the “covers a meaningful
number of fields” test to explicitly state that the check only covers fields
populated by makePlayerUpdate. Document that optional or null fields such as
team, spawnTile, and clanTag are excluded, so changes to PlayerUpdate or
diffPlayerUpdate require updating the stub together.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/core/execution/DoomsdayClockExecution.ts`:
- Around line 350-353: Update the neighbor traversal in consume() to use
mg.forEachNeighbor() instead of mg.neighbors(tile), preserving the existing
tiles.has(neighbour) ownership check and front count increment for each neighbor
while avoiding per-tile array allocation.

In `@tests/GameUpdateUtils.test.ts`:
- Around line 460-469: Update the comment for the scalar-field round-trip guard
near scalars and the “covers a meaningful number of fields” test to explicitly
state that the check only covers fields populated by makePlayerUpdate. Document
that optional or null fields such as team, spawnTile, and clanTag are excluded,
so changes to PlayerUpdate or diffPlayerUpdate require updating the stub
together.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3bb919dd-ef3f-4b84-9850-cd19d20cf962

📥 Commits

Reviewing files that changed from the base of the PR and between 9bf6edf and 1be7633.

⛔ Files ignored due to path filters (2)
  • src/client/render/gl/shaders/name/status-icon.frag.glsl is excluded by !**/*.glsl
  • src/client/render/gl/shaders/name/status-icon.vert.glsl is excluded by !**/*.glsl
📒 Files selected for processing (19)
  • resources/lang/en.json
  • src/client/components/DoomsdayClockPanel.ts
  • src/client/render/frame/derive/PlayerStatus.ts
  • src/client/render/gl/passes/name-pass/Types.ts
  • src/client/render/gl/passes/name-pass/index.ts
  • src/client/render/types/Renderer.ts
  • src/client/view/PlayerView.ts
  • src/core/configuration/Config.ts
  • src/core/execution/DoomsdayClockExecution.ts
  • src/core/game/DoomsdayClock.ts
  • src/core/game/Game.ts
  • src/core/game/GameUpdateUtils.ts
  • src/core/game/GameUpdates.ts
  • src/core/game/PlayerImpl.ts
  • tests/DoomsdayClockExecution.test.ts
  • tests/GameUpdateUtils.test.ts
  • tests/client/render/frame/derive/nuke-telegraphs.test.ts
  • tests/client/render/frame/derive/player-status.test.ts
  • tests/util/viewStubs.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Triage

Development

Successfully merging this pull request may close these issues.

1 participant