feat(sudden-death): battle-royale style zone gamemode#4469
Conversation
Optional game mode that guarantees a finish. Once armed, every side (each player in FFA, each whole team otherwise) must hold a rising share of the map; a side below the bar is skulled and, after a short warn, its troops bleed to zero. - Threshold rises in waves: a grace, then linear ramps to each level with 30s pauses. Levels track the ofstats FFA median (3/5/10/20/30%); the four speed presets (slow/normal/fast/very fast) change only the pace. - Troop drain is a linear ramp on max capacity; ~1 min from caught to wiped. - HUD: live bar vs target, wave/decay countdowns, red/orange cues, and an on-map skull (blinks in danger, steady while draining). - Deterministic integer-only sim; covered by unit and integration tests.
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds Sudden Death support across core game rules, player state flow, lobby controls, HUD/status rendering, localization, and tests. The game now tracks sudden-death state, computes wave and drain timing, applies it during execution, and renders the mode in client UI and status layers. ChangesSudden Death mode implementation
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related issues
Possibly related PRs
Suggested labels: Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
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. Comment |
Resolve conflicts in HostLobbyModal.ts and GameServer.ts (sudden-death config fields kept alongside upstream's new name-reveal/anonymize fields).
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (1)
tests/client/render/frame/derive/player-status.test.ts (1)
25-36: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd a direct test for the warn/drain boundary.
This helper now carries the new sudden-death fields, but the file still does not assert
computePlayerStatus()below and atsuddenDeathWarnTicks. That boundary drives the blink-vs-steady skull state in the name pass, so an off-by-one here would slip through.🤖 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/client/render/frame/derive/player-status.test.ts` around lines 25 - 36, Add direct boundary coverage for sudden-death warn/drain in computePlayerStatus(). Update the player status test file’s existing computePlayerStatus assertions to explicitly verify behavior at suddenDeathWarnTicks and just below it, so the warn-versus-drain state is pinned down. Use the ps helper and the computePlayerStatus test cases to cover both sides of the boundary and prevent off-by-one regressions in the name pass skull state.
🤖 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.
Inline comments:
In `@src/client/hud/layers/GameRightSidebar.ts`:
- Around line 305-314: Update the Sudden Death HUD math in GameRightSidebar so
it matches the core zero-land behavior instead of clamping land to 1. In the
section that computes land, requiredTiles, and the percentage values, preserve a
real land value and explicitly handle land <= 0 by treating required tiles and
displayed percentages as 0, rather than dividing by 1 and inventing ownership
percentages. Keep the fix localized to the sidebar rendering logic around
suddenDeathRequiredTiles/suddenDeathWaveState/sideTiles.
In `@src/client/render/gl/passes/name-pass/StatusIconProgram.ts`:
- Around line 171-190: The skull composition path in StatusIconProgram can still
fail after both images load, and the current catch only handles loading errors,
leaving atlasReady false. Update the composition/upload flow around the canvas
draw and upload(canvas) step so any drawing or upload failure falls back to
using the base atlas texture instead of dropping all status icons. Keep the
fallback localized to the status icon setup logic that builds the atlas for
suddenDeath.
In `@src/client/SinglePlayerModal.ts`:
- Around line 393-394: The hasOptionsChanged() logic in SinglePlayerModal is
still treating suddenDeathSpeed as a custom option even when suddenDeath is off,
which keeps the achievements warning active unnecessarily. Update the
option-change check so suddenDeathSpeed is only compared when suddenDeath is
enabled, and make the same conditional handling wherever the final config is
built in startGame() and the duplicated check around the other referenced block.
In `@src/core/configuration/Config.ts`:
- Around line 90-96: The default SuddenDeathDrain tuning in
SUDDEN_DEATH_DEFAULTS is too aggressive for the advertised one-minute wipe.
Retune the default values for enabled, warnSeconds, drainStartPercent,
drainMaxPercent, and/or drainRampSeconds so the default path lands near 60
seconds total, and verify the behavior through the SuddenDeathDrain config flow.
Add one regression test that uses the default Config/SUDDEN_DEATH_DEFAULTS path
and asserts the resulting drain timing matches the intended one-minute behavior.
In `@src/core/Schemas.ts`:
- Around line 250-257: The SuddenDeathConfigSchema currently validates
drainStartPercent and drainMaxPercent independently, so reversed values can
still pass. Add a cross-field validation on SuddenDeathConfigSchema to reject
configs where drainStartPercent is greater than drainMaxPercent, and keep the
existing per-field bounds intact. Use the SuddenDeathConfigSchema object as the
place to enforce the pairwise check before suddenDeathDrain() consumes it.
---
Nitpick comments:
In `@tests/client/render/frame/derive/player-status.test.ts`:
- Around line 25-36: Add direct boundary coverage for sudden-death warn/drain in
computePlayerStatus(). Update the player status test file’s existing
computePlayerStatus assertions to explicitly verify behavior at
suddenDeathWarnTicks and just below it, so the warn-versus-drain state is pinned
down. Use the ps helper and the computePlayerStatus test cases to cover both
sides of the boundary and prevent off-by-one regressions in the name pass skull
state.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: caa3490a-1a5e-41f8-8050-c1c2d5675bb2
⛔ Files ignored due to path filters (2)
resources/images/SuddenDeathSkull.svgis excluded by!**/*.svgsrc/client/render/gl/shaders/name/status-icon.vert.glslis excluded by!**/*.glsl
📒 Files selected for processing (29)
resources/atlases/status-atlas-meta.jsonresources/lang/en.jsonsrc/client/HostLobbyModal.tssrc/client/SinglePlayerModal.tssrc/client/components/GameConfigSettings.tssrc/client/hud/PlayerIcons.tssrc/client/hud/layers/GameRightSidebar.tssrc/client/render/frame/derive/PlayerStatus.tssrc/client/render/gl/passes/name-pass/StatusIconProgram.tssrc/client/render/gl/passes/name-pass/Types.tssrc/client/render/gl/passes/name-pass/index.tssrc/client/render/types/Renderer.tssrc/client/view/GameView.tssrc/client/view/PlayerView.tssrc/core/GameRunner.tssrc/core/Schemas.tssrc/core/configuration/Config.tssrc/core/execution/SuddenDeathExecution.tssrc/core/game/Game.tssrc/core/game/GameUpdateUtils.tssrc/core/game/GameUpdates.tssrc/core/game/PlayerImpl.tssrc/core/game/SuddenDeath.tssrc/server/GameServer.tstests/GameUpdateUtils.test.tstests/SuddenDeathExecution.test.tstests/client/render/frame/derive/nuke-telegraphs.test.tstests/client/render/frame/derive/player-status.test.tstests/util/viewStubs.ts
drainStartPercent and drainMaxPercent were validated independently, so a reversed pair (start > max) passed and produced a shrinking drain curve. Add a cross-field refine so start <= max.
The panel clamped the land denominator to 1, inventing percentages (and possible >100% ownership) when fallout removed all land. Pass the real land to the shared math (which returns 0 there) and guard the percentages.
Only skull image loading fell back to the bare atlas; a failure in canvas drawing or upload left atlasReady false and hid every status icon. Wrap the whole compose path so any failure still uploads the bare atlas.
Turning Sudden Death on, changing pace, then off left hasOptionsChanged() true (remembered speed != normal) even though startGame drops the mode. Only count the pace when the mode is enabled.
Exercises the resolved SUDDEN_DEATH_DEFAULTS with real troop income and asserts a full stack is wiped in ~1 minute from caught (warn + linear drain), not the ~45s a no-income analysis predicts.
A side's required share now scales with its member count: a team of N must hold N x what a solo player holds (capped at the whole map). FFA sides are size 1, so FFA is unchanged. Add suddenDeathSideRequiredTiles as the shared source for both the sim and the HUD.
In team modes the panel shows the side's combined share as "Team <name> <pct>%" instead of "You ...", and the threshold + wave percentages reflect the headcount-scaled requirement.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
tests/SuddenDeathExecution.test.ts (1)
305-319: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueNew team-scaling test bypasses
setup().This new test builds its game via
teamGame()→FakeGame/FakePlayerrather than the real simulation harness. The path guideline fortests/**/*.test.tscalls for usingsetup()fromtests/util/Setup.tsto exercise core simulation directly — a hand-rolled fake can silently drift from realPlayerImpl/Gamesemantics (e.g. team membership, tile bookkeeping) and mask regressions the real objects would surface.The bottom two tests in this file (setup()-based) already give some real-sim coverage for wave/drain timing, so this isn't a correctness bug, but converting this specific scenario to
setup()(or adding an equivalent real-sim assertion) would tighten confidence in the team-size scaling behavior.As per path instructions: "Use the
setup()helper fromtests/util/Setup.tsto create test game instances and exercise core simulation directly."🤖 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/SuddenDeathExecution.test.ts` around lines 305 - 319, The new team-size scaling test is using teamGame() with FakeGame/FakePlayer instead of the real simulation harness. Update the test to use setup() from tests/util/Setup.ts (or add an equivalent real-simulation assertion) so the SuddenDeathExecution behavior is exercised through the actual PlayerImpl/Game path and not a hand-rolled fake. Keep the same scenario and assertions, but locate the test by its “scales the threshold by team size” description and replace the fake setup with the shared setup() helper.Source: Path instructions
🤖 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 `@tests/SuddenDeathExecution.test.ts`:
- Around line 305-319: The new team-size scaling test is using teamGame() with
FakeGame/FakePlayer instead of the real simulation harness. Update the test to
use setup() from tests/util/Setup.ts (or add an equivalent real-simulation
assertion) so the SuddenDeathExecution behavior is exercised through the actual
PlayerImpl/Game path and not a hand-rolled fake. Keep the same scenario and
assertions, but locate the test by its “scales the threshold by team size”
description and replace the fake setup with the shared setup() helper.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: a7f44d1d-d940-48de-8482-c524a3cde6c6
📒 Files selected for processing (8)
resources/lang/en.jsonsrc/client/SinglePlayerModal.tssrc/client/hud/layers/GameRightSidebar.tssrc/client/render/gl/passes/name-pass/StatusIconProgram.tssrc/core/Schemas.tssrc/core/execution/SuddenDeathExecution.tssrc/core/game/SuddenDeath.tstests/SuddenDeathExecution.test.ts
🚧 Files skipped from review as they are similar to previous changes (6)
- resources/lang/en.json
- src/client/SinglePlayerModal.ts
- src/client/hud/layers/GameRightSidebar.ts
- src/core/execution/SuddenDeathExecution.ts
- src/core/Schemas.ts
- src/client/render/gl/passes/name-pass/StatusIconProgram.ts
The team readout doubled up as "Team <name>" (team names are already "Team 1", "Red", etc.). Show "<name>: <pct>%" in the team's on-map color instead.
evanpelle
left a comment
There was a problem hiding this comment.
Fable review:
Findings (most severe first)
- src/server/GameServer.ts:255 — Sudden death can never be turned off once enabled in a host lobby. When the host unchecks the toggle, HostLobbyModal.ts:1111 sends suddenDeath: undefined, which JSON.stringify drops entirely; the server's !== undefined guard then keeps the stored {enabled: true} config. The game starts with sudden death active while the host's UI shows it off. Sibling toggles clear via null (waterNukes: this.waterNukes ? true : null with a nullable schema) — suddenDeath needs the same, or send {enabled: false}. (Confirmed independently by four finder angles and direct code reading.)
- src/core/execution/SuddenDeathExecution.ts:51 — A player who dies while flagged keeps the mark forever. clearSuddenDeath() is only ever called on contenders, which filters on isAlive(), and no kill path resets markedSuddenDeathTick. Since suddenDeathTicks() is ticks - markedTick, it changes every tick, so diffPlayerUpdate emits a delta for the dead player on every tick for the rest of the game — and a local player who dies while flagged watches a permanently stuck "Draining" panel with an ever-growing rate while spectating (the sidebar has no alive gate; the name-pass skull is safe because PlayerStatus skips dead players).
- src/client/hud/layers/GameRightSidebar.ts:318 — Spectators and eliminated players get a permanent red-alert pulse. With me null or holding 0 tiles, yourPct is 0, so once the bar rises (requiredTiles > 0), nearDanger is true forever: the panel pulses red showing "Safe … You 0.0%" at someone with no territory stake. Needs a guard for no-player / dead-player states.
- src/core/game/PlayerImpl.ts:320 — Shipping the derived counter suddenDeathTicks defeats per-field diffing. The value changes every tick while flagged, so every skulled player forces 10 update deltas/second across the worker boundary when the client only needs 1 Hz. Sending the constant markedSuddenDeathTick instead (changes exactly on enter/clear) and deriving elapsed ticks client-side eliminates the churn — and would also cap the damage from finding 2.
- src/client/render/gl/passes/name-pass/StatusIconProgram.ts:130 — Runtime canvas compositing of the skull into the atlas adds a silent failure mode. If SuddenDeathSkull.svg 404s (new asset missing from the CDN bucket), the catch {} uploads the bare atlas with no warning, but the shader still activates slot 8 from pd4.w and samples atlas cell 10 — verified fully transparent in the committed PNG — so flagged players get a blank gap that mis-centers their whole status-icon row. The meta already reserves slot 10; baking the skull into the committed status-atlas.png deletes ~50 lines of loader/compositor/fallback (and the as any meta cast) and removes the failure mode.
- src/client/hud/layers/GameRightSidebar.ts:286 — sideTiles() hand-duplicates the sim's side-grouping rules. SuddenDeath.ts exists explicitly so "the sim and the HUD always agree," yet the FFA-vs-team grouping, bot/alive filters, and tile summing are re-implemented in the HUD (and the warn/drain derivation at line 314 is a second copy of the execution's logic). No numeric divergence today on the common path, but any tweak to one copy silently desyncs the displayed bar from who actually gets skulled. Move the side/tiles helper into SuddenDeath.ts.
- src/core/Schemas.ts:262 — The wire schema exposes four tunables nobody sends. warnSeconds, drainStartPercent, drainMaxPercent, drainRampSeconds (plus the cross-field refine) are only ever exercised by tests; both modals send just {enabled, speed}. They become permanent validated wire-protocol surface. Simpler: schema is {enabled, speed}, drain constants stay in SUDDEN_DEATH_DEFAULTS.
- src/client/components/GameConfigSettings.ts:189 — Feature-specific suddenDeathSpeed sentinel on the shared ToggleOptionConfig. renderOptionToggle branches on suddenDeathSpeed !== undefined into a bespoke card that re-implements the existing "toggle card with inner control" pattern (ToggleInputCard, used for Starting Gold). Either render via ToggleInputCard with a select variant or give the config a generic child-control slot, so the shared type stays feature-agnostic.
When the host unchecked Sudden Death the lobby sent suddenDeath: undefined,
which JSON.stringify drops, so the server's "!== undefined" merge kept a
previously-enabled config and the game started with the mode still on. Send
{ enabled: false } instead so the off-state survives the wire and clears it.
Nothing clears markedSuddenDeathTick on death (the execution only touches alive contenders), so a player who died while flagged stayed inSuddenDeath() forever: a stuck "Draining" panel locally and a per-tick PlayerUpdate delta (suddenDeathTicks keeps growing). Gate inSuddenDeath()/suddenDeathTicks() on isAlive() so an eliminated player is cleanly not in sudden death.
The panel is a personal readout, but it rendered whenever the mode was on with no isAlive gate. A spectator or 0-tile player has yourPct 0, so once the bar rose nearDanger stayed true and the panel pulsed red "Safe ... You 0.0%" forever. Return early when there is no alive local player.
diffPlayerUpdate excludes troops/gold/tiles (they travel on the compact stats channel) but compared suddenDeathTicks, which grows every tick, so every flagged player forced a full PlayerUpdate delta 10x/second. Ship the constant markedSuddenDeathTick instead (changes only on enter/clear) and derive elapsed ticks at read time in PlayerView and PlayerStatus. No more per-tick churn.
The zone now spares the side with the most tiles (the crown holder in FFA, the top team otherwise). Sudden death culls the challengers toward the leader, so the leader always keeps its army: the game can never freeze with every side bled to zero, and the final wave squeezes out everyone but the leader for a single winner. Ties broken by side order (deterministic).
The zone topped out at 30% (the FFA median), which up to three sides can hold at once -> a possible top-of-map standoff. Add a 6th wave to 55%: only one side can hold that, so combined with the crown exemption it squeezes out everyone but the leader. Reached one cycle after 30% per preset (normal ~35 min).
…ompositing The skull is now baked into status-atlas.png cell 10, so StatusIconProgram loads the atlas plainly like every other icon. Removes the in-browser compositor/fallback (~50 lines) and its silent failure mode (a missing SVG would have left a blank gap that mis-centered the status-icon row).
Per maintainer review: "Sudden Death" implied instant elimination, but this is a slow squeeze. Rename the mode to Doomsday Clock and the stages Safe/Danger/ Draining to Stable/Unstable/Collapsing. Renames every mention we added — code identifiers, i18n keys, config + wire fields, and the source files/asset — so the feature is one consistent name (no compatibility cost; it is all new).
Moves the ~160-line readout (render + sideStats/teamDisplayName/teamColor helpers + styles) out of game-right-sidebar into a self-contained <doomsday-clock-panel> element. Kept nested inside the sidebar so it still stacks centered under the game timer; it hides itself (display:none) when off, after a winner, or for a spectator/eliminated player.
GameRunner added DoomsdayClockExecution unconditionally (it self-gated in tick). Guard registration on doomsdayClockConfig().enabled, matching the pattern used for the other optional executions.
…filter Per review: the mode is not OFM-specific (reword two comments), and mg.players() already returns only alive players, so the contenders filter no longer needs && p.isAlive(). Mirror that contract in the test's fake game.
The mode is not OFM-specific; reword the schema/config comments to match the two the reviewer already flagged.
The collapsing branch set no detail line, so "Rising to X%" / the wave countdown vanished once troops started draining. Show the zone progress in the collapsing state too (the bar keeps rising as you bleed).
No client ever sends the drain tunables (warnSeconds/drainStartPercent/ drainMaxPercent/drainRampSeconds); they were validated wire surface with no sender. Drop them from DoomsdayClockConfigSchema and source them only from DOOMSDAY_CLOCK_DEFAULTS. This supersedes the reversed-pair refine (12abd55) — with the fields gone from the wire there is nothing to cross-validate.
|
addressed fab 1-5, and 7 |

