Phase 7: Minigames Set 3 — Sequence Sam, Human Keyboard, Cursor Curling, Human Tetris¶
Depends on: Phase 4 (Minigame Engine & Lifecycle), Phase 5 (first minigame set establishes implementation patterns: BaseMinigame extensions, registry registration, constants, Zod schemas, data pipelines, client components, and
buildGameLog())Parallelizable with: Phase 6, Phase 8 — after Phase 5 is complete, Phases 6, 7, and 8 can be implemented in parallel since they share no inter-dependencies. Each phase independently extends
BaseMinigame, registers games in the shared registry, and follows the patterns established in Phase 5.This phase implements the third set of four minigames for RMHbox. Each game extends
BaseMinigamefrom Phase 4. Notable in this phase: Cursor Curling includes a custom server-side 2D physics simulation, and Human Keyboard and Human Tetris are cooperative games.
Table of Contents¶
7.1 Sequence Sam¶
Game ID: sequence-sam | Category: action | Icon: grid-3x3
Players: 2–16 | Duration: ~120s (up to 20 rounds)
7.1.1 Install NPM Packages¶
No additional NPM packages required for Sequence Sam
The game is pure algorithmic logic: sequence generation, rotation mapping, input validation. Verification: Confirm no new dependencies needed.
7.1.2 Add Constants to lib/rmhbox/constants.ts¶
Add
SS_MAX_ROUNDS = 20— maximum number of roundsAdd
SS_STARTING_LENGTH = 3— initial sequence lengthAdd
SS_MAX_STRIKES = 3— strikes before eliminationAdd
SS_CHAOS_INTERVAL = 4— every 4th round is a Chaos RoundAdd
SS_TILE_FLASH_DURATION_MS = 500— how long each tile lights up during pattern displayAdd
SS_TILE_GAP_MS = 200— gap between tile flashesAdd
SS_INPUT_TIME_PER_STEP_MS = 1500— input time allowed per step in the sequenceAdd
SS_ROUND_RESULTS_SECONDS = 2— round results display durationAdd
SS_TRANSITION_SECONDS = 1— transition between roundsAdd
SS_SURVIVE_POINTS = 50— points for surviving a roundAdd
SS_PERFECT_ROUND_BONUS = 25— bonus for zero mistakes in a roundAdd
SS_CHAOS_SURVIVE_BONUS = 50— bonus for surviving a Chaos RoundAdd
SS_SPEED_BONUS_PER_MS = 0.05— bonus per ms under time limitAdd
SS_WINNER_BONUS = 200— points for last player standingAdd
SS_PLACEMENT_POINTS = 20— points per placement rankAdd
SS_GRID_SIZE = 9— total grid cells (3×3)Add
SS_GRID_COLS = 3— grid column countAdd
ROTATION_MAP_CW— rotation mapping for 90° clockwise:const ROTATION_MAP_CW: Record<number, number> = { 0: 2, 1: 5, 2: 8, 3: 1, 4: 4, 5: 7, 6: 0, 7: 3, 8: 6, };
Verification: Import all
SS_*constants; confirm correct types. VerifyROTATION_MAP_CWmaps all 9 positions. Verify center (4) maps to itself.
7.1.3 Define Zod Validation Schemas¶
Create
lib/rmhbox/sequence-sam/schemas.tsDefine
SSTapSchema:const SSTapSchema = z.object({ position: z.number().int().min(0).max(8), });
Verification: Valid:
{ position: 0 },{ position: 8 }. Invalid:{ position: 9 },{ position: -1 },{ position: 4.5 }.
7.1.4 Implement Server Handler¶
Create
server/rmhbox/minigames/sequence-sam.ts
7.1.4.1 Type Definitions¶
Define
SSPhasetype:type SSPhase = 'PATTERN_DISPLAY' | 'INPUT' | 'ROUND_RESULTS' | 'TRANSITION' | 'GAME_OVER';
Verification: Type has exactly 5 values matching spec.
Define
SSPlayerStatetype:type SSPlayerState = { userId: string; strikesRemaining: number; isEliminated: boolean; eliminatedOnRound: number | null; currentInputIndex: number; hasCompletedSequence: boolean; hasFailed: boolean; failedAtIndex: number | null; inputStartedAt: number | null; completedAt: number | null; totalScore: number; roundScore: number; };
Define
SequenceSamStatetype:type SequenceSamState = { currentRound: number; maxRounds: number; sequence: number[]; rotatedSequence: number[] | null; isChaosRound: boolean; phase: SSPhase; playerStates: Map<string, SSPlayerState>; eliminatedPlayers: string[]; activePlayers: string[]; phaseStartedAt: number; phaseEndsAt: number; currentDisplayStep: number; };
Verification: All types compile. Cross-reference against spec §1.4.
7.1.4.2 Class: SequenceSamGame extends BaseMinigame¶
Constructor: call
super(context)Verification: Instantiate class; confirm no errors.
7.1.4.3 State Initialization (start())¶
Initialize
playerStatesmap: for each player, setstrikesRemaining = SS_MAX_STRIKES,isEliminated = false,totalScore = 0Set
activePlayers = [...context.players],eliminatedPlayers = []Set
currentRound = 0,maxRounds = SS_MAX_ROUNDSInitialize
sequence = [](will be built on first round)Call
startNextRound()Verification: Unit test with 4 players: all have 3 strikes, all active.
7.1.4.4 Sequence Generation¶
extendSequence():If
sequence.length === 0: generate initial sequence of lengthSS_STARTING_LENGTH(3 random positions 0–8, no consecutive duplicates)Otherwise: append 1 new random position (not the same as the last position in the sequence)
If current round is a Chaos Round (
currentRound % SS_CHAOS_INTERVAL === 0):Compute
rotatedSequenceby mapping each position throughROTATION_MAP_CWSet
isChaosRound = true
Otherwise:
rotatedSequence = null,isChaosRound = falseVerification: Unit test: round 1 → sequence length 3, no consecutive duplicates. Round 2 → length 4, extends previous. Round 4 → chaos,rotatedSequencecomputed. Center (4) stays center in rotation.
7.1.4.5 Round Lifecycle¶
startNextRound():Increment
currentRoundIf
currentRound > maxRoundsORactivePlayers.length <= 1, callendGame(); returnCall
extendSequence()Reset each active player’s round state:
currentInputIndex = 0,hasCompletedSequence = false,hasFailed = false,failedAtIndex = null,roundScore = 0Set
phase = 'PATTERN_DISPLAY',currentDisplayStep = 0Emit
SS_ROUND_STARTto all:{ round: currentRound, sequenceLength: sequence.length, isChaosRound }Begin pattern display sequence Verification: Round starts correctly. Player states reset. Event emitted.
displayPattern():Emit
SS_PATTERN_STEPevents one at a time with proper timing:For step
i: emit afteri × (SS_TILE_FLASH_DURATION_MS + SS_TILE_GAP_MS)msPayload:
{ step: i, position: sequence[i], totalSteps: sequence.length }
CRITICAL: Send one step at a time via scheduled timers, NOT the entire sequence at once
After all steps displayed, wait
SS_TILE_FLASH_DURATION_MS(last tile’s display time)If
isChaosRound: emitSS_GRID_ROTATEwith{ degrees: 90 }; wait 500ms for rotation animationEmit
SS_PATTERN_COMPLETEwith{ rotated: isChaosRound }Call
startInputPhase()Verification: Unit test: sequence of length 5 → 5SS_PATTERN_STEPevents emitted with correct timing. Chaos round → rotate event before input phase. Raw sequence array is NOT in any event payload.
startInputPhase():Set
phase = 'INPUT'Compute input time:
sequence.length × SS_INPUT_TIME_PER_STEP_MSSet
phaseEndsAt = Date.now() + inputTimeRecord
inputStartedAtfor each active playerStart
TIMER_TICKinterval (1s)Schedule
endInputPhase()after input time expires Verification: Input time scales with sequence length. Timer ticks.
endInputPhase():Stop timer
Any active player who hasn’t completed or failed the sequence → mark as failed (timeout)
Call
processRoundResults()Verification: Timeout players marked as failed.
processRoundResults():Set
phase = 'ROUND_RESULTS'For each active player:
If
hasCompletedSequence:Award
SS_SURVIVE_POINTSIf
hasFailed === false(no wrong taps at all): awardSS_PERFECT_ROUND_BONUSIf
isChaosRound: awardSS_CHAOS_SURVIVE_BONUSSpeed bonus:
floor((phaseEndsAt - completedAt) × SS_SPEED_BONUS_PER_MS)— ms remaining at completion
If
hasFailed:Deduct 1 strike
If
strikesRemaining <= 0:Set
isEliminated = true,eliminatedOnRound = currentRoundMove from
activePlayerstoeliminatedPlayersEmit
SS_ELIMINATION:{ userId, userName, finalRank: activePlayers.length + eliminatedPlayers.length - ... }
Grace Rule: If ALL remaining active players failed the round, NONE are eliminated (no strikes deducted). This prevents anticlimactic simultaneous elimination on a hard round.
Compute ranks for eliminated players this round (shared rank if multiple eliminated same round)
Award
SS_PLACEMENT_POINTS × (totalPlayers - rank + 1)for eliminated playersBuild
SSRoundSurvivor[]andSSRoundEliminated[]Emit
SS_ROUND_RESULTS:{ survivors, eliminated, roundNumber }If
activePlayers.length <= 1:Award
SS_WINNER_BONUSto the remaining player (if exactly 1)Schedule
endGame()afterSS_ROUND_RESULTS_SECONDS
Else: schedule
startTransition()afterSS_ROUND_RESULTS_SECONDSVerification: Unit test: 4 players (2 complete, 1 fail, 1 timeout) → 2 survive with points, 2 lose strikes. Grace rule: all 3 active fail → no eliminations. Last player standing → gets winner bonus.
startTransition():Set
phase = 'TRANSITION'Schedule
startNextRound()afterSS_TRANSITION_SECONDSVerification: Next round starts after transition.
7.1.4.6 Input Handling — SS_TAP¶
Validate phase is
'INPUT'; reject if notValidate sender is in
activePlayersand not eliminated; reject if notParse through
SSTapSchemaCheck player hasn’t already completed or failed this round; reject if so
Determine expected position:
If
isChaosRound: userotatedSequence[player.currentInputIndex]Else: use
sequence[player.currentInputIndex]
Compare tapped
positionagainst expected:Correct:
Increment
player.currentInputIndexEmit
SS_TAP_RESULTto tapper ONLY:{ position, correct: true, currentIndex, sequenceLength }If
currentInputIndex === sequence.length:Set
hasCompletedSequence = true,completedAt = Date.now()Emit
SS_PLAYER_COMPLETEto ALL:{ userId, userName, timeMs }If ALL active players have completed or failed, immediately call
endInputPhase()
Incorrect:
Set
hasFailed = true,failedAtIndex = player.currentInputIndexEmit
SS_TAP_RESULTto tapper ONLY:{ position, correct: false, currentIndex, sequenceLength }Emit
SS_PLAYER_FAILEDto ALL:{ userId, userName, failedAtIndex }If ALL active players have completed or failed, immediately call
endInputPhase()Verification: Unit test: correct tap → index advances. Wrong tap → immediate fail. Chaos round → rotated position used for validation. All finished → early phase end.
7.1.4.7 getStateForPlayer(userId)¶
During PATTERN_DISPLAY:
{ currentRound, isChaosRound, sequenceLength: sequence.length, phase, currentDisplayStep, myStrikesRemaining, otherPlayers: [...], scores: [...] }
Raw
sequencearray is NEVER included
During INPUT:
{ currentRound, isChaosRound, sequenceLength, phase, timeRemaining, myInputIndex, myHasCompleted, myHasFailed, myStrikesRemaining, otherPlayers: Array<{ userId, userName, hasCompleted, hasFailed, strikesRemaining, isEliminated }>, scores: [...] }
Other players’
currentInputIndexis NOT visible (only completed/failed status)
During ROUND_RESULTS: include full
survivors[]andeliminated[]arraysCRITICAL: The raw
sequenceandrotatedSequencearrays are NEVER sent to any client. Players only see tile flashes via individualSS_PATTERN_STEPevents. Verification: Unit test: sequence array absent from all player states. Other players’ input progress masked.
7.1.4.8 getStateForSpectator()¶
Same as player but with additional data:
Each player’s
currentInputIndexand which tiles they’ve tapped — spectators watch attempts in real-timeStill NO raw sequence in the state Verification: Spectator sees per-player tap progress.
7.1.4.9 Join-in-Progress Handling¶
Policy:
spectate_onlySequence is cumulative; JIP player would lack context for early steps
Send spectator state on join Verification: JIP → spectator only.
7.1.4.10 Reconnection Handling (handlePlayerReconnect(userId))¶
If PATTERN_DISPLAY still active: player re-sees pattern from current step onward (missed earlier steps is natural penalty)
If INPUT phase: player can continue from their
currentInputIndexStrike count and elimination status preserved
If eliminated: reconnect as spectator
Send current timer tick with accurate time remaining Verification: Reconnect during INPUT → can continue inputting. Reconnect after elimination → spectator.
7.1.4.11 Disconnect Handling (handlePlayerDisconnect(userId))¶
If INPUT phase and player hasn’t completed/failed → they’ll timeout at phase end (treated as fail)
No special cleanup Verification: Disconnect → timeout → fail → strike deducted.
7.1.4.12 computeResults() and Awards¶
Compute final rankings: last standing → rank 1, then by elimination order (later = higher rank)
Players eliminated on the same round share rank
Final score = cumulative
totalScoreCompute awards:
Memory Master — last player standing (winner); icon:
brainPerfect Memory — most perfect (no-mistake) rounds; icon:
check-circleChaos Survivor — survived the most Chaos Rounds; icon:
rotate-ccwSpeed Demon — fastest average completion time across survived rounds; icon:
zapIron Will — survived a round with 1 strike remaining (0 left after that round); icon:
shield
Return
MinigameResultswith rankings, awards, andgameSpecificDataVerification: Unit test: scenarios for each award. Ties handled correctly.
7.1.4.13 buildGameLog()¶
Maintain an
actionLog: GameLogAction[]array on the game instanceBuild
GameLogconforming to core.md §13.3, includinggameSettingsper §12A.11
initialState (from minigames-3.md §1.14):
interface SSInitialState {
gridSize: number;
maxStrikes: number;
chaosInterval: number;
playerCount: number;
tileFlashDurationMs: number;
inputTimePerStepMs: number;
gameSettings: GameSettingValues;
}
Actions Logged:
Action Type |
Payload |
Recorded When |
|---|---|---|
|
|
Start of each round |
|
|
When a chaos round triggers a tile remap |
|
|
After all inputs are evaluated |
|
|
When a player is eliminated |
|
|
Game over |
In
computeResults(), buildGameLogwithinitialState, full action log, andfinalResultsReturn
GameLogfrombuildGameLog()Verification: Unit test: 8-player game lasting 10 rounds, verify log containsround_start/round_resultper round,eliminationfor each elimination,initialStatehas grid and chaos config,finalResultsmatches placements.
7.1.5 Register Game in Minigame Registry¶
Add entry to
lib/rmhbox/minigame-registry.ts:{ id: "sequence-sam", displayName: "Sequence Sam", description: "Remember the pattern, repeat it perfectly! Chaos Rounds rotate the grid to test your spatial reasoning. Last one standing wins.", category: "action", icon: "grid-3x3", minPlayers: 2, maxPlayers: 16, estimatedDurationSeconds: 120, supportsTeams: false, instructionDurationSeconds: 15, preloadAssets: { images: [], sounds: [], data: [], estimatedSizeBytes: 0 }, joinInProgressPolicy: "spectate_only", tags: ["action", "memory", "speed", "competitive"], }
Verification: Registry lookup for
"sequence-sam"returns correct metadata.Add server handler to
MINIGAME_SERVER_REGISTRYinserver/rmhbox/minigame-server-registry.ts:import { SequenceSamGame } from './minigames/sequence-sam'; MINIGAME_SERVER_REGISTRY.set('sequence-sam', SequenceSamGame);
Verification:
MINIGAME_SERVER_REGISTRY.get('sequence-sam')returnsSequenceSamGameclass.Add lazy-loaded component to
MinigameRenderermap:'sequence-sam': lazy(() => import('./minigames/sequence-sam/SequenceSamGame'))
Verification:
MinigameRendererrenders<Suspense>fallback, thenSequenceSamGamecomponent.
7.1.6 Build Client Components¶
7.1.6.1 components/rmhbox/minigames/sequence-sam/SequenceSamGame.tsx¶
Phase router — renders based on
phaseSubscribe to all
SS_*andTIMER_TICKeventsMaintain local state: round, phase, chaos status, strikes, progress, player statuses
Handle
SS_ROUND_START→ reset round state, prepare gridHandle
SS_PATTERN_STEP→ flash tile at given position forSS_TILE_FLASH_DURATION_MSHandle
SS_GRID_ROTATE→ trigger 90° rotation animation on the gridHandle
SS_PATTERN_COMPLETE→ enable input on grid tilesHandle
SS_TAP_RESULT→ flash tile green (correct) or red (incorrect)Handle
SS_PLAYER_COMPLETE→ update player statusHandle
SS_PLAYER_FAILED→ update player statusHandle
SS_ROUND_RESULTS→ show survivors and eliminatedHandle
SS_ELIMINATION→ show elimination banner if self eliminatedHandle
SS_GAME_OVER→ show final resultsConditional rendering:
PATTERN_DISPLAY→<GridDisplay />with animated flashes (non-interactive)INPUT→<GridDisplay />interactive (tappable tiles)ROUND_RESULTS→ results overlayGAME_OVER→<SequenceSamResults />Verification: Component renders for each phase. Grid flashes play correctly. Tap handling works.
7.1.6.2 components/rmhbox/minigames/sequence-sam/GridDisplay.tsx¶
3×3 CSS Grid layout with
GridTilecomponentsDuring PATTERN_DISPLAY: tiles flash in sequence (non-interactive)
During INPUT: tiles are tappable
Chaos Round: grid rotation animation (CSS transform
rotate(90deg)with smooth Framer Motion transition)Responsive sizing: grid fills available width on mobile
Min tap target: 80px per tile on mobile Verification: Grid renders 9 tiles. Flashes animate correctly. Rotation is smooth.
7.1.6.3 components/rmhbox/minigames/sequence-sam/GridTile.tsx¶
Individual tile states:
default,flashing(yellow/white glow during pattern display),tapped-correct(green),tapped-incorrect(red),disabled(after round fail)On tap: emit
rmhbox:game:inputwith{ action: "SS_TAP", data: { position } }Visual feedback: brief scale animation on tap
Accessibility: aria-label with tile position Verification: All states render correctly. Tap emits event.
7.1.6.4 components/rmhbox/minigames/sequence-sam/StrikeIndicator.tsx¶
Display hearts (❤️) for remaining strikes, black hearts (🖤) for lost strikes
Animate heart loss (shake + fade to black)
Accept
strikesRemainingandmaxStrikesas props Verification: 3 hearts → lose 1 → 2 red + 1 black. Animation plays.
7.1.6.5 components/rmhbox/minigames/sequence-sam/ChaosOverlay.tsx¶
“🌀 CHAOS ROUND!” announcement overlay
Appears at round start for Chaos Rounds
Brief animation (shake/spin effect), auto-dismisses after 1.5s Verification: Only appears on Chaos Rounds. Dismisses automatically.
7.1.6.7 components/rmhbox/minigames/sequence-sam/SequenceSamResults.tsx¶
Final rankings table: rank, player name, total score, rounds survived, perfect rounds, chaos rounds survived
Winner highlighted with crown icon
Awards display Verification: Rankings correct. Awards shown.
7.1.6.8 Sound Effects¶
Wire up sound effects using
playSound()for each Sequence Sam event:SS_ROUND_START→playSound('goFanfare')SS_PATTERN_STEP→playSound('click')SS_PATTERN_COMPLETE→playSound('swoosh')SS_GRID_ROTATE(chaos round) →playSound('swoosh')SS_TAP_RESULTcorrect →playSound('scoreDing')SS_TAP_RESULTwrong /SS_PLAYER_FAILED→playSound('buzzer')SS_ELIMINATION→playSound('buzzer')SS_GAME_OVER→playSound('victoryFanfare')Verification: Each event triggers the correct sound exactly once. No overlapping duplicate sounds.
7.1.6.9 Zustand Store Integration¶
Read grid state from
publicStateRead own strike count and input index from
privateState(raw sequence never exposed)Detect spectator mode → render read-only grid with progress indicators Verification: Players see grid + own strikes. Spectators see grid + all players’ progress. Raw sequence never sent to client.
7.1.7 Integration Testing¶
End-to-end test: 4 players → play through multiple rounds
Verify sequence grows by 1 each round (3, 4, 5, 6…)
Verify sequence is cumulative (each round extends the previous)
Verify no consecutive duplicate positions in sequence
Verify Chaos Round on rounds 4, 8, 12, 16, 20
Verify
ROTATION_MAP_CWapplied correctly (e.g., original position 0 → tapped position 2 in chaos)Verify strike system: 3 strikes per player, elimination on 0
Verify Grace Rule: all active players fail → no eliminations
Verify scoring: survive + perfect bonus + chaos bonus + speed bonus + winner bonus Verification: All assertions pass.
Information masking test:
Raw
sequencearray NEVER in any WebSocket event or player statePattern is delivered only via timed
SS_PATTERN_STEPevents (one at a time)Other players’
currentInputIndexnot visible (only completed/failed) Verification: Zero sequence leakage.
Reconnection test: Disconnect during PATTERN_DISPLAY → reconnect → see remaining steps only (natural penalty for missed steps) Verification: Reconnected player can attempt input but may have missed earlier steps.
7.1.8 Game Settings Integration (§12A)¶
Integrate host-configurable settings using the §12A system established in Phase 5. See lib/rmhbox/game-settings.ts and BaseMinigame.getSetting() for the canonical pattern.
Registry Entry¶
Export
SEQUENCE_SAM_SETTINGS: GameSettingsSchemainlib/rmhbox/minigame-registry.tswith 5 entries:maxRounds(integer, default5, min 3, max 8, step 1)startingLength(integer, default3, min 2, max 5, step 1)maxStrikes(integer, default3, min 1, max 5, step 1)enableChaosRounds(boolean, defaulttrue)chaosInterval(integer, default3, min 2, max 5, step 1)
Attach
settingsSchema: SEQUENCE_SAM_SETTINGSto thesequence-samMinigameDefinition. Verification: Registry lookup returns definition withsettingsSchemacontaining 5 entries.
Handler getSetting() Integration¶
Replace hardcoded constants with this.getSetting() calls in the Sequence Sam handler:
Constant |
Setting Key |
Replacement |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Boolean setting logic: When
enableChaosRoundsisfalse, skip all chaos-round logic (no tile remapping, no shuffled sequences). ThechaosIntervalsetting is ignored when chaos rounds are disabled. Verification: Each constant usage replaced. Handler respects custom settings passed viaMinigameContext.gameSettings.
7.1.9 History Display Configuration¶
Implement the history display config for Sequence Sam as defined in minigames-3.md §1.15.
7.1.9.1 Create Detail Component¶
Create components/rmhbox/minigames/sequence-sam/SequenceSamHistoryDetail.tsx:
Render round-by-round elimination view with 3×3 tile grid visualization
Show chaos round indicators with rotation badges
Display per-player status (survived/eliminated/strikes remaining)
Show elimination order timeline and speed bonus indicators
7.1.9.2 Register History Display¶
Add registration in lib/rmhbox/history-display-registrations.ts with:
Searchable fields:
playerNames(player names)Filterable fields:
roundsSurvived(range),hadChaosRound(boolean),wasEliminated(boolean)Summary:
{rounds} rounds ({chaos} chaos) — Sequence memory
7.1.9.3 Tests¶
Verify
getHistoryDisplay('sequence-sam')returns a valid configVerify searchable fields extract player names from a mock game log
Verify filterable fields include roundsSurvived (range), hadChaosRound (boolean), wasEliminated (boolean)
Verify
getSummary()returns a meaningful string for a mock game logVerify
DetailComponentrenders without errors when given a valid game log
7.2 Human Keyboard¶
Game ID: human-keyboard | Category: action | Icon: keyboard
Players: 3–10 | Duration: ~120s (cooperative)
7.2.1 Install NPM Packages¶
No additional NPM packages required for Human Keyboard Verification: Confirm no new dependencies needed.
7.2.2 Add Constants to lib/rmhbox/constants.ts¶
Add
HK_TYPING_DURATION_SECONDS = 90— maximum typing phase durationAdd
HK_SENTENCE_REVEAL_SECONDS = 3— sentence reveal durationAdd
HK_RESULTS_SECONDS = 5— results display durationAdd
HK_RESHUFFLE_INTERVAL_SECONDS = 8— time between key reshufflesAdd
HK_RESHUFFLE_WARNING_SECONDS = 3— warning before reshuffleAdd
HK_SPACE_DELAY_MS = 200— auto-advance delay for spacesAdd
HK_WRONG_KEY_PENALTY_MS = 500— cursor lock duration on wrong keyAdd
HK_INPUT_RATE_LIMIT = 5— max key presses per second per playerAdd
HK_CORRECT_KEY_POINTS = 20— points per correct key pressAdd
HK_WRONG_KEY_PENALTY_POINTS = -5— penalty per wrong key pressAdd
HK_PERFECT_ACCURACY_BONUS = 200— bonus for 100% accuracyAdd
HK_COMPLETION_BONUS = 100— bonus for completing the sentence (per player)Add
HK_TIME_BONUS_PER_SECOND = 5— bonus per second remaining on completionAdd
HK_MVP_BONUS = 100— bonus for the player with most correct pressesVerification: Import all
HK_*constants; confirm correct types.
7.2.3 Create Static Data Files¶
Create directory
public/data/rmhbox/human-keyboard/Verification: Directory exists on disk.Create
public/data/rmhbox/human-keyboard/sentences.json— curated target sentencesEach entry follows:
{ id: string; text: string; // e.g., "the quick brown fox jumps over the lazy dog" normalizedText: string; // lowercase, only a-z and spaces letterCount: number; // count of non-space characters difficulty: "easy" | "medium" | "hard"; category: string; // "Pangram", "Quote", "Phrase" }
Include at least 60 sentences
Normalized: lowercase letters and spaces only (no punctuation, no numbers)
Length range: 20–60 characters
Easy: 20–30 chars, Medium: 30–45 chars, Hard: 45–60 chars
At least 5 categories
No duplicates Verification: Parse JSON; validate all entries; confirm ≥60 sentences; confirm normalized format (regex
/^[a-z ]+$/).
7.2.4 Define Zod Validation Schemas¶
Create
lib/rmhbox/human-keyboard/schemas.tsDefine
HKPressSchema:const HKPressSchema = z.object({ key: z.string().length(1).regex(/^[a-z]$/), });
Verification: Valid:
{ key: "a" },{ key: "z" }. Invalid:{ key: "A" }(uppercase),{ key: "ab" }(2 chars),{ key: "1" }(digit).
7.2.5 Create Data Loader¶
Create
lib/rmhbox/human-keyboard/data-loader.tsExport
loadSentences(): TargetSentence[]— reads and parsessentences.json, caches as singletonExport
selectSentenceForGame(pool: TargetSentence[], playerCount: number, usedIds: Set<string>): TargetSentenceSelect sentence appropriate for player count (shorter for more players, longer for fewer)
Exclude used IDs Verification: Unit test: selection scales with player count. Exclusion works.
7.2.6 Implement Server Handler¶
Create
server/rmhbox/minigames/human-keyboard.ts
7.2.6.1 Type Definitions¶
Define
HKPhasetype:type HKPhase = 'SENTENCE_REVEAL' | 'TYPING' | 'RESULTS';
Verification: Type has exactly 3 values.
Define
HKPlayerStatstype:type HKPlayerStats = { userId: string; correctPresses: number; wrongPresses: number; wrongPlayerPresses: number; accuracy: number; totalScore: number; currentKeys: string[]; };
Define
HumanKeyboardStatetype:type HumanKeyboardState = { targetSentence: TargetSentence; normalizedText: string; cursorPosition: number; displayCursorPosition: number; phase: HKPhase; isComplete: boolean; keyAssignments: Map<string, string[]>; letterToPlayer: Map<string, string>; nextReshuffleAt: number; reshuffleCount: number; playerStats: Map<string, HKPlayerStats>; lockUntil: number | null; phaseStartedAt: number; phaseEndsAt: number; startedAt: number; completedAt: number | null; };
Verification: All types compile. Cross-reference against spec §2.4.
7.2.6.2 Class: HumanKeyboardGame extends BaseMinigame¶
Constructor: call
super(context); load sentences via data loader Verification: Instantiate class; confirm no errors.
7.2.6.3 Key Assignment Algorithm¶
Implement
assignKeys(playerIds: string[]): Map<string, string[]>:Shuffle the 26 letters a–z
Distribute evenly across players (round-robin)
Sort each player’s assigned letters alphabetically
Build reverse lookup
letterToPlayer: Map<string, string>(letter → userId)For 5 players: ~5 letters each; for 3 players: ~8-9 each Verification: Unit test: 5 players → 26 letters distributed, 5 or 6 each. All 26 letters assigned. No duplicates. Reverse lookup works.
7.2.6.4 State Initialization (start())¶
Select sentence via
selectSentenceForGame()Compute
normalizedText(lowercase, a-z and spaces only — should already be normalized in data)Set
cursorPosition = 0(index into non-space characters),displayCursorPosition = 0(index into full string)Call
assignKeys()for initial assignmentInitialize
playerStatsfor each player (all counters at 0)Set
phase = 'SENTENCE_REVEAL',isComplete = falseEmit
HK_SENTENCE_REVEALto all:{ sentence: targetSentence.text, normalizedLength: targetSentence.letterCount, typingDurationSeconds: HK_TYPING_DURATION_SECONDS }Emit
HK_KEY_ASSIGNMENTto each player individually:{ assignments: Record<string, string[]>, myKeys: string[] }assignmentscontains ALL players’ key lists (but see masking — other players’ keys are hidden for players, visible for spectators)Actually per spec: other players’ key assignments are HIDDEN from players; only
myKeysis sentRevise: send
{ myKeys: string[] }to each player only; NO global assignments map
Schedule
startTypingPhase()afterHK_SENTENCE_REVEAL_SECONDSVerification: Sentence selected. Keys assigned. Each player receives only their own keys.
7.2.6.5 Typing Phase¶
startTypingPhase():Set
phase = 'TYPING',startedAt = Date.now(),phaseEndsAt = startedAt + HK_TYPING_DURATION_SECONDS * 1000Set
nextReshuffleAt = startedAt + HK_RESHUFFLE_INTERVAL_SECONDS * 1000Start
TIMER_TICKinterval (1s)Start reshuffle timer: schedule
reshuffleWarning()atnextReshuffleAt - HK_RESHUFFLE_WARNING_SECONDS * 1000Schedule
endTypingPhase()afterHK_TYPING_DURATION_SECONDSVerification: Timer starts. Reshuffle scheduled.
reshuffleWarning():Emit
HK_RESHUFFLE_WARNINGto all:{ secondsUntilReshuffle: HK_RESHUFFLE_WARNING_SECONDS }Schedule
performReshuffle()afterHK_RESHUFFLE_WARNING_SECONDSVerification: Warning emitted 3s before reshuffle.
performReshuffle():Re-call
assignKeys()with current active playersUpdate
keyAssignmentsandletterToPlayerIncrement
reshuffleCountEmit
HK_RESHUFFLEto all (broadcast):{ reshuffleNumber: reshuffleCount }Emit
HK_KEY_ASSIGNMENTto each player individually with their newmyKeysUpdate each player’s
currentKeysinplayerStatsSet
nextReshuffleAt = Date.now() + HK_RESHUFFLE_INTERVAL_SECONDS * 1000Schedule next
reshuffleWarning()Handle orphaned keys if a player disconnected since last reshuffle (redistribute) Verification: New keys assigned. Each player notified. Next reshuffle scheduled.
handleSpaceAutoAdvance():When cursor position reaches a space character in the sentence:
After
HK_SPACE_DELAY_MS(200ms) delay, auto-advance cursor to the next non-space characterEmit
HK_SPACE_AUTOto all:{ newCursorPosition, newDisplayCursorPosition }Check if sentence is complete after advancing Verification: Spaces auto-skip with brief delay. Event emitted.
endTypingPhase():Stop all timers
Set
phase = 'RESULTS'Call
computeResults()Verification: Phase ends. Results computed.
7.2.6.6 Input Handling — HK_PRESS¶
Validate phase is
'TYPING'; reject if notParse through
HKPressSchemaApply rate limiting: check player hasn’t exceeded
HK_INPUT_RATE_LIMITpresses/second; reject if exceededCheck cursor is not locked (
lockUntil); if locked, reject silentlyCheck sentence is not already complete; reject if so
Determine expected character: the next non-space character at
cursorPositionin normalized textDetermine who owns this key:
letterToPlayer.get(pressed key)Evaluate:
Correct key by correct player (pressed key === expected AND player owns it):
Increment
playerStats[userId].correctPressesAdvance
cursorPositionanddisplayCursorPositionEmit
HK_KEY_CORRECTto ALL:{ key, userId, userName, cursorPosition, displayCursorPosition }Check if next character is a space → call
handleSpaceAutoAdvance()Check if sentence is complete → emit
HK_COMPLETE, schedule results
Correct key by WRONG player (pressed key === expected BUT player doesn’t own it):
Increment
playerStats[userId].wrongPlayerPressesEmit
HK_KEY_WRONG_PLAYERto pressing player ONLY:{ key, correctOwner: ownerUserId }NO cursor advance; NO penalty
Wrong key entirely (pressed key !== expected):
Increment
playerStats[userId].wrongPressesEmit
HK_KEY_WRONGto pressing player ONLY:{ key, penaltyMs: HK_WRONG_KEY_PENALTY_MS }Set
lockUntil = Date.now() + HK_WRONG_KEY_PENALTY_MSEmit
HK_CURSOR_LOCKEDto ALL:{ lockDurationMs: HK_WRONG_KEY_PENALTY_MS, reason: "wrong key" }Verification: Unit test: correct key by owner → cursor advances. Correct key by wrong player → rejected with “wrong player” message, no penalty. Wrong key → cursor locked for 500ms. Rate limit: 6th press in 1s rejected.
7.2.6.7 Scoring Computation (computeResults())¶
For each player:
Base score:
correctPresses × HK_CORRECT_KEY_POINTS+wrongPresses × HK_WRONG_KEY_PENALTY_POINTSIf personal accuracy === 100%: add
HK_PERFECT_ACCURACY_BONUSIf sentence completed: add
HK_COMPLETION_BONUSDetermine MVP: player with most
correctPresses; awardHK_MVP_BONUSTime bonus (if completed):
HK_TIME_BONUS_PER_SECOND × Math.floor(secondsRemaining)
Apply team performance multiplier:
Time ≤ 50% of limit → “Outstanding” (1.5×)
Time ≤ 75% of limit → “Good” (1.0×)
Incomplete → “Better luck next time” (0.5×)
Multiply each player’s score by the multiplier
Build
HKPlayerResult[]with all statsEmit
HK_RESULTSto all:{ playerResults, teamPerformance, timeBonus, completed }Verification: Unit test: 3 players complete sentence in 30s (< 50% of 90s) → “Outstanding” → 1.5× multiplier. MVP is player with most correct presses.
7.2.6.8 getStateForPlayer(userId)¶
During TYPING:
{ sentence: string; cursorPosition: number; displayCursorPosition: number; phase: 'TYPING'; timeRemaining: number; myKeys: string[]; nextExpectedLetter: string; isMyTurn: boolean; // nextExpectedLetter is in myKeys myStats: { correctPresses, wrongPresses, accuracy }; progress: number; // 0.0–1.0 isLocked: boolean; nextReshuffleIn: number; }
Other players’ key assignments NOT visible
Other players’ individual stats NOT visible until results Verification: Own keys visible. Other keys hidden. Progress accurate.
7.2.6.9 getStateForSpectator()¶
Full state: all players’ key assignments, real-time stats, which player owns the next expected letter
Creates dramatic tension for spectators watching Verification: Spectator sees all key assignments and stats.
7.2.6.10 Join-in-Progress Handling¶
Policy:
spectate_onlyKey assignments are balanced at game start; adding mid-game would require redistribution
JIP players receive spectator state Verification: JIP → spectator.
7.2.6.11 Reconnection Handling (handlePlayerReconnect(userId))¶
Send current key assignment, cursor position, sentence state
Player can resume pressing keys
If reshuffle occurred while disconnected, receive latest assignment Verification: Reconnect → current keys and state restored.
7.2.6.12 Disconnect Handling (handlePlayerDisconnect(userId))¶
Letters assigned to the disconnected player become orphaned
After grace period: redistribute orphaned letters among remaining players via
performReshuffle()System chat message explains redistribution Verification: Orphaned letters redistributed. All 26 letters covered.
7.2.6.13 computeResults() and Awards¶
Final rankings by
totalScore(descending)Compute awards:
MVP Typist — most correct key presses; icon:
crownPerfect Fingers — 100% accuracy (no wrong presses); icon:
check-circleButterfingers — most wrong key presses; icon:
x-circleNot My Job — most “wrong player” presses (kept pressing keys they don’t own); icon:
user-xTeam Spirit — team completed the sentence (awarded to ALL players); icon:
users
Return
MinigameResultsVerification: Unit test: each award triggers correctly.
7.2.6.14 buildGameLog()¶
Maintain an
actionLog: GameLogAction[]array on the game instanceBuild
GameLogconforming to core.md §13.3, includinggameSettingsper §12A.11
initialState (from minigames-3.md §2.15):
interface HKInitialState {
sentence: string;
sentenceLength: number;
typingDurationSeconds: number;
reshuffleIntervalSeconds: number;
playerCount: number;
initialKeyAssignments: Record<string, string[]>;
gameSettings: GameSettingValues;
}
Actions Logged:
Action Type |
Payload |
Recorded When |
|---|---|---|
|
|
Each key reshuffle |
|
|
At 25%, 50%, 75%, 100% completion |
|
|
End of game, per player |
|
|
Game over |
In
computeResults(), buildGameLogwithinitialState, full action log, andfinalResultsReturn
GameLogfrombuildGameLog()Verification: Unit test: 5-player game, 2 reshuffles, verify log capturesreshuffleevents,progress_milestoneat each threshold,player_summaryper player,initialStatehas sentence and key assignments.
7.2.7 Register Game in Minigame Registry¶
Add entry to
lib/rmhbox/minigame-registry.ts:{ id: "human-keyboard", displayName: "Human Keyboard", description: "Each player controls a few letters. Work together to type the sentence! Keys reshuffle every 8 seconds.", category: "action", icon: "keyboard", minPlayers: 3, maxPlayers: 10, estimatedDurationSeconds: 120, supportsTeams: true, instructionDurationSeconds: 15, preloadAssets: { images: [], sounds: [], data: [], estimatedSizeBytes: 0 }, joinInProgressPolicy: "spectate_only", tags: ["action", "coordination", "cooperative", "speed", "chaos"], }
Verification: Registry lookup correct.
Add server handler to
MINIGAME_SERVER_REGISTRYinserver/rmhbox/minigame-server-registry.ts:import { HumanKeyboardGame } from './minigames/human-keyboard'; MINIGAME_SERVER_REGISTRY.set('human-keyboard', HumanKeyboardGame);
Verification:
MINIGAME_SERVER_REGISTRY.get('human-keyboard')returnsHumanKeyboardGameclass.Add lazy-loaded component to
MinigameRenderermap:'human-keyboard': lazy(() => import('./minigames/human-keyboard/HumanKeyboardGame'))
Verification:
MinigameRendererrenders<Suspense>fallback, thenHumanKeyboardGamecomponent.
7.2.8 Build Client Components¶
7.2.8.1 components/rmhbox/minigames/human-keyboard/HumanKeyboardGame.tsx¶
Phase router — renders based on
phaseSubscribe to all
HK_*andTIMER_TICKeventsMaintain local state: sentence, cursor, myKeys, stats, progress, lock status, reshuffle timer
Handle
HK_SENTENCE_REVEAL→ store sentenceHandle
HK_KEY_ASSIGNMENT→ update myKeysHandle
HK_KEY_CORRECT→ advance cursor, highlight typed characterHandle
HK_KEY_WRONG→ shake animation on input, show penaltyHandle
HK_KEY_WRONG_PLAYER→ show “not your key” toastHandle
HK_CURSOR_LOCKED→ show lock indicator, disable input brieflyHandle
HK_SPACE_AUTO→ advance cursor past spaceHandle
HK_RESHUFFLE_WARNING→ show countdown overlayHandle
HK_RESHUFFLE→ scramble animation, update keysHandle
HK_COMPLETE→ celebration animationHandle
HK_RESULTS→ show resultsConditional rendering:
SENTENCE_REVEAL→ sentence display with entrance animationTYPING→<SentenceDisplay />+<KeyAssignment />+<KeyboardLayout />RESULTS→<HumanKeyboardResults />Verification: Component renders for each phase.
7.2.8.2 components/rmhbox/minigames/human-keyboard/SentenceDisplay.tsx¶
Full sentence text with character-by-character highlighting
Already-typed characters in muted/grey
Current cursor position highlighted (larger, different color)
Remaining characters in default color
Cursor blink animation
Shows which letter is expected next Verification: Cursor position visually correct. Typed chars greyed out.
7.2.8.3 components/rmhbox/minigames/human-keyboard/KeyAssignment.tsx¶
Display “Your letters: [B] [F] [J] [Q] [V] [Z]”
Each letter as a distinct button/badge
Highlight the letter that matches the next expected character (⚡ “It’s YOUR turn!”)
On key press (tap or keyboard): emit
rmhbox:game:inputwith{ action: "HK_PRESS", data: { key } }Desktop keyboard capture: listen for physical keyboard presses of owned letters
Non-owned letters on desktop: show “not your key” feedback on press Verification: Correct letter highlighted. Tap/keyboard emits event. Keyboard capture works.
7.2.8.4 components/rmhbox/minigames/human-keyboard/KeyboardLayout.tsx¶
Visual keyboard showing all 26 letters
Owned keys highlighted/bright; unowned keys dimmed
Active key (next expected) has prominent highlight
Key press visual feedback (brief scale animation)
Touch-friendly sizing (min 36px per key) Verification: Owned vs unowned visually distinct. Active key highlighted.
7.2.8.5 components/rmhbox/minigames/human-keyboard/ProgressBar.tsx¶
Horizontal progress bar: fraction of sentence completed
Percentage label
Animated fill on advance Verification: Progress matches cursor position.
7.2.8.6 components/rmhbox/minigames/human-keyboard/ReshuffleWarning.tsx¶
“Reshuffling in 3… 2… 1…” countdown overlay
Appears 3s before each reshuffle
Key scramble animation during reshuffle (keys visually shuffle)
Auto-dismisses Verification: Warning appears at correct time. Animation plays.
7.2.8.7 components/rmhbox/minigames/human-keyboard/HumanKeyboardResults.tsx¶
Team performance badge: “Outstanding” / “Good” / “Better luck next time”
Individual stats table: correct presses, wrong presses, accuracy, MVP indicator
Final scores with multiplier applied
Sentence completion time (if completed) Verification: All stats display correctly.
7.2.8.8 Sound Effects¶
Wire up sound effects using
playSound()for each Human Keyboard event:HK_SENTENCE_REVEAL→playSound('goFanfare')HK_KEY_CORRECT→playSound('scoreDing')HK_KEY_WRONG→playSound('buzzer')HK_CURSOR_LOCKED→playSound('buzzer')HK_SPACE_AUTO→playSound('click')HK_RESHUFFLE_WARNING→playSound('countdownBeep')HK_RESHUFFLE→playSound('swoosh')HK_COMPLETE→playSound('victoryFanfare')Verification: Each event triggers the correct sound exactly once. No overlapping duplicate sounds.
7.2.8.9 Zustand Store Integration¶
Read own key assignments from
privateStateRead sentence and cursor from
publicStateDetect spectator mode → render full keyboard heatmap view showing all key assignments Verification: Players see own keys + shared sentence/cursor. Spectators see full keyboard heatmap with all assignments.
7.2.9 Integration Testing¶
End-to-end test: 4 players → start Human Keyboard → type through a sentence
Verify 26 letters distributed evenly (~6-7 each)
Verify only the correct key owner can advance the cursor
Verify wrong-key penalty locks cursor for 500ms
Verify wrong-player press is rejected without penalty
Verify spaces auto-advance after 200ms
Verify reshuffle occurs every 8s with 3s warning
Verify completion triggers HK_COMPLETE and transitions to results
Verify scoring: correct keys + penalties + MVP + completion + time bonus + team multiplier Verification: All assertions pass.
Rate limiting test: Player sends 6 key presses in 1 second → 6th rejected Verification: Rate limit enforced.
Reshuffle stress test: 3 reshuffles occur → each time all 26 letters are assigned, no orphans Verification: All 26 letters covered after each reshuffle.
Disconnect test: Player disconnects → their letters become orphaned → after grace period, letters redistributed Verification: Redistribution works. All 26 letters covered.
Cursor lock test: Wrong key → cursor locked → correct key during lock → rejected → lock expires → correct key → accepted Verification: Lock timing correct.
7.2.10 Game Settings Integration (§12A)¶
Integrate host-configurable settings using the §12A system.
Registry Entry¶
Export
HUMAN_KEYBOARD_SETTINGS: GameSettingsSchemainlib/rmhbox/minigame-registry.tswith 4 entries:typingDuration(integer, default60, min 30, max 120, step 10)enableReshuffle(boolean, defaulttrue)reshuffleInterval(integer, default20, min 10, max 45, step 5)wrongKeyLockMs(integer, default500, min 0, max 2000, step 100)
Attach
settingsSchema: HUMAN_KEYBOARD_SETTINGSto thehuman-keyboardMinigameDefinition. Verification: Registry lookup returns definition withsettingsSchemacontaining 4 entries.
Handler getSetting() Integration¶
Replace hardcoded constants with this.getSetting() calls in the Human Keyboard handler:
Constant |
Setting Key |
Replacement |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
Boolean setting logic: When
enableReshuffleisfalse, key assignments remain fixed for the entire round. ThereshuffleIntervalsetting is ignored when reshuffling is disabled. Verification: Each constant usage replaced. Handler respects custom settings passed viaMinigameContext.gameSettings.
7.2.11 History Display Configuration¶
Implement the history display config for Human Keyboard as defined in minigames-3.md §2.16.
7.2.11.1 Create Detail Component¶
Create components/rmhbox/minigames/human-keyboard/HumanKeyboardHistoryDetail.tsx:
Render cooperative typing replay with target sentence and typed progress
Show key assignment map (player → keys visualization)
Display per-player accuracy statistics and reshuffle timeline markers
7.2.11.2 Register History Display¶
Add registration in lib/rmhbox/history-display-registrations.ts with:
Searchable fields:
targetSentence(sentence text),playerNames(player names)Filterable fields:
accuracy(range),completedSentence(boolean)Summary:
"{sentence}"
7.2.11.3 Tests¶
Verify
getHistoryDisplay('human-keyboard')returns a valid configVerify searchable fields extract target sentence and player names from a mock game log
Verify filterable fields include accuracy (range) and completedSentence (boolean)
Verify
getSummary()returns a meaningful string for a mock game logVerify
DetailComponentrenders without errors when given a valid game log
7.3 Cursor Curling¶
Game ID: cursor-curling | Category: action | Icon: target
Players: 2–8 | Duration: ~120s (3 ends)
7.3.1 Install NPM Packages¶
No external physics engine required
The physics is a simplified 2D simulation (~50 lines of code): linear velocity with friction damping, circle-circle elastic collision, and wall bouncing.
If future complexity is desired, consider
planck-js(~40KB gzip), but it is NOT needed for the current spec. Verification: Confirm no new dependencies needed.
7.3.2 Add Constants to lib/rmhbox/constants.ts¶
Add
CU_TOTAL_ENDS = 3— number of ends (rounds)Add
CU_END_START_SECONDS = 2— end announcement durationAdd
CU_AIM_DURATION_SECONDS = 3— time to aimAdd
CU_POWER_DURATION_SECONDS = 2— time for power meterAdd
CU_END_RESULTS_SECONDS = 5— end results displayAdd
CU_TRANSITION_SECONDS = 2— transition between endsAdd
CU_CANVAS_WIDTH = 400— rink width (px)Add
CU_CANVAS_HEIGHT = 600— rink height (px)Add
CU_HOUSE_CENTER = { x: 200, y: 100 }— house (target) center positionAdd
CU_BULLSEYE_RADIUS = 15— bullseye zone radiusAdd
CU_INNER_RADIUS = 40— inner ring radiusAdd
CU_OUTER_RADIUS = 70— outer ring radiusAdd
CU_HOUSE_RADIUS = 100— house boundary radiusAdd
CU_STONE_RADIUS = 12— stone collision radiusAdd
CU_LAUNCH_Y = 550— y-coordinate of launch zoneAdd
CU_BASE_FRICTION = 0.98— velocity multiplier per tick (< 1 for deceleration)Add
CU_SWEPT_FRICTION = 0.995— reduced friction when sweeping is activeAdd
CU_MAX_LAUNCH_SPEED = 15— maximum launch velocity (px/tick)Add
CU_SIMULATION_TICK_MS = 33— physics tick interval (~30Hz)Add
CU_STOP_THRESHOLD = 0.1— velocity magnitude below which stone stopsAdd
CU_RESTITUTION = 0.7— coefficient of restitution for stone-stone collisionsAdd
CU_SWEEP_ZONE_RADIUS = 60— sweep detection radius ahead of stone’s directionAdd
CU_SWEEP_WINDOW_MS = 500— sliding window for sweep frequency detectionAdd
CU_SWEEP_THRESHOLD = 6— minimum sweep events per window to be effectiveAdd
CU_SWEEP_INPUT_RATE_LIMIT = 15— max sweep inputs per second per playerAdd
CU_BULLSEYE_POINTS = 100— points for bullseye zoneAdd
CU_INNER_RING_POINTS = 60— points for inner ringAdd
CU_OUTER_RING_POINTS = 30— points for outer ringAdd
CU_HOUSE_POINTS = 10— points for in-house but outside outer ringAdd
CU_CLOSEST_BONUS = 50— bonus for stone closest to bullseye centerVerification: Import all
CU_*constants; confirm correct types. VerifyCU_BASE_FRICTION < 1(deceleration). VerifyCU_SWEPT_FRICTION > CU_BASE_FRICTION(less deceleration).
7.3.3 Define Zod Validation Schemas¶
Create
lib/rmhbox/cursor-curling/schemas.tsDefine
ThrowStoneSchema:const ThrowStoneSchema = z.object({ angle: z.number().min(-Math.PI / 2).max(Math.PI / 2), power: z.number().min(0).max(1), });
Verification: Valid:
{ angle: 0, power: 0.8 }(straight up). Invalid:{ angle: Math.PI }(out of range—can only throw upward).Define
SweepSchema:const SweepSchema = z.object({ x: z.number().min(0).max(CU_CANVAS_WIDTH), y: z.number().min(0).max(CU_CANVAS_HEIGHT), });
Verification: Valid:
{ x: 200, y: 300 }. Invalid:{ x: 500 }(out of bounds).
7.3.4 Implement Server Handler¶
Create
server/rmhbox/minigames/cursor-curling.ts
7.3.4.1 Type Definitions¶
Define
CUPhasetype:type CUPhase = 'END_START' | 'AIM' | 'POWER' | 'SIMULATION' | 'END_RESULTS' | 'TRANSITION' | 'GAME_OVER';
Verification: Type has exactly 7 values matching spec.
Define
CurlingStonetype:type CurlingStone = { id: string; userId: string; position: { x: number; y: number }; isInPlay: boolean; color: string; };
Define
StonePhysicstype:type StonePhysics = { position: { x: number; y: number }; velocity: { vx: number; vy: number }; friction: number; isMoving: boolean; };
Define
SweepStatetype:type SweepState = { userId: string; recentInputs: Array<{ x: number; y: number; timestamp: number }>; isSweeping: boolean; };
Define
EndResulttype:type EndResult = { endNumber: number; stonePositions: Array<{ userId: string; userName: string; position: { x: number; y: number }; distance: number; zone: string; points: number; }>; closestUserId: string | null; };
Define
CursorCurlingStatetype:type CursorCurlingState = { currentEnd: number; totalEnds: number; phase: CUPhase; throwOrder: string[]; currentThrowerIndex: number; stones: CurlingStone[]; activeStoneSim: StonePhysics | null; sweepStates: Map<string, SweepState>; playerScores: Map<string, number>; endResults: EndResult[]; phaseStartedAt: number; phaseEndsAt: number; };
Verification: All types compile. Cross-reference against spec §3.4.
7.3.4.2 Class: CursorCurlingGame extends BaseMinigame¶
Constructor: call
super(context)Verification: Instantiate class; confirm no errors.
7.3.4.3 State Initialization (start())¶
Initialize
playerScoreswith 0 for all playersSet
currentEnd = 0,totalEnds = CU_TOTAL_ENDSInitialize
stones = [],endResults = []Call
startNextEnd()Verification: Scores initialized. End starts.
7.3.4.4 Physics Engine Implementation¶
Implement
simulateStone(stone: StonePhysics, allStones: CurlingStone[], sweepActive: boolean): void:Called every
CU_SIMULATION_TICK_MS(33ms) while stone is movingFriction damping:
friction = sweepActive ? CU_SWEPT_FRICTION : CU_BASE_FRICTIONstone.velocity.vx *= frictionstone.velocity.vy *= friction
Position update:
stone.position.x += stone.velocity.vxstone.position.y += stone.velocity.vy
Wall collision:
If
position.x - CU_STONE_RADIUS < 0:position.x = CU_STONE_RADIUS; velocity.vx = -velocity.vxIf
position.x + CU_STONE_RADIUS > CU_CANVAS_WIDTH: same, other sideIf
position.y - CU_STONE_RADIUS < 0: stone slides off the top → markisInPlay = false, stop simulation for this stoneIf
position.y > CU_LAUNCH_Y + 50: stone stayed in launch zone → markisInPlay = false
Stone-stone collision detection:
For each existing stone
otherinallStones(that isisInPlayand not self):Compute distance:
dist = sqrt((x1-x2)² + (y1-y2)²)If
dist < CU_STONE_RADIUS * 2: collision detectedApply elastic collision formula:
Compute collision normal:
nx = (x2-x1)/dist,ny = (y2-y1)/distRelative velocity:
dvx = vx1-vx2,dvy = vy1-vy2(other stone is stationary or has low velocity)Impulse:
impulse = (dvx*nx + dvy*ny) * (1 + CU_RESTITUTION) / 2(equal mass)Apply impulse: moving stone
vx -= impulse*nx,vy -= impulse*ny; hit stonevx += impulse*nx,vy += impulse*ny
Emit
CU_STONE_COLLISIONto all with new positionsAfter collision, the hit stone may also start moving — need to simulate it too (or continue tracking until it stops)
Stop condition:
speed = sqrt(vx² + vy²)If
speed < CU_STOP_THRESHOLD: setisMoving = false, zero velocity, round position Verification: Unit test: stone launched straight up at power 1.0 → decelerates over time, stops. Stone hitting left wall → bounces. Two stones colliding → both velocities updated. Stone going off top → removed from play. Sweeping → travels farther before stopping.
Implement
runSimulationLoop():Use
setIntervalatCU_SIMULATION_TICK_MS(managed via BaseMinigame timer tracking)Each tick:
Compute sweep effectiveness for each sweeper (check frequency in
CU_SWEEP_WINDOW_MS)Determine if any sweep meets
CU_SWEEP_THRESHOLD→sweepActive = trueCall
simulateStone()for the active stoneAlso simulate any other stones set in motion by collisions
Broadcast
CU_STONE_POSITIONfor each moving stone:{ stoneId, x, y, vx, vy }If sweeping is effective, emit
CU_SWEPT_EFFECT:{ stoneId, frictionReduced: true }If all stones have stopped moving:
Clear interval
Finalize stone positions → update
stonesarrayEmit
CU_STONE_STOPPEDfor each stone that just stoppedCall
nextThrowOrEndPhase()Verification: Simulation loop runs at 30Hz. Position broadcasts stream to clients. Sweep effects applied. Loop terminates when all stones stop.
7.3.4.5 End Lifecycle¶
startNextEnd():Increment
currentEndIf
currentEnd > totalEnds, callendGame(); returnRandomize
throwOrder(different order each end)Set
currentThrowerIndex = -1Clear
stones = [](fresh rink each end)Set
phase = 'END_START'Emit
CU_END_STARTto all:{ endNumber: currentEnd, throwOrder: PlayerThrowInfo[] }Schedule
nextThrowOrEndPhase()afterCU_END_START_SECONDSVerification: New end starts. Throw order randomized. Stones cleared.
nextThrowOrEndPhase():Increment
currentThrowerIndexIf
currentThrowerIndex >= throwOrder.length:All players have thrown → call
computeEndResults()Return
Set
phase = 'AIM'Emit
CU_THROWER_ACTIVEto all:{ userId, userName, aimDurationSeconds: CU_AIM_DURATION_SECONDS }Start aim timer; schedule power phase after
CU_AIM_DURATION_SECONDS(if player doesn’t throw earlier) Verification: Players take turns. After last player, end results computed.
startPowerPhase():Set
phase = 'POWER'Emit
CU_POWER_PHASEto active thrower ONLY:{ powerDurationSeconds: CU_POWER_DURATION_SECONDS }Schedule auto-throw (random power) after
CU_POWER_DURATION_SECONDSVerification: Power phase started. Auto-throw on timeout.
computeEndResults():Set
phase = 'END_RESULTS'For each stone that is
isInPlay:Compute distance to
CU_HOUSE_CENTER:dist = sqrt((x - centerX)² + (y - centerY)²)Determine zone:
dist <= CU_BULLSEYE_RADIUS→ “bullseye”dist <= CU_INNER_RADIUS→ “inner”dist <= CU_OUTER_RADIUS→ “outer”dist <= CU_HOUSE_RADIUS→ “house”dist > CU_HOUSE_RADIUS→ “outside” (0 points)
Assign points based on zone
Determine
closestUserId: player whose stone has minimum distance to house center (must be within house)Award
CU_CLOSEST_BONUSto closest playerUpdate
playerScoresBuild
EndResultEmit
CU_END_RESULTSto allSchedule
startTransition()afterCU_END_RESULTS_SECONDSVerification: Unit test: stone at (200, 100) → distance 0 → bullseye → 100 points. Stone at (200, 130) → distance 30 → inner → 60 points. Closest gets +50 bonus.
startTransition():Set
phase = 'TRANSITION'Schedule
startNextEnd()afterCU_TRANSITION_SECONDSVerification: Next end starts after transition.
7.3.4.6 Input Handling — THROW_STONE¶
Validate phase is
'AIM'or'POWER'; reject if notValidate sender is the active thrower (
throwOrder[currentThrowerIndex]); reject if notParse through
ThrowStoneSchemaCreate
StonePhysics:position = { x: CU_CANVAS_WIDTH / 2, y: CU_LAUNCH_Y }(center of launch zone)velocity = { vx: Math.sin(angle) * power * CU_MAX_LAUNCH_SPEED, vy: -Math.cos(angle) * power * CU_MAX_LAUNCH_SPEED }(negative vy = upward)friction = CU_BASE_FRICTIONisMoving = true
Create
CurlingStoneand add tostonesarraySet
phase = 'SIMULATION'Initialize
sweepStatesfor all non-thrower playersEmit
CU_STONE_LAUNCHEDto all:{ userId, angle, power }Start
runSimulationLoop()Verification: Unit test: throw at angle=0, power=1.0 → stone launches straight up at max speed. Throw at angle=0.5 → launches rightward-up. Simulation starts.
7.3.4.7 Input Handling — SWEEP¶
Validate phase is
'SIMULATION'; reject if notValidate sender is NOT the active thrower; reject if thrower tries to sweep own stone
Parse through
SweepSchemaApply rate limit: max
CU_SWEEP_INPUT_RATE_LIMIT(15) per second per playerRecord sweep input in
sweepStates[userId].recentInputswith timestampPrune inputs older than
CU_SWEEP_WINDOW_MSfrom the windowAssess sweep effectiveness:
Check if sweep position is within
CU_SWEEP_ZONE_RADIUSahead of the stone’s current position and directionCheck if number of recent inputs in window ≥
CU_SWEEP_THRESHOLDIf both conditions met: set
isSweeping = trueEmit
CU_SWEEP_ACTIVEto all:{ userId, userName, isActive: true }
If conditions no longer met:
isSweeping = false, emitCU_SWEEP_ACTIVEwithisActive: falseVerification: Unit test: 6 sweep inputs in 500ms near the stone → sweep active, friction reduced. 3 inputs → below threshold, no effect. Sweep position behind stone → no effect.
7.3.4.8 getStateForPlayer(userId)¶
During AIM (active thrower): include aim UI data (own aim visible)
During AIM (non-thrower): see who is throwing, see all existing stones on rink
During SIMULATION:
{ currentEnd, phase, stones: [...], activeStonId, canSweep: boolean, // true if not the thrower sweepingPlayers: Array<{ userId, userName }>, scores: [...] }
Aim direction and power meter are HIDDEN from non-throwers during AIM/POWER
During END_RESULTS: include full results with distances and zones Verification: Thrower’s aim hidden from others. Stone positions visible to all during simulation.
7.3.4.9 getStateForSpectator()¶
Sees thrower’s aim direction and power meter level during AIM/POWER (omniscient)
Sees all stone positions and sweep activity during SIMULATION
Full experience Verification: Spectator sees aim and power.
7.3.4.10 Join-in-Progress Handling¶
Policy:
spectate_onlyThrow order established at game start
JIP → spectator Verification: JIP → spectator.
7.3.4.11 Reconnection Handling (handlePlayerReconnect(userId))¶
Receive current stone positions, whose turn it is, scores
If it was their turn and AIM/POWER still active → can complete throw
If timeout occurred → auto-throw (power=0, angle=0 — dud throw) Verification: Reconnect during AIM → can throw. After timeout → dud throw.
7.3.4.12 Disconnect Handling (handlePlayerDisconnect(userId))¶
If it was their turn → auto-throw after brief wait (dud throw)
Their existing stones on the rink are preserved Verification: Dud throw on disconnect. Stones preserved.
7.3.4.13 computeResults() and Awards¶
Final rankings by cumulative
playerScores(descending)Compute awards:
Bullseye! — hit the bullseye in any end; icon:
targetMaster Sweeper — most effective sweeping (most sweep-active time that changed friction); icon:
windDemolition Derby — knocked the most opponent stones out of play via collisions; icon:
boomGentle Touch — stone stopped closest to bullseye center (best precision across all ends); icon:
featherOff the Rails — stone went out of bounds the most times; icon:
slash
Return
MinigameResultsVerification: Unit test: each award triggers for appropriate scenarios.
7.3.4.14 buildGameLog()¶
Maintain an
actionLog: GameLogAction[]array on the game instanceBuild
GameLogconforming to core.md §13.3, includinggameSettingsper §12A.11
initialState (from minigames-3.md §3.14):
interface CUInitialState {
totalEnds: number;
playerCount: number;
canvasSize: { width: number; height: number };
houseCenter: { x: number; y: number };
bullseyeRadius: number;
stoneRadius: number;
throwOrder: string[];
gameSettings: GameSettingValues;
}
Actions Logged:
Action Type |
Payload |
Recorded When |
|---|---|---|
|
|
Start of each end |
|
|
After each player’s throw completes |
|
|
When a stone comes to rest |
|
|
End of each end |
|
|
Game over |
In
computeResults(), buildGameLogwithinitialState, full action log, andfinalResultsReturn
GameLogfrombuildGameLog()Verification: Unit test: 3-end game, 4 players, verify 12throw/stone_restactions and 3end_resultactions,initialStatehas house geometry and throw order.
7.3.5 Register Game in Minigame Registry¶
Add entry to
lib/rmhbox/minigame-registry.ts:{ id: "cursor-curling", displayName: "Cursor Curling", description: "Flick your stone toward the bullseye! Sweep to reduce friction, or knock opponents away.", category: "action", icon: "target", minPlayers: 2, maxPlayers: 8, estimatedDurationSeconds: 120, supportsTeams: false, instructionDurationSeconds: 15, preloadAssets: { images: [], sounds: [], data: [], estimatedSizeBytes: 0 }, joinInProgressPolicy: "spectate_only", tags: ["action", "physics", "precision", "competitive"], }
Verification: Registry lookup correct.
Add server handler to
MINIGAME_SERVER_REGISTRYinserver/rmhbox/minigame-server-registry.ts:import { CursorCurlingGame } from './minigames/cursor-curling'; MINIGAME_SERVER_REGISTRY.set('cursor-curling', CursorCurlingGame);
Verification:
MINIGAME_SERVER_REGISTRY.get('cursor-curling')returnsCursorCurlingGameclass.Add lazy-loaded component to
MinigameRenderermap:'cursor-curling': lazy(() => import('./minigames/cursor-curling/CursorCurlingGame'))
Verification:
MinigameRendererrenders<Suspense>fallback, thenCursorCurlingGamecomponent.
7.3.6 Build Client Components¶
7.3.6.1 components/rmhbox/minigames/cursor-curling/CursorCurlingGame.tsx¶
Phase router — renders based on
phaseSubscribe to all
CU_*andTIMER_TICKeventsMaintain local state: end, phase, stones, active simulation, sweep status, scores
Handle
CU_END_START→ reset rink, show end numberHandle
CU_THROWER_ACTIVE→ show who’s throwingHandle
CU_STONE_LAUNCHED→ begin client-side rendering of stone trajectoryHandle
CU_STONE_POSITION→ update stone position (interpolate between ticks for smooth animation at 60fps)Handle
CU_STONE_COLLISION→ collision particle effectHandle
CU_SWEEP_ACTIVE→ show sweep indicatorsHandle
CU_SWEPT_EFFECT→ visual indication of friction changeHandle
CU_STONE_STOPPED→ finalize stone positionHandle
CU_END_RESULTS→ show scoringConditional rendering:
AIM(thrower) →<CurlingCanvas />+<AimArrow />POWER(thrower) →<CurlingCanvas />+<PowerMeter />SIMULATION→<CurlingCanvas />+<SweepOverlay />END_RESULTS→<EndResults />Verification: Component renders for each phase. Stone animation smooth.
7.3.6.2 components/rmhbox/minigames/cursor-curling/CurlingCanvas.tsx¶
HTML5 Canvas (400×600) rendering the rink
Draw house: concentric circles at
CU_HOUSE_CENTERwith bullseye, inner, outer, house radiiDraw all stones as colored circles with player initials
During SIMULATION: interpolate stone positions between server ticks for smooth 60fps animation
Receive positions at 30Hz, render at 60fps using linear interpolation
Draw rink boundaries (walls)
Responsive scaling for different screen sizes Verification: House rings render correctly. Stones animate smoothly. Responsive.
7.3.6.3 components/rmhbox/minigames/cursor-curling/AimArrow.tsx¶
Directional arrow from launch position
Desktop: follows mouse position relative to launch point; angle computed from cursor position
Mobile: drag gesture to set angle
Arrow length indicates direction (not power)
Limited to upward-facing angles (±90° from vertical) Verification: Arrow follows cursor/touch. Angle within bounds.
7.3.6.4 components/rmhbox/minigames/cursor-curling/PowerMeter.tsx¶
Oscillating power bar (fills and depletes in a cycle)
Tap/click to lock in power level (0.0–1.0)
Visual: vertical bar with color gradient (green=low, yellow=mid, red=high)
Oscillation speed increases over time for difficulty Verification: Power oscillates. Lock-in captures current value. Sent to server.
7.3.6.5 components/rmhbox/minigames/cursor-curling/SweepOverlay.tsx¶
Sweep zone indicator: translucent area in front of the moving stone
“Wiggle to sweep!” instruction
Desktop: detect rapid cursor movement (mouse events at high frequency)
Mobile: detect rapid touch movement (touch events)
Send
SWEEPactions with current touch/cursor position at up to 15HzVisual indicator when sweep is actively effective (icon + glow effect) Verification: Sweep inputs sent at correct rate. Visual feedback on active sweep.
7.3.6.6 components/rmhbox/minigames/cursor-curling/StoneSprite.tsx¶
Colored circle with player initial
Size:
CU_STONE_RADIUSscaled to displayShadow/3D effect for depth
Trail effect while moving Verification: Stone renders with correct color and initial.
7.3.6.7 components/rmhbox/minigames/cursor-curling/EndResults.tsx¶
Stone position overlay showing distance to bullseye for each stone
Zone labels (bullseye, inner, outer, house, outside)
Points per stone
Closest-to-center highlight with bonus
Cumulative scores Verification: All info displays correctly.
7.3.6.8 Sound Effects¶
Wire up sound effects using
playSound()for each Cursor Curling event:CU_END_START→playSound('swoosh')CU_THROWER_ACTIVE→playSound('chime')CU_STONE_LAUNCHED→playSound('swoosh')CU_STONE_COLLISION→playSound('click')CU_STONE_STOPPED→playSound('click')CU_END_RESULTS→playSound('victoryFanfare')CU_GAME_OVER→playSound('victoryFanfare')Verification: Each event triggers the correct sound exactly once. No overlapping duplicate sounds.
7.3.6.9 Zustand Store Integration¶
Read stone positions and phase from
publicStateRead aim/power preview from
privateState(only for active thrower)Use
requestAnimationFramefor 60fps interpolation between 30Hz server updatesDetect spectator mode → render omniscient view with aim/power overlays Verification: Players see stones + own aim/power when active. Spectators see all overlays. Interpolation produces smooth 60fps rendering.
7.3.7 Integration Testing¶
End-to-end test: 3 players → 3 ends → each player throws once per end
Verify stone physics: launches, decelerates, stops
Verify wall bouncing: stone hits left/right wall → bounces
Verify out-of-bounds: stone goes off top → out of play
Verify scoring by zone proximity
Verify closest-to-center bonus Verification: All physics and scoring assertions pass.
Stone collision test:
Player A’s stone is near bullseye; Player B throws and hits it → both stones move
Velocity transfer based on collision angle and restitution
Both final positions are realistic Verification: Collision physics correct. Events emitted.
Sweeping test:
Non-thrower sends 6 sweep inputs in 500ms near the stone’s path → friction reduced
Stone travels farther with sweep vs. without sweep (compare two throws at same power/angle)
Sweeping rate limit: 16th input in 1s rejected Verification: Swept stone travels measurably farther.
Aim/power masking test:
During AIM: non-throwers don’t see aim direction
During POWER: non-throwers don’t see power level
Spectators see both Verification: Masking correct.
Reconnection test: Player disconnects on their turn → auto-throw (dud) → reconnect → spectator for that throw, next turn normal Verification: Auto-throw works.
7.3.8 Game Settings Integration (§12A)¶
Integrate host-configurable settings using the §12A system.
Registry Entry¶
Export
CURSOR_CURLING_SETTINGS: GameSettingsSchemainlib/rmhbox/minigame-registry.tswith 4 entries:totalEnds(integer, default4, min 2, max 6, step 1)aimDuration(integer, default15, min 5, max 30, step 5)powerDuration(integer, default5, min 3, max 10, step 1)enableSweeping(boolean, defaulttrue)
Attach
settingsSchema: CURSOR_CURLING_SETTINGSto thecursor-curlingMinigameDefinition. Verification: Registry lookup returns definition withsettingsSchemacontaining 4 entries.
Handler getSetting() Integration¶
Replace hardcoded constants with this.getSetting() calls in the Cursor Curling handler:
Constant |
Setting Key |
Replacement |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
Boolean setting logic: When
enableSweepingisfalse, skip the sweeping phase entirely after stone release — the stone travels on its natural trajectory without team intervention. Verification: Each constant usage replaced. Handler respects custom settings passed viaMinigameContext.gameSettings.
7.3.9 History Display Configuration¶
Implement the history display config for Cursor Curling as defined in minigames-3.md §3.15.
7.3.9.1 Create Detail Component¶
Create components/rmhbox/minigames/cursor-curling/CursorCurlingHistoryDetail.tsx:
Render per-end scoring summary with stone positions on house visualization
Show distance-from-center ranking and sweep activity indicators
Display per-end scoring breakdown (bullseye/inner/outer/house/outside)
7.3.9.2 Register History Display¶
Add registration in lib/rmhbox/history-display-registrations.ts with:
Searchable fields:
playerNames(player names)Filterable fields:
hitBullseye(boolean),endCount(range),sweepCount(range)Summary:
{ends} ends — Curling precision
7.3.9.3 Tests¶
Verify
getHistoryDisplay('cursor-curling')returns a valid configVerify searchable fields extract player names from a mock game log
Verify filterable fields include hitBullseye (boolean), endCount (range), sweepCount (range)
Verify
getSummary()returns a meaningful string for a mock game logVerify
DetailComponentrenders without errors when given a valid game log
7.4 Human Tetris¶
Game ID: human-tetris | Category: action | Icon: square-stack
Players: 4–10 | Duration: ~120s (8 waves, cooperative)
7.4.1 Install NPM Packages¶
No additional NPM packages required for Human Tetris
Pure grid-based movement logic and shape generation. Verification: Confirm no new dependencies needed.
7.4.2 Add Constants to lib/rmhbox/constants.ts¶
Add
HT_TOTAL_WAVES = 8— number of wavesAdd
HT_GRID_COLS = 8— grid column countAdd
HT_GRID_ROWS = 6— grid row countAdd
HT_EASY_POSITION_SECONDS = 8— positioning time for waves 1–3Add
HT_MEDIUM_POSITION_SECONDS = 6— positioning time for waves 4–6Add
HT_HARD_POSITION_SECONDS = 4— positioning time for waves 7–8Add
HT_WALL_PREVIEW_SECONDS = 3— wall preview durationAdd
HT_WALL_IMPACT_SECONDS = 1— wall impact animation durationAdd
HT_WAVE_RESULTS_SECONDS = 2— wave results displayAdd
HT_EXCLUSION_RATIO = 0.2— fraction of players that must hide in dead zones (medium/hard waves)Add
HT_DEAD_ZONE_MIN_COUNT = 2— minimum dead zone cellsAdd
HT_MOVE_RATE_LIMIT = 6— max moves per second per playerAdd
HT_SUCCESS_POINTS = 100— points per player on team successAdd
HT_PARTIAL_POINTS = 30— base points for partial success (multiplied by fill ratio)Add
HT_CORRECT_POSITION_POINTS = 50— points for being in correct positionAdd
HT_HIT_PENALTY = -20— penalty for being hit by the wallAdd
HT_PERFECT_WAVE_BONUS = 50— bonus per player for success with ≥2s remainingAdd
HT_STREAK_BONUS = 200— bonus per player for all 8 waves successfulVerification: Import all
HT_*constants; confirm correct types.
7.4.3 Create Static Data Files¶
Create directory
public/data/rmhbox/human-tetris/Verification: Directory exists on disk.Create
public/data/rmhbox/human-tetris/shapes.json— wall shape templatesEach entry follows:
{ id: string; holes: Array<{ col: number; row: number }>; requiredPlayers: number; difficulty: "easy" | "medium" | "hard"; description: string; // e.g., "L-shape", "T-shape", "Line" }
Include at least 40 shape templates
Shapes range from 3-hole (easy, small groups) to 8-hole (hard, large groups)
All shapes must have connected holes (orthogonally adjacent — no floating islands)
Shapes centered within the 8×6 grid (not touching edges to leave room for dead zones)
Include variety: lines, L-shapes, T-shapes, squares, zigzags, crosses
Difficulty balanced: ≥15 easy, ≥15 medium, ≥10 hard Verification: Parse JSON; validate all entries; confirm connectivity (flood-fill each shape); confirm ≥40 templates.
7.4.4 Define Zod Validation Schemas¶
Create
lib/rmhbox/human-tetris/schemas.tsDefine
HTMoveSchema:const HTMoveSchema = z.object({ direction: z.enum(['up', 'down', 'left', 'right']), });
Verification: Valid:
{ direction: "up" }. Invalid:{ direction: "diagonal" }.
7.4.5 Create Data Loader and Shape Generator¶
Create
lib/rmhbox/human-tetris/shape-generator.tsExport
loadShapeTemplates(): ShapeTemplate[]— reads and parsesshapes.json, caches as singletonExport
selectShapeForWave(templates: ShapeTemplate[], waveNumber: number, playerCount: number, usedIds: Set<string>): WallShape:Determine
requiredPlayersbased on wave difficulty:Waves 1–3 (easy):
requiredPlayers = playerCount(all must fill holes)Waves 4–8 (medium/hard):
requiredPlayers = playerCount - floor(playerCount × HT_EXCLUSION_RATIO)
Find templates with matching
requiredPlayerscount and appropriate difficultyIf no exact match: scale a template (add/remove holes) to match required count via connected expansion/contraction
Exclude used templates
Return
WallShapewith holes, requiredPlayers, and deadZones
Export
generateDeadZones(gridCols: number, gridRows: number, holes: GridPosition[], extraPlayers: number): GridPosition[]:Place dead zones: corners first, then edges
Count:
extraPlayers + HT_DEAD_ZONE_MIN_COUNT(minimum 2, typicallytotalPlayers - requiredPlayers + 2)Dead zones must NOT overlap with hole positions
Export
validateConnectedness(holes: GridPosition[]): boolean:Flood-fill from first hole; confirm all holes reachable Verification: Unit test: 6 players, wave 1 (easy) → 6 holes. Wave 5 (medium) → 5 holes (6 - floor(6 × 0.2) = 5), 1 player + 2 dead zones. All shapes connected. Dead zones don’t overlap holes.
7.4.6 Implement Server Handler¶
Create
server/rmhbox/minigames/human-tetris.ts
7.4.6.1 Type Definitions¶
Define
HTPhasetype:type HTPhase = 'WALL_PREVIEW' | 'POSITIONING' | 'WALL_IMPACT' | 'WAVE_RESULTS' | 'GAME_OVER';
Verification: Type has exactly 5 values.
Define
GridPositiontype:type GridPosition = { col: number; row: number };
Define
WallShapetype:type WallShape = { holes: GridPosition[]; requiredPlayers: number; deadZones: GridPosition[]; difficulty: 'easy' | 'medium' | 'hard'; };
Define
WaveResulttype:type WaveResult = { waveNumber: number; success: boolean; filledHoles: number; totalHoles: number; playersInCorrectPosition: string[]; playersHitByWall: string[]; teamScore: number; };
Define
HumanTetrisStatetype:type HumanTetrisState = { currentWave: number; totalWaves: number; phase: HTPhase; gridCols: number; gridRows: number; currentWall: WallShape | null; playerPositions: Map<string, GridPosition>; waveResults: WaveResult[]; consecutiveSuccesses: number; playerScores: Map<string, number>; phaseStartedAt: number; phaseEndsAt: number; };
Verification: All types compile. Cross-reference against spec §4.4.
7.4.6.2 Class: HumanTetrisGame extends BaseMinigame¶
Constructor: call
super(context); load shape templates Verification: Instantiate class; confirm no errors.
7.4.6.3 State Initialization (start())¶
Initialize
playerScoreswith 0 for all playersInitialize
playerPositions— random starting positions on the grid (no overlaps)Set
currentWave = 0,totalWaves = HT_TOTAL_WAVESSet
consecutiveSuccesses = 0,waveResults = []Call
startNextWave()Verification: All players placed. No overlapping starting positions.
7.4.6.4 Wave Lifecycle¶
startNextWave():Increment
currentWaveIf
currentWave > totalWaves, callendGame(); returnSelect shape via
selectShapeForWave()based on wave number, player countGenerate dead zones via
generateDeadZones()Set
currentWallwith holes and dead zonesSet
phase = 'WALL_PREVIEW'Emit
HT_WAVE_STARTto all:{ waveNumber: currentWave, wall: WallShapeView, positioningSeconds, deadZones, requiredPlayers }
positioningSecondsbased on difficulty: waves 1-3 = 8s, 4-6 = 6s, 7-8 = 4s
Start
TIMER_TICKintervalSchedule
startPositioning()afterHT_WALL_PREVIEW_SECONDSVerification: Shape generated. Event emitted with correct duration.
startPositioning():Set
phase = 'POSITIONING'Determine positioning duration based on wave difficulty
Schedule
wallImpact()after positioning duration Verification: Phase transitions. Timer runs.
wallImpact():Stop timer
Set
phase = 'WALL_IMPACT'Evaluate each player’s position:
Build set of hole positions and dead zone positions
For each player:
If position is in
holes→ status'IN_HOLE'If position is in
deadZones→ status'IN_DEAD_ZONE'Else → status
'HIT_BY_WALL'
Compute
filledHoles: count of hole positions occupied by at least one playerCompute
allHolesFilled = (filledHoles === currentWall.holes.length)Compute
allPlayersSafe = (no player has status 'HIT_BY_WALL')success = allHolesFilled && allPlayersSafeEmit
HT_WALL_IMPACTto all withWallImpactResultCall
computeWaveScore()Verification: Unit test: 5 players, 4 holes, 2 dead zones → all in correct positions → success. 1 player on regular cell → hit → partial.
computeWaveScore():If
success:Each player:
+HT_SUCCESS_POINTS(100)If time remaining ≥ 2s:
+HT_PERFECT_WAVE_BONUS(50) per playerIncrement
consecutiveSuccessesIf
consecutiveSuccesses === HT_TOTAL_WAVES:+HT_STREAK_BONUS(200) per player
If partial (some holes filled but not all):
partialScore = HT_PARTIAL_POINTS × (filledHoles / totalHoles)Players in correct positions:
+HT_CORRECT_POSITION_POINTS(50)Players hit by wall:
+HT_HIT_PENALTY(-20)Reset
consecutiveSuccesses = 0
If failure (no holes filled):
Players hit by wall:
+HT_HIT_PENALTY(-20)Reset
consecutiveSuccesses = 0
Update
playerScoresBuild
WaveResultSet
phase = 'WAVE_RESULTS'Emit
HT_WAVE_RESULTSto allSchedule
startNextWave()afterHT_WAVE_RESULTS_SECONDSVerification: Full success → 100 + optional 50 per player. Partial → proportional scoring. Streak tracks consecutive successes.
7.4.6.5 Input Handling — HT_MOVE¶
Validate phase is
'POSITIONING'; reject if notParse through
HTMoveSchemaApply rate limit: max
HT_MOVE_RATE_LIMIT(6) moves/second per player; reject if exceededCompute new position based on direction:
up:row - 1;down:row + 1;left:col - 1;right:col + 1
Validate new position:
Within bounds:
0 <= col < HT_GRID_COLS,0 <= row < HT_GRID_ROWSCell not occupied by another player UNLESS it’s a dead zone (dead zones allow multiple occupants)
If valid:
Update
playerPositions[userId]to new positionEmit
HT_PLAYER_MOVEDto ALL:{ userId, userName, col, row }
If invalid:
Emit
HT_MOVE_REJECTEDto mover ONLY:{ reason: 'OUT_OF_BOUNDS' | 'CELL_OCCUPIED' | 'RATE_LIMITED' }Verification: Unit test: valid move → position updates, event broadcast. Out of bounds → rejected. Occupied non-dead-zone cell → rejected. Dead zone cell with another player → allowed.
7.4.6.6 getStateForPlayer(userId)¶
During POSITIONING:
{ waveNumber, totalWaves, phase, gridCols, gridRows, wall: WallShapeView, deadZones: GridPosition[], requiredPlayers: number, timeRemaining: number, playerPositions: Array<{ userId, userName, col, row, isMe }>, filledHoles: GridPosition[], // which holes currently have a player unfilledHoles: GridPosition[], // which holes are still empty scores: [...], consecutiveSuccesses: number, }
All player positions visible (cooperative — need to see everyone to coordinate) Verification: All positions visible. Filled/unfilled holes computed from current positions.
7.4.6.7 getStateForSpectator()¶
Same as player view (minimal masking — cooperative game) Verification: Spectator sees everything players see.
7.4.6.8 Join-in-Progress Handling¶
Policy:
spectate_onlyWall shapes pre-generated for current player count
JIP → spectator Verification: JIP → spectator.
7.4.6.9 Reconnection Handling (handlePlayerReconnect(userId))¶
Send current wall shape, all player positions, dead zones, timer
Avatar position preserved (wherever they were)
Can immediately start moving Verification: Position preserved. Can move immediately.
7.4.6.10 Disconnect Handling (handlePlayerDisconnect(userId))¶
Avatar stays in last position (frozen)
May cause wave failure if needed in a hole
If remaining players <
minPlayers(4), force-end Verification: Frozen avatar. Force-end below min.
7.4.6.11 computeResults() and Awards¶
Final rankings by
playerScores(descending)Compute awards:
Perfect Team — all 8 waves successful (team award, given to all); icon:
trophyDead Zone Expert — successfully hid in dead zones the most times; icon:
ghostShape Filler — was in a hole cell correctly the most times; icon:
puzzleWall Magnet — got hit by the wall the most times; icon:
zapSpeed Mover — reached correct position with the most time remaining (averaged); icon:
rabbit
Return
MinigameResultsVerification: Each award triggers correctly.
7.4.6.12 buildGameLog()¶
Maintain an
actionLog: GameLogAction[]array on the game instanceBuild
GameLogconforming to core.md §13.3, includinggameSettingsper §12A.11
initialState (from minigames-3.md §4.16):
interface HTInitialState {
playerCount: number;
arenaSize: { width: number; height: number };
wallSpeedInitial: number;
totalWaves: number;
gapTolerance: number;
movementSpeed: number;
gameSettings: GameSettingValues;
}
Actions Logged:
Action Type |
Payload |
Recorded When |
|---|---|---|
|
|
Start of each wave |
|
|
Moment of wall impact |
|
|
After wave evaluation |
|
|
Game over |
In
computeResults(), buildGameLogwithinitialState, full action log, andfinalResultsReturn
GameLogfrombuildGameLog()Verification: Unit test: 8-wave game, verify 8wave_startand 8wave_resultactions,wave_impactwith player positions,game_endwith streak data,initialStatehas arena config and wave parameters.
7.4.7 Register Game in Minigame Registry¶
Add entry to
lib/rmhbox/minigame-registry.ts:{ id: "human-tetris", displayName: "Human Tetris", description: "Position your team to fill the holes in the wall! Extra players must hide in dead zones.", category: "action", icon: "square-stack", minPlayers: 4, maxPlayers: 10, estimatedDurationSeconds: 120, supportsTeams: true, instructionDurationSeconds: 15, preloadAssets: { images: [], sounds: [], data: [], estimatedSizeBytes: 0 }, joinInProgressPolicy: "spectate_only", tags: ["action", "coordination", "cooperative", "spatial"], }
Verification: Registry lookup correct.
Add server handler to
MINIGAME_SERVER_REGISTRYinserver/rmhbox/minigame-server-registry.ts:import { HumanTetrisGame } from './minigames/human-tetris'; MINIGAME_SERVER_REGISTRY.set('human-tetris', HumanTetrisGame);
Verification:
MINIGAME_SERVER_REGISTRY.get('human-tetris')returnsHumanTetrisGameclass.Add lazy-loaded component to
MinigameRenderermap:'human-tetris': lazy(() => import('./minigames/human-tetris/HumanTetrisGame'))
Verification:
MinigameRendererrenders<Suspense>fallback, thenHumanTetrisGamecomponent.
7.4.8 Build Client Components¶
7.4.8.1 components/rmhbox/minigames/human-tetris/HumanTetrisGame.tsx¶
Phase router — renders based on
phaseSubscribe to all
HT_*andTIMER_TICKeventsMaintain local state: wave, phase, wall, positions, results, streak
Handle
HT_WAVE_START→ store wall shape, dead zones, show previewHandle
HT_PLAYER_MOVED→ update position on gridHandle
HT_MOVE_REJECTED→ show rejection feedbackHandle
HT_WALL_IMPACT→ trigger wall animation, show resultsHandle
HT_WAVE_RESULTS→ display success/failureHandle
HT_GAME_OVER→ show final resultsConditional rendering:
WALL_PREVIEW→<WallPreview />POSITIONING→<WallCanvas />+<SwipeDetector />WALL_IMPACT→<WallAnimation />WAVE_RESULTS→<WaveResults />Verification: Renders for each phase. Events handled correctly.
7.4.8.2 components/rmhbox/minigames/human-tetris/WallCanvas.tsx¶
8×6 grid renderer using CSS Grid or Canvas
Cell types with distinct visuals:
Hole cells: marked with distinctive color/pattern (transparent or highlighted border)
Dead zones: skull icon (💀) or different background
Wall cells: solid dark color
Regular cells: neutral background
Player avatars rendered on their cells (colored circle with initial)
Real-time position updates as players move
Visual indicators:
Filled hole: green highlight
Unfilled hole: pulsing/red highlight
Player on wrong cell: subtle warning indicator
Responsive sizing (cells scale with screen) Verification: Grid renders correctly. All cell types visually distinct. Positions update in real-time.
7.4.8.3 components/rmhbox/minigames/human-tetris/GridCell.tsx¶
Individual cell component with state-driven styling
States:
wall,hole(empty),hole-filled,dead-zone,regularPlayer avatar overlay when occupied Verification: All states render correctly.
7.4.8.4 components/rmhbox/minigames/human-tetris/PlayerAvatar.tsx¶
Small colored circle with player initial/name
Smooth CSS transition when moving between cells
“You” label for own avatar
Glow effect when in correct position (hole or dead zone) Verification: Avatar moves smoothly. Own avatar identified.
7.4.8.5 components/rmhbox/minigames/human-tetris/WallPreview.tsx¶
Shows incoming wall shape before positioning begins
Required player count: “Fill 5 holes! (2 players must hide)”
Dead zone locations marked
Entrance animation (wall slides in from side) Verification: Shape displayed. Player count info correct.
7.4.8.6 components/rmhbox/minigames/human-tetris/WallAnimation.tsx¶
Wall “moves through” animation when timer expires
Safe players shown in green; hit players in red with impact effect
Brief dramatic pause before results Verification: Animation plays. Hit/safe visuals correct.
7.4.8.7 components/rmhbox/minigames/human-tetris/SwipeDetector.tsx¶
Mobile swipe detection for movement (up/down/left/right)
Desktop arrow key listener
Rate-limited to
HT_MOVE_RATE_LIMITon client side (server also enforces)Emits
rmhbox:game:inputwith{ action: "HT_MOVE", data: { direction } }Prevent page scroll during swipe Verification: Swipe detects direction. Keyboard arrows work. Rate limited.
7.4.8.8 components/rmhbox/minigames/human-tetris/WaveResults.tsx¶
Success/failure banner with Framer Motion animation
“Wave 5 Complete! ✅” or “Wave 5 Failed ❌”
Player-by-player results: in hole ✅, in dead zone ✅, hit by wall ❌
Team score for the wave
Streak counter: “🔥 4 in a row!” Verification: All info displays. Streak counter accurate.
7.4.8.9 Sound Effects¶
Wire up sound effects using
playSound()for each Human Tetris event:HT_WAVE_START→playSound('swoosh')HT_PLAYER_MOVED(own) →playSound('click')HT_MOVE_REJECTED→playSound('buzzer')HT_WALL_IMPACT(all safe) →playSound('victoryFanfare')HT_WALL_IMPACT(hit) →playSound('buzzer')HT_GAME_OVER→playSound('victoryFanfare')Verification: Each event triggers the correct sound exactly once. No overlapping duplicate sounds.
7.4.8.10 Zustand Store Integration¶
Read all player positions, wall shape, and dead zones from
publicState(cooperative game — all positions visible)Detect spectator mode → disable
SwipeDetector/ movement input Verification: All players see full board state. Spectators see full board but cannot send movement inputs.
7.4.9 Integration Testing¶
End-to-end test: 6 players → 8 waves
Verify wave 1-3 (easy): all 6 must fill holes, positioning time = 8s
Verify wave 4-6 (medium): 5 must fill holes, 1 must hide, positioning time = 6s
Verify wave 7-8 (hard): 5 must fill holes, 1 must hide, positioning time = 4s
Verify wall shapes are connected (no floating islands)
Verify dead zones don’t overlap with holes
Verify collision: two players can’t occupy same non-dead-zone cell
Verify dead zones: multiple players CAN occupy same dead zone cell
Verify scoring: success 100pts, partial proportional, hit -20, perfect wave +50, streak +200 Verification: All assertions pass.
Movement test:
Move up from row 0 → rejected (out of bounds)
Move right from col 7 → rejected (out of bounds)
Move into occupied regular cell → rejected
Move into dead zone with another player → accepted
7 moves in 1 second → 7th rejected (rate limit) Verification: Boundary, collision, and rate limit all work.
Shape scaling test: 4 players → shapes have 4 holes (easy) or 3 holes + 1 dead zone (medium). 10 players → shapes scale up. Verification: Shapes match player count.
Disconnect test: Player disconnects → avatar frozen → may cause wave failure if in wrong position Verification: Frozen avatar persists. Wave evaluation handles it.
Streak test: 8 consecutive successes →
HT_STREAK_BONUSawarded to all Verification: Streak bonus triggers.
7.4.10 Game Settings Integration (§12A)¶
Integrate host-configurable settings using the §12A system.
Registry Entry¶
Export
HUMAN_TETRIS_SETTINGS: GameSettingsSchemainlib/rmhbox/minigame-registry.tswith 4 entries:totalWaves(integer, default8, min 4, max 12, step 1)wallPreviewDuration(integer, default5, min 2, max 10, step 1)startingPositionTime(integer, default8, min 4, max 15, step 1)enableDeadZones(boolean, defaultfalse)
Attach
settingsSchema: HUMAN_TETRIS_SETTINGSto thehuman-tetrisMinigameDefinition. Verification: Registry lookup returns definition withsettingsSchemacontaining 4 entries.
Handler getSetting() Integration¶
Replace hardcoded constants with this.getSetting() calls in the Human Tetris handler:
Constant |
Setting Key |
Replacement |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
Boolean setting logic: When
enableDeadZonesistrue, the handler spawns dead-zone grid cells that eliminate players on contact. Default isfalse— enabling this is an advanced difficulty modifier. Verification: Each constant usage replaced. Handler respects custom settings passed viaMinigameContext.gameSettings.
7.4.11 History Display Configuration¶
Implement the history display config for Human Tetris as defined in minigames-3.md §4.17.
7.4.11.1 Create Detail Component¶
Create components/rmhbox/minigames/human-tetris/HumanTetrisHistoryDetail.tsx:
Render block-by-block summary with block shapes and placement positions
Show line clear events, voting results for rotation decisions
Display team coordination score and game-over condition
7.4.11.2 Register History Display¶
Add registration in lib/rmhbox/history-display-registrations.ts with:
Searchable fields:
playerNames(player names)Filterable fields:
linesCleared(range),blocksPlaced(range)Summary:
{blocks} blocks, {clears} lines cleared
7.4.11.3 Tests¶
Verify
getHistoryDisplay('human-tetris')returns a valid configVerify searchable fields extract player names from a mock game log
Verify filterable fields include linesCleared (range) and blocksPlaced (range)
Verify
getSummary()returns a meaningful string for a mock game logVerify
DetailComponentrenders without errors when given a valid game log
7.5 Cross-Game Integration Testing¶
7.5.1 Registry Verification¶
Verify all 4 Phase 7 games registered in the minigame registry
Call registry lookup for each:
"sequence-sam","human-keyboard","cursor-curling","human-tetris"Confirm all metadata fields correct
Confirm each handler instantiates with valid context Verification: All 4 games registered and instantiable.
7.5.2 Random Selection Test¶
Phase 7 games appear in random selection pool
Player count filtering: 2-player lobby excludes
"human-keyboard"(min 3),"human-tetris"(min 4)3-player lobby excludes
"human-tetris"(min 4) but includes"human-keyboard"Verification: Filtering by min players works correctly.
7.5.3 Lifecycle Integration Test¶
For each Phase 7 game: full lifecycle through state machine
Scoring integrates with lobby LeaderboardManager
Awards appear in post-game display
Cooperative games (
human-keyboard,human-tetris) track team performance correctly alongside individual scores Verification: Lifecycle, scoring, and awards all work.
7.5.4 Sequential Game Test¶
Play Sequence Sam → Human Keyboard → Cursor Curling → Human Tetris in same lobby
No state leakage between games
Cumulative lobby scores correct Verification: Clean state between games.
7.5.5 Concurrent Lobby Test¶
Two lobbies: one playing Cursor Curling, one playing Human Tetris simultaneously
No state contamination
Physics simulation in Cursor Curling doesn’t affect other lobby’s game Verification: Independent lobbies. Physics isolated.
7.5.6 Spectator Mode Test¶
Spectate each Phase 7 game:
SS: see each player’s tap progress (inputIndex), pattern displayed same as player
HK: see all key assignments, real-time stats, which player’s turn it is
CC: see thrower’s aim/power, all stone positions, sweep activity
HT: same as player (cooperative, minimal masking)
Spectators cannot send game inputs Verification: Spectator states correct. No input accepted.
7.5.7 Physics Simulation Stress Test (Cursor Curling)¶
8 players throwing stones (8 stones on rink per end)
Multiple simultaneous collisions handled correctly
Simulation loop terminates when all stones stop (no infinite loops)
Position broadcasts at ~30Hz without dropping frames
Server CPU usage remains reasonable during simulation Verification: Physics handles 8 stones. No infinite loops. Acceptable performance.
7.5.8 Phase 5 + Phase 7 Coexistence Test¶
Verify Phase 5 games (Rhyme Time, Undercover Agent, Category Crash, Wiki-Race) still function correctly after Phase 7 deployment
Play a mixed session: Phase 5 game → Phase 7 game → Phase 5 game
Verify registry correctly contains all 8 games (Phase 5 + Phase 7)
Verify no naming collisions between Phase 5 and Phase 7 constants, event types, or component paths Verification: No regressions. All 8 games playable in any order.
7.5.9 Game History Integration Test¶
For each Phase 7 game: verify
buildGameLog()produces a validGameLogobjectVerify game log is passed to
persistMatchResults()and stored in the databaseVerify
GET /api/rmhbox/history?matchId=...returns the game log inMatchDetailResponseVerify game-specific action types are present in the log for each game:
Sequence Sam:
round_start,round_result,player_eliminatedHuman Keyboard:
game_start,reshuffle,milestone,keystroke_summary,game_completeCursor Curling:
end_start,throw,stone_rest,end_resultHuman Tetris:
wave_start,wave_result,game_endVerification: Game logs persist and are retrievable via API. Action types match spec.
Verify
getHistoryDisplay()returns a valid config for each Phase 7 gameVerify each game’s history display has non-empty searchable and filterable fields
Verify each game’s
getSummary()returns a non-empty string for a valid game logVerify each game’s
DetailComponentcan be instantiated
7.5.10 MinigameRenderer Code-Splitting Test¶
MinigameRenderer code-splitting: verify each Phase 7 game loads as a separate chunk
Start each game → verify chunk loaded on demand
Verify
<Suspense>fallback renders during load Verification: Network tab shows separate chunk files. Main bundle unaffected.
7.5.11 Sound Effect Integration Test¶
Sound effect integration test: verify all 4 Phase 7 games trigger sounds at correct moments
Sequence Sam: pattern step clicks, strike buzzer, victory fanfare
Human Keyboard: key correct ding, key wrong buzzer, reshuffle swoosh
Cursor Curling: stone launch swoosh, collision click, end results fanfare
Human Tetris: wave start swoosh, move click, impact results Verification: Sounds fire once per event. Volume settings respected.
7.5.12 MINIGAME_SERVER_REGISTRY Completeness Test¶
MINIGAME_SERVER_REGISTRY completeness: verify all 4 Phase 7 handlers registered
MINIGAME_SERVER_REGISTRY.get('sequence-sam')→SequenceSamGameMINIGAME_SERVER_REGISTRY.get('human-keyboard')→HumanKeyboardGameMINIGAME_SERVER_REGISTRY.get('cursor-curling')→CursorCurlingGameMINIGAME_SERVER_REGISTRY.get('human-tetris')→HumanTetrisGameVerification: All 4 handlers instantiate and implementBaseMinigameinterface.
Note on parallel development: Phase 7 can be implemented fully in parallel with Phase 6 and Phase 8 after Phase 5 is complete. The coexistence test above (7.5.8) validates Phase 7 against Phase 5. If other phases are also complete, run an expanded coexistence test covering all deployed phases to verify the full registry (up to 16 games).
7.6 Game Settings Test Plan (§12A)¶
All tests go in testing/rmhbox/phase-7/game-settings.test.ts (or integrated into the phase-7 test suite). Follow the Phase 5 test patterns in testing/rmhbox/phase-5/6-game-settings.test.ts.
7.6.1 Schema Completeness Tests¶
Each of the 4 exported settings arrays has the expected number of entries (SS: 5, HK: 4, CU: 4, HT: 4).
Every setting has
key,type,label,defaultdefined.Integer settings have
min,max,stepdefined.Boolean settings have no
min/max/step.Default values fall within declared constraints.
7.6.2 Sequence Sam Settings Tests¶
Test Case |
Description |
|---|---|
|
With no custom settings, handler uses |
|
Handler plays 8 sequence rounds |
|
First sequence has 5 elements |
|
One wrong answer eliminates the player |
|
No chaos rounds occur; all rounds are standard |
|
Chaos rounds trigger every |
|
Chaos round every 2 rounds |
|
Setting |
7.6.3 Human Keyboard Settings Tests¶
Test Case |
Description |
|---|---|
|
Uses |
|
Typing timer is 90s |
|
Key assignments stay fixed for the entire round |
|
Keys reshuffle every |
|
Reshuffle occurs every 10s |
|
Setting has no effect when |
|
No lockout on wrong key press |
|
2s lockout on wrong key press |
7.6.4 Cursor Curling Settings Tests¶
Test Case |
Description |
|---|---|
|
Uses |
|
Handler plays 6 ends |
|
Aim phase timer is 25s |
|
Power phase timer is 8s |
|
No sweeping phase after stone release |
|
Sweeping phase is active |
7.6.5 Human Tetris Settings Tests¶
Test Case |
Description |
|---|---|
|
Uses |
|
Handler plays 12 waves |
|
Wall preview is 3s |
|
Position time is 12s |
|
Dead zones appear on the grid |
|
No dead zones |
7.6.6 getSetting() Fallback Tests¶
Calling
getSetting('maxRounds', SS_MAX_ROUNDS)with emptygameSettingsreturns the fallback.Calling
getSetting('maxRounds', SS_MAX_ROUNDS)withgameSettings: { maxRounds: 7 }returns7.Calling
getSetting('unknownKey', 42)returns42.