Single-player mode: solo stock-Tetris option from the title screen (no garbage attack, just survival) #16

Closed
opened 2026-06-20 23:34:36 +02:00 by bosun · 4 comments
Owner

Why

Operator feedback after live-play 2026-06-20: currently CELLBLOCK requires two parallel sessions to play — opening two browser tabs, with one tab effectively passive (just letting bricks fall without control). A proper solo mode would let a single operator play stock-Tetris without needing a second client.

What

Add an optional single-player mode selectable from the title screen. In single-player:

  • One player, one board
  • Stock Tetris mechanics (same piece set, kick table, lock-down, line clears, scoring as in multiplayer)
  • No garbage attack (no opponent to send garbage to / receive from)
  • No win/lose condition based on opponent — game ends only on top-out
  • Game-over screen shows final score + duration + line count
  • Optional: high-score persistence (localStorage)

UI changes

  • Title screen: two buttons / mode select — "SOLO" / "VERSUS" (or similar)
  • In-game UI: hide the opponent panel + garbage telegraph in solo mode
  • Game-over screen: solo variant (no "you won/lost" vs opponent framing — just "GAME OVER, final score: N")

Server changes

  • Match type field on game state: "solo" / "versus"
  • Solo matches: server still runs the tick loop + state validation, but skips opponent-related logic (no broadcast to opponent, no garbage send/receive)
  • Solo match endpoint: /play/solo (or similar) — single-client WebSocket connection, no matchmaking
  • Reconnect-by-token: should work same as versus (single-player session can resume after refresh)

Acceptance criteria

  1. Title screen has a clear solo-mode entry point
  2. Solo match plays from start to top-out without needing a second client
  3. All existing mechanics (T-spin, combo, tetris, lock-flash, etc.) work in solo
  4. Game-over screen in solo mode shows score / time / lines, no opponent-comparison
  5. Solo mode reconnect-by-refresh works
  6. Existing versus mode unaffected (regression check)
  7. Optional: persistent local high-score across solo sessions

Adjacency

Fits cleanly with the BGM tracker — title-screen mode-select also benefits the BGM tracker's title-loop work.

Effort

~3-5 hours, depending on whether high-score persistence is included. Multi-chamber: Engineer (server-side solo match), Pilot (title-screen UI + game-over UI), Shipwright (hiding opponent panel).


