Skip to content

feat(game): Sudden Death mode β€” last-place player eliminated per round (#827)#1472

Open
mholzi wants to merge 2 commits into
mainfrom
feat/sudden-death-827
Open

feat(game): Sudden Death mode β€” last-place player eliminated per round (#827)#1472
mholzi wants to merge 2 commits into
mainfrom
feat/sudden-death-827

Conversation

@mholzi

@mholzi mholzi commented Jun 14, 2026

Copy link
Copy Markdown
Owner

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-shotgun pass (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

  1. Wizard toggle defaults ON (issue said False) when β‰₯3 players; auto-disabled + tooltip below that. The dataclass default stays False; the wizard checkbox is what defaults on.
  2. NEW: live toggle on the reveal screen β€” the host can flip Sudden Death on/off mid-game from the Stop/Skip/End control bar, not only in the wizard.
    • ON β†’ eliminations arm starting next round; current round's results stand.
    • OFF β†’ no further cuts; already-eliminated players stay out.

Mechanics

  • Eliminate the lowest round delta (not cumulative) among survivors. Tie-break: slowest submitter is out; a non-submitter counts as slowest. Round 1 never eliminates. Never cuts the last survivor β€” start_round auto-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.
  • New "Last One Standing" superlative for the survivor.

Surfaces

  • Backend (game/, server/): sudden_death_mode config + 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, REVEAL eliminated_this_round, leaderboards).
  • Frontend (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 .min bundles.

Tests / gates

  • 17 new unit tests in test_state.py (elimination, round-delta, tie-break, round-1 skip, auto-end, all-submitted, persistence, live toggle, superlative).
  • βœ… Python unit suite (1116 passed) Β· βœ… JS suite (325 passed) Β· βœ… ruff Β· βœ… mypy Β· βœ… build:check (no .min drift).

Scope notes / follow-ups

  • Server does not hard-reject Sudden Death with <3 players at game-create time (players join the lobby after create_game clears sessions). Guarding is done in the wizard (disabled <3) and the elimination/auto-end logic self-guards. Worth a confirm.
  • Cheer/reaction buttons on the eliminated view were cut from v1 (no existing reaction system to reuse), per the issue's flags.
  • The END winner/podium still ranks by cumulative score; the survivor is marked via the "Last One Standing" superlative + hero. Crowning the survivor as Guess artist functionalityΒ #1 regardless of score is a possible follow-up.

Closes #827

πŸ€– Generated with Claude Code

#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>

@gemini-code-assist gemini-code-assist 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.

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.

Comment on lines +1588 to +1592
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;
}

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.

high

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.

Suggested change
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;
}

Comment on lines +1651 to +1653
var leaderboard = data.leaderboard || [];
var survivors = leaderboard.filter(function(e) { return !e.eliminated; });
var winner = survivors.length ? survivors[0].name : null;

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.

high

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).

Suggested change
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;

Comment on lines +634 to +639
try:
body = await request.json()
except (ValueError, UnicodeDecodeError):
return _json_error("Invalid JSON", 400, code="INVALID_REQUEST")

enabled = bool(body.get("enabled", False))

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.

medium

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.

Suggested change
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))

Comment on lines +2783 to +2784
// Arming below 3 survivors is pointless (2 left = final already / 1 = winner).
btn.disabled = remaining < 3;

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.

medium

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:

  1. 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.
  2. 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.

Suggested change
// 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;

Comment on lines +331 to +332
subEl.textContent = utils.t('game.eliminatedRound', { round: round })
|| ('Eliminated Β· Round ' + round);

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.

medium

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);

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.

medium

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.

Suggested change
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>
@mholzi

mholzi commented Jun 14, 2026

Copy link
Copy Markdown
Owner Author

Follow-ups addressed (commit 73d9c8d)

All three flagged items are now fixed:

  1. Survivor is the winner β€” compute_winners() returns the last player standing regardless of cumulative score once the game resolves (β‰₯1 elimination, one survivor), and get_final_leaderboard() now orders by survival (survivor 1st, then reverse elimination order, score breaking ties). Podium Guess artist functionalityΒ #1, the winner field, and the "Last One Standing" hero all agree. Falls back to score-based when Sudden Death is off or the game ended early.
  2. Server-side <3-player floor β€” start-gameplay auto-disables Sudden Death (returning a warnings[] + sudden_death_disabled flag) when fewer than 3 players are connected. This is the right enforcement point since players join the lobby after create_game clears sessions; the wizard still disables the toggle client-side.
  3. Eliminated players can cheer β€” instead of adding bespoke buttons to the minimal Stark-Blackout view, eliminated spectators now get the existing #reaction-bar during PLAYING (it normally only shows in REVEAL), piggybacking the live-reaction system.

+4 new tests. Gates: βœ… 1120 Python tests Β· βœ… 325 JS Β· βœ… ruff Β· βœ… mypy Β· βœ… build:check.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Sudden Death mode β€” last-place player eliminates per round

1 participant