feat(game): Sudden Death mode β last-place player eliminated per round (#827)#1472
feat(game): Sudden Death mode β last-place player eliminated per round (#827)#1472mholzi wants to merge 2 commits into
Conversation
#827) New opt-in mode where the lowest round-scoring player is eliminated each round from round 2 on, until one player is left standing. Can be set in the wizard (defaults ON with >=3 players) or toggled live by the host from the reveal-screen control bar. Backend (game/): - GameState.sudden_death_mode (config + create_game + rematch-preserved). - PlayerSession.eliminated / eliminated_round (persist across rounds, reset per game). - Elimination after scoring in _end_round_unlocked: lowest round delta, tie-break by slowest submission (non-submitter = slowest); round 1 never eliminates; never cuts the last survivor. - start_round auto-ends the game when one player remains. - all_submitted / early-reveal ignore eliminated players. - set_sudden_death(enabled) live toggle + /beatify/api/sudden-death view. - "Last One Standing" superlative for the survivor. - WS state: sudden_death_mode, players[].eliminated/eliminated_round, REVEAL eliminated_this_round, leaderboards carry eliminated state. Frontend (www/): - Wizard mode card (default ON, disabled + tooltip when <3 players). - Player: "You're out" eliminated view + leaderboard skull/dim; submit gated. - Admin: reveal-screen live toggle, elimination + FINAL arc chips, submission tracker + leaderboard skip/dim eliminated. - Dashboard TV: full-bleed "OUT" takeover, FINAL banner, "Last One Standing" trophy hero; leaderboard dim. - i18n keys across en/de/es/fr/nl; regenerated .min bundles. Tests: 17 new unit tests in test_state.py (elimination, tie-break, round-1 skip, auto-end, all-submitted, persistence, live toggle, superlative). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request implements a new "Sudden Death" game mode where the lowest-scoring player is eliminated each round. The implementation spans backend state management, lifecycle hooks, API endpoints, and comprehensive frontend updates for the player, admin, and dashboard views, along with unit tests. The review feedback identifies several important issues: a bug where the elimination takeover key is not reset on rounds without eliminations (preventing it from firing in rematches), an incorrect winner assignment if multiple survivors remain, a potential server crash if a non-dictionary JSON payload is sent to the sudden-death API, an overly restrictive disable threshold for the live toggle when two players remain, and incorrect argument ordering in translation helper calls.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if (!names.length) { | ||
| // No elimination this round β make sure the key resets so a future | ||
| // elimination with the same names (different round) still fires. | ||
| return; | ||
| } |
There was a problem hiding this comment.
The comment states that the key should be reset when there is no elimination this round, but the code simply returns without resetting sdLastOutKey.
Without resetting sdLastOutKey, if a game is rematched and the same player is eliminated in the same round of the new game (e.g., round 2), the key will match the cached sdLastOutKey from the previous game, and the "OUT" takeover will fail to fire.
Set sdLastOutKey = null; before returning to ensure the key is properly reset.
| if (!names.length) { | |
| // No elimination this round β make sure the key resets so a future | |
| // elimination with the same names (different round) still fires. | |
| return; | |
| } | |
| if (!names.length) { | |
| // No elimination this round β make sure the key resets so a future | |
| // elimination with the same names (different round) still fires. | |
| sdLastOutKey = null; | |
| return; | |
| } |
| var leaderboard = data.leaderboard || []; | ||
| var survivors = leaderboard.filter(function(e) { return !e.eliminated; }); | ||
| var winner = survivors.length ? survivors[0].name : null; |
There was a problem hiding this comment.
If a Sudden Death game ends by reaching the maximum number of rounds rather than by 1v1 elimination, there can be multiple survivors left (e.g., survivors.length > 1).
Currently, winner is set to survivors[0].name as long as survivors.length is non-zero. This means the highest-scoring survivor will be crowned as the "Last One Standing" even though other players also survived.
To prevent this incorrect display, winner should only be extracted from the leaderboard if there is exactly one survivor (survivors.length === 1). Otherwise, it should remain null (or fall back to the superlative, which also correctly guards for exactly one survivor).
| var leaderboard = data.leaderboard || []; | |
| var survivors = leaderboard.filter(function(e) { return !e.eliminated; }); | |
| var winner = survivors.length ? survivors[0].name : null; | |
| var leaderboard = data.leaderboard || []; | |
| var survivors = leaderboard.filter(function(e) { return !e.eliminated; }); | |
| var winner = survivors.length === 1 ? survivors[0].name : null; |
| try: | ||
| body = await request.json() | ||
| except (ValueError, UnicodeDecodeError): | ||
| return _json_error("Invalid JSON", 400, code="INVALID_REQUEST") | ||
|
|
||
| enabled = bool(body.get("enabled", False)) |
There was a problem hiding this comment.
If the request contains a valid JSON payload that is not an object (e.g., a list [] or a string "enabled"), await request.json() will succeed but return a non-dict object. Calling body.get(...) on it will then raise an AttributeError, resulting in an unhandled 500 Internal Server Error.
Add a type check to ensure body is a dictionary before accessing its keys.
| try: | |
| body = await request.json() | |
| except (ValueError, UnicodeDecodeError): | |
| return _json_error("Invalid JSON", 400, code="INVALID_REQUEST") | |
| enabled = bool(body.get("enabled", False)) | |
| try: | |
| body = await request.json() | |
| except (ValueError, UnicodeDecodeError): | |
| return _json_error("Invalid JSON", 400, code="INVALID_REQUEST") | |
| if not isinstance(body, dict): | |
| return _json_error("Invalid request body", 400, code="INVALID_REQUEST") | |
| enabled = bool(body.get("enabled", False)) |
| // Arming below 3 survivors is pointless (2 left = final already / 1 = winner). | ||
| btn.disabled = remaining < 3; |
There was a problem hiding this comment.
The live toggle is disabled when there are fewer than 3 survivors (remaining < 3). However, if there are exactly 2 survivors left, toggling Sudden Death is still highly relevant:
- If it is currently ON, the host might want to turn it OFF to prevent the game from ending on the next round and allow the remaining 2 players to continue playing.
- If it is currently OFF, the host might want to turn it ON to make the next round a decisive "winner-takes-all" final round.
Disabling the toggle at 2 players unnecessarily restricts these valid host choices. Consider changing the disable threshold to remaining < 2 instead.
| // Arming below 3 survivors is pointless (2 left = final already / 1 = winner). | |
| btn.disabled = remaining < 3; | |
| // Only disable the toggle if there are fewer than 2 survivors left. | |
| btn.disabled = remaining < 2; |
| subEl.textContent = utils.t('game.eliminatedRound', { round: round }) | ||
| || ('Eliminated Β· Round ' + round); |
There was a problem hiding this comment.
The current call to utils.t passes the variables object { round: round } as the second argument. If the translation system's signature is t(key, fallback, variables), this treats the variables object as the fallback string, which could result in [object Object] being displayed if the translation key is missing.
Additionally, if utils.t returns the key name (e.g., 'game.eliminatedRound') when a translation is missing, the || fallback will never be reached because the returned key is truthy.
To be safe and idiomatic, pass the fallback string as the second argument and the variables as the third argument.
subEl.textContent = utils.t('game.eliminatedRound', 'Eliminated Β· Round {round}', { round: round });| // not steal/bet badges (they're no longer playing this round). | ||
| if (isEliminated) { | ||
| var round = (player.eliminated_round != null) ? player.eliminated_round : ''; | ||
| var outText = utils.t('game.outRound', { round: round }) || ('Out Β· R' + round); |
There was a problem hiding this comment.
Similar to the game.eliminatedRound translation call, pass the fallback string as the second argument and the variables as the third argument to ensure robust translation fallback and interpolation.
| var outText = utils.t('game.outRound', { round: round }) || ('Out Β· R' + round); | |
| var outText = utils.t('game.outRound', 'Out Β· R{round}', { round: round }); |
β¦#827) - Survivor is the winner: compute_winners() returns the last player standing (regardless of cumulative score) once the game resolves; get_final_leaderboard orders by survival (survivor 1st, then reverse elimination order), so the podium and the "Last One Standing" hero agree. - Server-side <3-player floor: start-gameplay auto-disables Sudden Death (with a warning) when fewer than 3 players are connected β players join the lobby after create_game, so this is the correct enforcement point. - Eliminated players can cheer: surface the existing reaction bar during PLAYING for eliminated spectators (piggybacks the live-reaction system; no new UI, keeps the minimal Stark Blackout view). - 4 new tests (survivor wins, score fallback, survival-order leaderboard). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-ups addressed (commit 73d9c8d)All three flagged items are now fixed:
+4 new tests. Gates: β 1120 Python tests Β· β 325 JS Β· β ruff Β· β mypy Β· β build:check. |
What & why
Implements Sudden Death mode (#827): an opt-in mode where the lowest round-scoring player is eliminated each round (from round 2 on) until one player is left standing. Adds the tension arc and clear finale the open-ended scoring lacked.
Design directions came from a
/design-shotgunpass (7 screen elements Γ variants); picks: wizard in-row toggle, player "Stark Blackout" eliminated view, hard cut-line leaderboard, full-bleed "OUT" TV takeover, arena FINAL, "Last One Standing" trophy hero, admin control-bar live toggle.Two changes vs. the issue as filed
False) when β₯3 players; auto-disabled + tooltip below that. The dataclass default staysFalse; the wizard checkbox is what defaults on.Stop/Skip/Endcontrol bar, not only in the wizard.Mechanics
start_roundauto-ends the game when one remains.all_submitted/ early-reveal ignore eliminated players. Eliminated state persists across rounds + pause/resume, resets on a new game/rematch.Surfaces
game/,server/):sudden_death_modeconfig +create_game,PlayerSession.eliminated/eliminated_round, elimination in_end_round_unlocked,set_sudden_death()+POST /beatify/api/sudden-death, WS state (sudden_death_mode,players[].eliminated, REVEALeliminated_this_round, leaderboards).www/): wizard mode card, player eliminated view + leaderboard cut-line (submit gated), admin live toggle + elimination/FINAL chips, dashboard TV "OUT"/FINAL/Last-One-Standing, i18n en/de/es/fr/nl, regenerated.minbundles.Tests / gates
test_state.py(elimination, round-delta, tie-break, round-1 skip, auto-end, all-submitted, persistence, live toggle, superlative).ruffΒ· βmypyΒ· βbuild:check(no.mindrift).Scope notes / follow-ups
create_gameclears sessions). Guarding is done in the wizard (disabled <3) and the elimination/auto-end logic self-guards. Worth a confirm.Closes #827
π€ Generated with Claude Code