Resolves Issue #4463
Description:
An optional game mode that (almost) guarantees a finish instead of letting late-game
stalemates drag on.
Once enabled, every side (each player in FFA, each whole team in team modes)
must hold a rising share of the map. A side below the bar is skulled; after a
short warn its troops bleed to zero, forcing consolidation to a winner.
How it works
each level with 30s pauses between (a battle-royale "zone"). Levels track the
ofstats FFA territory median (3/5/10/20/30%).
normal ends ~30 min, very fast ~15.
(10s warn + ~50s ≈ 1 min total).
cues) and an on-map skull above flagged players (blinks in danger, steady while
draining).
Notes for review
src/core), covered by unit +integration tests.
GameServer.updateGameConfigso the setting survives thehost → server → client round-trip.
pd4.w: 0/1/2 =none/danger/draining); the skull is composited into the icon atlas at load.
Testing
npm test,npm run lint,npx prettier --check .,npm run build-prodall pass.UI:
Dropdown between slow, normal, fast, very fast
Before zone:

Zone started, player not affected the pannel also blinks orange for 10s:

Player affected, grace period (Danger):

Skull icon blinking over player (everyone sees it) - older screenshot, the clipping has been fixed

Player affected, grace period ended (Draining):

Skull icon no longer blinking, everyone can see you are in a state of decay, and troops are draining:

Skull is visible like alliances icon also on player tab

(just UI example, best way to see it is to hop on a solo game and play against AI)
Please complete the following:
Please put your Discord username so you can be contacted if a bug or regression is found:
zixer._