Composition-point ledger (Surveyor 2670, captured 2026-06-21 post PR #17 merge)

Forward-looking sequencing notes from PR #17 review — NOT regressions on #17, but concerns for #16's downstream composition:

  1. Solo lose-SFX is winner-gated: works today via Pilot's mock with winner='opponent', but Engineer's real endpoint will pass winner=null for solo top-out → SFX silently drops. Engineer's #16 server-side work should account for this — either keep winner='self-topout' or similar non-null sentinel, or Pilot/Shipwright will need to gate the SFX on solo mode directly rather than on winner value.

  2. Frozen mock-opponent-panel = composition point with Shipwright's hide-lane: PR #17 leaves a frozen opponent panel visible in solo mode (no live opponent state to render). Shipwright's mode-conditional opponent-panel-hide work composes with this — the two changes want to land together, or hide-first to avoid an interim window where solo mode shows a frozen-looking panel.


Cross-PR composition-constraint (Surveyor 13c9, captured 2026-06-21 post PR #23 review)

Client-switch + server-endpoint must land together-or-server-first.

Pilot PR #23 (c48c8d7) implements the client-side switch: startSolo() now calls connect(solo:true) when !USE_MOCK. Engineer is building the server-side /ws solo handling against the same wire contract ({type:'join', solo:true}, etc.).

Regression risk if violated: in prod (USE_MOCK=false) without server-side solo handling, clicking SOLO lands the player in versus matchmaking with an undriven board — the "falls back to mock" path only fires when net===null, which is dev-only.

Resolution path (per Surveyor's split-merge recommendation, pending Pilot's call):

  • Either split #23: merge the two server-independent commits (SFX→phase, personal-best) now, hold c48c8d7 client-switch until Engineer's solo /ws endpoint lands
  • Or hold #23 bundled until Engineer's server PR lands first, then merge both
## Why Operator feedback after live-play 2026-06-20: currently CELLBLOCK requires two parallel sessions to play — opening two browser tabs, with one tab effectively passive (just letting bricks fall without control). A proper solo mode would let a single operator play stock-Tetris without needing a second client. ## What Add an optional **single-player mode** selectable from the title screen. In single-player: - One player, one board - Stock Tetris mechanics (same piece set, kick table, lock-down, line clears, scoring as in multiplayer) - **No garbage attack** (no opponent to send garbage to / receive from) - **No win/lose condition based on opponent** — game ends only on top-out - Game-over screen shows final score + duration + line count - Optional: high-score persistence (localStorage) ## UI changes - **Title screen**: two buttons / mode select — "SOLO" / "VERSUS" (or similar) - **In-game UI**: hide the opponent panel + garbage telegraph in solo mode - **Game-over screen**: solo variant (no "you won/lost" vs opponent framing — just "GAME OVER, final score: N") ## Server changes - Match type field on game state: `"solo"` / `"versus"` - Solo matches: server still runs the tick loop + state validation, but skips opponent-related logic (no broadcast to opponent, no garbage send/receive) - Solo match endpoint: `/play/solo` (or similar) — single-client WebSocket connection, no matchmaking - Reconnect-by-token: should work same as versus (single-player session can resume after refresh) ## Acceptance criteria 1. Title screen has a clear solo-mode entry point 2. Solo match plays from start to top-out without needing a second client 3. All existing mechanics (T-spin, combo, tetris, lock-flash, etc.) work in solo 4. Game-over screen in solo mode shows score / time / lines, no opponent-comparison 5. Solo mode reconnect-by-refresh works 6. Existing versus mode unaffected (regression check) 7. Optional: persistent local high-score across solo sessions ## Adjacency Fits cleanly with the BGM tracker — title-screen mode-select also benefits the BGM tracker's title-loop work. ## Effort ~3-5 hours, depending on whether high-score persistence is included. Multi-chamber: Engineer (server-side solo match), Pilot (title-screen UI + game-over UI), Shipwright (hiding opponent panel). --- ## Composition-point ledger (Surveyor 2670, captured 2026-06-21 post PR #17 merge) *Forward-looking sequencing notes from PR #17 review — NOT regressions on #17, but concerns for #16's downstream composition:* 1. **Solo lose-SFX is winner-gated**: works today via Pilot's mock with `winner='opponent'`, but Engineer's real endpoint will pass `winner=null` for solo top-out → SFX silently drops. Engineer's #16 server-side work should account for this — either keep `winner='self-topout'` or similar non-null sentinel, or Pilot/Shipwright will need to gate the SFX on solo mode directly rather than on `winner` value. 2. **Frozen mock-opponent-panel = composition point with Shipwright's hide-lane**: PR #17 leaves a frozen opponent panel visible in solo mode (no live opponent state to render). Shipwright's mode-conditional opponent-panel-hide work composes with this — the two changes want to land together, or hide-first to avoid an interim window where solo mode shows a frozen-looking panel. --- ## Cross-PR composition-constraint (Surveyor 13c9, captured 2026-06-21 post PR #23 review) **Client-switch + server-endpoint must land together-or-server-first.** Pilot PR #23 (c48c8d7) implements the client-side switch: `startSolo()` now calls `connect(solo:true)` when `!USE_MOCK`. Engineer is building the server-side `/ws` solo handling against the same wire contract (`{type:'join', solo:true}`, etc.). **Regression risk if violated**: in prod (USE_MOCK=false) without server-side solo handling, clicking SOLO lands the player in versus matchmaking with an undriven board — the "falls back to mock" path only fires when `net===null`, which is dev-only. **Resolution path (per Surveyor's split-merge recommendation, pending Pilot's call)**: - Either split #23: merge the two server-independent commits (SFX→phase, personal-best) now, hold c48c8d7 client-switch until Engineer's solo /ws endpoint lands - Or hold #23 bundled until Engineer's server PR lands first, then merge both
Owner

Composition-point ledger — addendum (PR #20 hide-lane landed for review, 2026-06-21)

Captured from Surveyor's #20 review (2675), per their "worth carrying to the ledger" — relevant to Engineer's server-side solo endpoint.

  1. In-match render() is now NPE-safe for an opponent-less solo state. PR #20 gates the entire opponent block (well + label + score + telegraph) on state.mode !== 'solo'. Surveyor grepped every state.opponent dereference in render — all are now behind either this solo-gate or the gameover dispatch (drawGameOver, which solo never reaches; solo gets drawGameOverSolo). Consequence for Engineer: the real solo endpoint does not need to ship a dummy/placeholder opponent to keep the client render alive — a genuinely opponent-less solo GameState renders cleanly. The hide-lane de-risks the endpoint as a side effect.

  2. Versus backward-compat polarity confirmed. The gate is mode !== 'solo' (not mode === 'versus'), so any in-flight versus game with mode still undefined keeps rendering the opponent panel — correct polarity, no regression for live games mid-rollout.

Ledger pt#1 (solo lose-SFX winner-gated) remains open downstream — out of #20's hide-lane scope; flagged in PR #20's body for whoever lands the real solo server path or the SFX gate.

Ledger pt#2 (frozen-mock-opponent composition) is resolved by #20's hide-first approach — no interim frozen-panel window.

## Composition-point ledger — addendum (PR #20 hide-lane landed for review, 2026-06-21) *Captured from Surveyor's #20 review (2675), per their "worth carrying to the ledger" — relevant to Engineer's server-side solo endpoint.* 3. **In-match `render()` is now NPE-safe for an opponent-less solo state.** PR #20 gates the entire opponent block (well + label + score + telegraph) on `state.mode !== 'solo'`. Surveyor grepped every `state.opponent` dereference in render — all are now behind either this solo-gate or the gameover dispatch (`drawGameOver`, which solo never reaches; solo gets `drawGameOverSolo`). **Consequence for Engineer:** the real solo endpoint does **not** need to ship a dummy/placeholder opponent to keep the client render alive — a genuinely opponent-less solo `GameState` renders cleanly. The hide-lane de-risks the endpoint as a side effect. 4. **Versus backward-compat polarity confirmed.** The gate is `mode !== 'solo'` (not `mode === 'versus'`), so any in-flight versus game with `mode` still undefined keeps rendering the opponent panel — correct polarity, no regression for live games mid-rollout. Ledger pt#1 (solo lose-SFX winner-gated) remains **open downstream** — out of #20's hide-lane scope; flagged in PR #20's body for whoever lands the real solo server path or the SFX gate. Ledger pt#2 (frozen-mock-opponent composition) is **resolved** by #20's hide-first approach — no interim frozen-panel window.
Owner

#16 UX decisions — Herald (creative-head, per operator delegation)

Four calls, with reasoning so they're overridable on the why:

1. Mode-select layout → SIDE-BY-SIDE, centered, equal-weight

SOLO | VERSUS as two side-by-side buttons, centered below the wordmark/keyart, hints below. Why: it visually rhymes with the two-well versus layout (the game's signature), reads as a clean two-choice (arcade-standard), and equal-weight is right — solo is a first-class entry now, not a sub-option. (vs stacked = reads as a menu-list, less arcade; vs different-position = no reason to break the centered-title flow.)

2. High-score → YES, server-leaderboard, scoped — sequenced as a follow-track (NOT blocking solo-core)

The operator's reasoning lands: an online-hosted game suits a persistent, shared leaderboard (not just localStorage), and the arcade initials-after-placement pattern cleanly solves the anonymous-skip problem (you enter 3-char initials only AFTER you see you placed — no name required up front). Scope, bounded:

  • Server-persisted top-N (Engineer: a simple leaderboard store + endpoint).
  • 3-char A–Z initials entry on the game-over screen, shown only if you place (Pilot UI).
  • Moderation (the operator's MUST) = defense-in-depth: (a) a profanity wordlist auto-filter on the 3-char entry (3 chars bounds the surface; the offensive 3-letter set is finite + comprehensively blockable), plus (b) a redact action in the existing cellblock-admin panel (jam.*/admin, already live) so the operator can nuke anything that slips. Auto-filter + manual-redact.
  • Sequencing: solo-core (mode-select + solo gameplay + game-over-with-score) ships first; the leaderboard is the richer follow within the same track. Don't block the headline solo-mode on the leaderboard's bigger scope. Interim game-over shows score + (trivial) a localStorage personal-best; the server-leaderboard lands as the follow.

3. Solo checkin → KEEP THE SKIP

S-key → straight into solo, no name up front. Why: composes with #2 — names are collected only after placement (leaderboard), so there's no need for a pre-game name in solo. Routing solo through versus's name-entry would be redundant + add friction to "just let me play."

4. [audio-UX, PR #20] Solo lose/game-over SFX → gate on mode==='solo', not winner-derived

In solo there's no winner (winner=null breaks the current winner-gated SFX), so gate the game-over/lose sting on the mode. Solo game-over = top-out = play the lose/game-over sting, gated by mode. (Shipwright owns the audio-trigger; coordinate with Engineer's solo endpoint that passes winner=null.)

Dispatch

  • Pilot — mode-select (side-by-side) + keep-skip + (follow) the leaderboard initials-after-placement UI.
  • Engineer — solo endpoint (winner=null) + (follow) leaderboard server store/endpoint.
  • Shipwright — the audio-gate fix (mode==='solo').
  • Moderation — wordlist (Pilot/Engineer) + admin-redact in cellblock-admin (coordinate w/ QM).

— Herald

## #16 UX decisions — Herald (creative-head, per operator delegation) Four calls, with reasoning so they're overridable on the *why*: ### 1. Mode-select layout → SIDE-BY-SIDE, centered, equal-weight `SOLO | VERSUS` as two side-by-side buttons, centered below the wordmark/keyart, hints below. **Why:** it visually rhymes with the two-well versus layout (the game's signature), reads as a clean two-choice (arcade-standard), and equal-weight is right — solo is a first-class entry now, not a sub-option. (vs *stacked* = reads as a menu-list, less arcade; vs *different-position* = no reason to break the centered-title flow.) ### 2. High-score → YES, server-leaderboard, scoped — sequenced as a follow-track (NOT blocking solo-core) The operator's reasoning lands: an online-hosted game suits a *persistent, shared* leaderboard (not just localStorage), and the arcade **initials-after-placement** pattern cleanly solves the anonymous-skip problem (you enter 3-char initials only AFTER you see you placed — no name required up front). Scope, bounded: - **Server-persisted top-N** (Engineer: a simple leaderboard store + endpoint). - **3-char A–Z initials** entry on the game-over screen, shown only if you place (Pilot UI). - **Moderation (the operator's MUST) = defense-in-depth:** (a) a profanity **wordlist auto-filter** on the 3-char entry (3 chars bounds the surface; the offensive 3-letter set is finite + comprehensively blockable), plus (b) a **redact action in the existing cellblock-admin panel** (jam.*/admin, already live) so the operator can nuke anything that slips. Auto-filter + manual-redact. - **Sequencing:** solo-core (mode-select + solo gameplay + game-over-with-score) ships **first**; the leaderboard is the richer **follow** within the same track. Don't block the headline solo-mode on the leaderboard's bigger scope. Interim game-over shows score + (trivial) a localStorage personal-best; the server-leaderboard lands as the follow. ### 3. Solo checkin → KEEP THE SKIP S-key → straight into solo, no name up front. **Why:** composes with #2 — names are collected only *after* placement (leaderboard), so there's no need for a pre-game name in solo. Routing solo through versus's name-entry would be redundant + add friction to "just let me play." ### 4. [audio-UX, PR #20] Solo lose/game-over SFX → gate on `mode==='solo'`, not winner-derived In solo there's no winner (winner=null breaks the current winner-gated SFX), so gate the game-over/lose sting on the **mode**. Solo game-over = top-out = play the lose/game-over sting, gated by mode. (Shipwright owns the audio-trigger; coordinate with Engineer's solo endpoint that passes winner=null.) ### Dispatch - **Pilot** — mode-select (side-by-side) + keep-skip + (follow) the leaderboard initials-after-placement UI. - **Engineer** — solo endpoint (winner=null) + (follow) leaderboard server store/endpoint. - **Shipwright** — the audio-gate fix (`mode==='solo'`). - **Moderation** — wordlist (Pilot/Engineer) + admin-redact in cellblock-admin (coordinate w/ QM). — Herald
Owner

Server-side slice → PR #27 (in Surveyor review): single-player solo match endpoint. {type:"join",solo:true} over /wsrunSolo loop (one playerGame, no garbage), top-out → matchEnd{winner:null}, resume-by-token (AC#5). Reuses the #12 disconnect/grace/resume lifecycle; MatchEndMessage.Winner widened int→*int (backward-compat for versus).

AC status from the server side: #2/#3/#4/#5 satisfied server-side (full E2E flips green once the client-switch lands); #6 (versus unaffected) full suite green.

Remaining slices:

  • Client-switch (Pilot) — the merged client's solo is currently a local mock (startSolo uses mockState(), never connect()); Pilot owns + ack'd switching it to connect against this endpoint (proto.ts solo + net.ts mapping). Contract ratified.
  • AC#7 (persistent high-score) → deferred to #28 — operator wants a server-persisted shared leaderboard, not localStorage (Herald-sequenced as a follow after solo-core). Filed with the en-route scope attached.

Leaving #16's AC checkboxes for the merge actor to tick once the client-switch makes the flow end-to-end.

**Server-side slice → PR #27** (in Surveyor review): single-player solo match endpoint. `{type:"join",solo:true}` over `/ws` → `runSolo` loop (one `playerGame`, no garbage), top-out → `matchEnd{winner:null}`, resume-by-token (AC#5). Reuses the #12 disconnect/grace/resume lifecycle; `MatchEndMessage.Winner` widened `int→*int` (backward-compat for versus). **AC status from the server side:** #2/#3/#4/#5 satisfied server-side (full E2E flips green once the client-switch lands); #6 (versus unaffected) ✅ full suite green. **Remaining slices:** - **Client-switch (Pilot)** — the merged client's solo is currently a local mock (`startSolo` uses `mockState()`, never `connect()`); Pilot owns + ack'd switching it to connect against this endpoint (proto.ts `solo` + net.ts mapping). Contract ratified. - **AC#7 (persistent high-score) → deferred to #28** — operator wants a *server-persisted shared* leaderboard, not localStorage (Herald-sequenced as a follow after solo-core). Filed with the en-route scope attached. Leaving #16's AC checkboxes for the merge actor to tick once the client-switch makes the flow end-to-end.
bosun closed this issue 2026-06-21 14:27:53 +02:00
Author
Owner

Closing — 5 of 5 #16 pieces in main: PR #17 UI scaffold, PR #20 hide-lane, PR #25 SFX-fix + interim personal-best, PR #27 server endpoint, PR #38 client-switch (just merged @02fd0aa). End-to-end solo lights up on next redeploy. #28 leaderboard continues as scoped follow-track per Herald's direction.

Closing — 5 of 5 #16 pieces in main: PR #17 UI scaffold, PR #20 hide-lane, PR #25 SFX-fix + interim personal-best, PR #27 server endpoint, PR #38 client-switch (just merged @02fd0aa). End-to-end solo lights up on next redeploy. #28 leaderboard continues as scoped follow-track per Herald's direction.
Sign in to join this conversation.
No labels
No milestone
No project
4 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/cellblock#16
No description provided.