← Back
Play

Site Sprint Relay

A timed relay race through 5 construction-themed mini-game stations. Players complete Safety Gate, Measure & Place, Power, Flow, and Sequence stations as fast as possible under a shared countdown clock. Difficulty controls total time and per-station content complexity. Remaining time converts to a bonus added to the final score.

Game Flow

Menu → Difficulty Select → Level Intro → Countdown → Station Intro → Playing → Station Complete → [next station or Results]
  • Menu: Hero screen with game description, how-to-play tips, difficulty selection cards
  • Level Intro: Shows difficulty, station count, and total time. Start Relay button
  • Countdown: 3-2-1-GO via shared Countdown component
  • Station Intro: Modal showing "Station N of 5" and the station name. Appears before every station including the first
  • Playing: Active mini-game station. A shared clock runs across all stations. Each station manages its own gameplay but reports a MiniGameResult on completion
  • Station Complete: Brief modal showing the last station's score, then "Go" to advance
  • Results: Score breakdown with per-station scores, time bonus, grade, accuracy, stars

State Machine

FromEventTo
menuSTART_RUNintro
introBEGIN_COUNTDOWNcountdown
countdownGOplaying
playingSTATION_DONEstationComplete (or results if last)
playingTIME_UPresults
playingPAUSEpaused
stationCompleteNEXT_STATIONplaying
stationCompleteTIME_UPresults
stationCompleteSTART_RUNintro
pausedRESUMEplaying
pausedPAUSEpaused (no-op)
resultsSTART_RUNintro
(any)RESTARTintro
(any)RESET_MENUmenu

Stations

All 5 stations run in fixed order. Each is a thin phase adapter that picks content from a difficulty-gated pool and renders a shared mini-game playfield.

#StationPhase IDShared Mini-GameDescription
1Safety Gatesafety-gateSafetyGatePlayfieldJigsaw puzzle with safety Q&A. Drag matching-shape pieces into slots. Correct + distractor pieces share shapes; player must pick the right answer.
2Measure & Placemeasure-placeMeasurePlacePlayfieldPlace shapes at exact tape-measure positions. Drag shapes, extend tape to find markings, test placement accuracy.
3Powerpower-stationCircuitPlayfieldCircuit wiring challenge. Build or diagnose electrical circuits on a canvas.
4Flowflow-stationConnectionPlayfieldPipe/HVAC connection challenge. Place components on a grid to connect start to end with proper flow.
5Sequencesequence-stationDragOrderPlayfieldDrag-and-drop construction steps into correct assembly order.

Phase Adapter Pattern

Each station adapter in phases/{station}/index.tsx:

  1. Picks content via pickN(POOL, 1, difficulty) from the station's content pool
  2. Passes StationProps (difficulty, phaseId, timeLimitSeconds, getPauseCompensationMs, onComplete) to the shared playfield
  3. Sets hideTimer since the relay HUD manages the global clock

Content Pools

Content is organized as PoolItem<T>[] arrays. Each item has a maxDifficulty gate — items tagged Easy are available at all difficulties, items tagged Hard only appear at Hard+. Shared pools live in lib/mini-games/{name}/content/pool.ts; game-specific pools stay in content/pools/.

Pool LocationConfig TypeContent
content/pools/safety-gate.ts (local)SafetyGateConfig5 puzzles: PPE, Fatal Four, Confined Space, Safety Board, Scaffolding
content/pools/measure-place.ts (local)MeasurePlaceConfig5 configs with varying shape counts and tolerances
lib/mini-games/circuit/content/pool.ts (shared)CircuitChallengeConfig6 circuit wiring challenges (shared with Site Audit)
lib/mini-games/connection/content/pool.ts (shared)ConnectionConfig5 plumbing/HVAC grid configs (shared with Site Audit)
lib/mini-games/drag-order/content/pool.ts (shared)SequenceSet6 construction step sequences (shared with Site Audit)

Safety Gate Mini-Game (New)

Jigsaw puzzle where pieces are answers to safety questions. Located at lib/mini-games/safety-gate/.

Gameplay

  1. A safety question is displayed (e.g., "What are the 4 required PPE items?")
  2. An optional reference image provides visual context (collapsible accordion, open by default)
  3. The puzzle board shows jigsaw-shaped empty slots in a grid
  4. The piece tray contains correct answers + 2x distractor pieces
  5. Each grid position has a unique jigsaw shape; pieces can only snap into slots with matching shapes
  6. For each slot, there are 3 pieces with the same shape (1 correct + 2 distractors) — player must choose the right answer
  7. "Check Puzzle" validates all slots; generic "not quite right" feedback on failure (no per-slot hints)
  8. Reset clears all pieces; Shuffle randomizes tray order

Grid Sizes by Difficulty

DifficultyGridCorrectDistractorsTotal Pieces
Easy2x24812
Medium2x3 or 3x261218
Hard3x391827

Piece Assignment

  • Each correct piece gets a unique grid position (row, col) determining its jigsaw shape
  • Distractors are distributed round-robin across positions
  • Each position gets a color from a 9-color palette
  • Shape matching: piece.posRow === slot.row && piece.posCol === slot.col

Scoring

Uses useMiniGameCompletion with shared calculateWeightedScore:

  • correctCount: number of correct pieces placed
  • totalRequired: grid size (rows × cols)
  • penaltyCount: failed check attempts minus 1 (first check is free)
  • accuracy: correctCount / totalRequired × 100

Layout (Split-Panel)

Follows the drag-order pattern to avoid scroll-during-drag conflicts:

  • Upper panel (scrollable): question, reference image, puzzle board
  • Lower panel (scrollable): piece tray with count label
  • Fixed footer: Reset, Shuffle, Check Puzzle buttons
  • Both scroll panels switch to overflow-hidden during active drag

Files

FilePurpose
types.tsPuzzlePieceDef, SafetyGateConfig, SafetyGatePlayfieldProps
jigsaw-path.tsSVG path generation for interlocking jigsaw shapes (tabs/blanks via cubic beziers)
jigsaw-piece.tsxTrayPiece (draggable), BoardPiece (placed in slot), DragOverlayPiece
puzzle-board.tsxGrid of droppable slots with empty outlines, grey background
safety-gate-playfield.tsxMain playfield with DnD, position assignment, color assignment, shape matching
scoring.tsevaluatePuzzle() — checks all slots for correct pieces
index.tsBarrel export

Image Generation

Reference scene images for puzzle context. Config at games/site-sprint-relay/image-gen.config.ts, prompts at content/image-prompts.ts. Output to public/games/site-sprint-relay/. Aspect ratio 3:2, no background removal (full scene images).

Difficulty Scaling

Total relay time grows with difficulty. SSR is a cognitive/puzzle relay, not a reflex sprint — harder difficulties have substantially more content per station (e.g. safety-gate Easy = 4 jigsaw pieces, Hard = 9 pieces; flow-station Easy = 5x3 grid, Expert = 10x10), so the clock has to grow with it for the harder modes to be playable to completion.

SettingEasyMediumHardExpert
Total time (sec)300390480570
Total time (formatted)5:006:308:009:30
Content complexityBasic pools+ Medium pools+ Hard pools+ Expert pools

Scoring

The model is designed so harder difficulties win on the leaderboard for equal play quality. Three independent levers do the work:

  1. Per-station time budgets scale the time component fairly within and across difficulties.
  2. Per-difficulty time-bonus rate rewards harder modes more for unused clock time.
  3. End-of-run difficulty multiplier applied to (stationTotal + timeBonus) so the final score written to the platform is strictly higher on harder modes.

Per-Station

Each station normalizes to 0–1000 max score via useMiniGameCompletioncalculateWeightedScore. Factors: completion ratio (correct/total), accuracy, and time elapsed against a per-station time budget (not the global relay clock).

The budget is sourced from STATION_TIME_LIMIT_BY_DIFFICULTY via getStationTimeLimit(difficulty, phaseId). It is the scoring denominator only — the global relay clock still governs run termination.

Budgets scale with the station's actual content workload at each difficulty — for example, safety-gate Easy is a 4-piece 2x2 jigsaw, Hard is a 9-piece 3x3 jigsaw with more distractors, and Expert keeps the 3x3 grid but uses much harder domain content (electrical panel work, fall-protection systems).

StationEasyMediumHardExpert
safety-gate50607585
measure-place45607085
power-station506580100
flow-station557590110
sequence-station45607590
sum245320390470
total relay time300390480570
slack for time bonus+55+70+90+100

Sums sit ~55–100s under total relay time so a skilled player who hits par per station can still earn a meaningful time bonus on every difficulty.

Run Total

stationTotal         = sum(stationScores)                                  // max 5000
timeBonus            = remainingSeconds × TIME_BONUS_PER_SECOND_BY_DIFFICULTY[diff]
preMultiplier        = stationTotal + timeBonus
difficultyMultiplier = DIFFICULTY_SCORE_MULTIPLIERS[diff]
total                = round(preMultiplier × difficultyMultiplier)
DifficultyTime bonus / secDifficulty multiplierTheoretical max
Easy31.005,900
Medium51.157,993
Hard81.3011,492
Expert121.5017,760

For a skilled player who hits par per station (~850/station, full slack remaining), final scores land near:

DifficultyStationsTime bonusSubtotal× multiplierFinal
Easy425055×3 = 1654415× 1.004,415
Medium425070×5 = 3504600× 1.155,290
Hard425090×8 = 7204970× 1.306,461
Expert4250100×12 = 12005450× 1.508,175

For a fast skilled player (~30% under par), Easy ≈ 5,175, Medium ≈ 6,500, Hard ≈ 8,440, Expert ≈ 11,700. Strict ordering Expert > Hard > Medium > Easy at every skill tier.

The total is the integer written to the platform score column. Per the scoring rules, multiplier stays 1. The full breakdown (stationTotal, timeBonus, preMultiplier, difficultyMultiplier, per-station scores, remainingSeconds) is persisted in the data JSON for auditability.

Grade Thresholds

Both minimum score AND minimum accuracy must be met:

GradeEasyMediumHardExpertMin Accuracy
S4500600085001300090%
A3500470065001000075%
B230031004300650060%
C110015002100320040%
D00000%

Stars

Based on score-to-max ratio (max = getMaxFinalScore(difficulty)), scaled by difficulty:

Difficulty3 Stars2 Stars1 Star
Easy70%+55%+40%+
Medium75%+60%+45%+
Hard80%+65%+50%+
Expert80%+65%+50%+

Accuracy

Average of per-station accuracy values, rounded to nearest integer.

Technical Architecture

Layout Chain

GameShell (h-dvh, theme) → max-w-4xl container → PlayPage (relative h-full)
  ├── RunHud (absolute top, single row: time, score, station indicators, mute/pause)
  ├── Playfield strip (absolute, below HUD, flex-col)
  │   └── PhaseRunner → Station component (flex min-h-0 flex-1)
  ├── StationIntro overlay (z-40)
  ├── PauseMenu overlay
  └── ResultsScreen (absolute inset-0, z-50)

Mini-game playfields fill the parent with h-full flex-col. No self-imposed max-width — the route layout's max-w-4xl is the constraint. Content that is naturally narrow (puzzle board, tape measure) is centered within the full width while surrounding elements (tray, buttons) stretch.

Key Files

AreaFilePurpose
Typestypes.tsGamePhase, GameEvent, StationConfig, RelayConfig, StationProps, StationResult
Configconfig.tsStation definitions, time limits, grade thresholds, stars, scoring constants
Storestore.tsZustand store with transition engine: relay state, station progression, pause management
Scoringscoring.tscalculateTimeBonus(), calculateRunTotal(), calculateAccuracy()
Contentcontent/relays.tsgetRelayConfig(difficulty) builds RelayConfig from stations + time
Content Poolscontent/pools/*.tsDifficulty-gated content for each station
Pool Utilslib/mini-games/pools/utils.tsPoolItem<T>, pickN() with seeded shuffle and difficulty filtering (shared)
Barrelindex.tsPublic exports

Components

ComponentPurpose
phase-runner.tsxDynamic import registry mapping phaseId → station component. Passes StationProps. Prefetch support.
run-hud.tsxSingle-row HUD: time (with urgent/warning states), running score, station check indicators, mute/pause
run-results.tsxWraps shared ResultsScreen with per-station breakdown + time bonus
station-intro.tsxModal overlay showing station number, name, last score. "Go" button to continue

Hooks

HookPurpose
use-game-actions.tsWraps useGameActionsBase("site-sprint-relay-state") for persistence
use-game-audio.tsPhase-aware BGM/SFX management

Routing

/site-sprint-relay/                    → Menu (hero + difficulty selection)
/site-sprint-relay/play/[difficulty]/  → Gameplay (easy/medium/hard/expert)

Persistence

Uses useGameActionsBase("site-sprint-relay-state"). On results phase: updateRunProgress()submitScore()signalComplete(). Guest mode uses versioned localStorage.

Shared Platform Usage

Shared AssetImportUsage
ResultsScreen@/components/global/results-screenWrapped by run-results.tsx
Countdown@/components/global/countdownPre-run 3-2-1-GO
LevelIntro@/components/global/level-introPre-run briefing
PauseMenu@/components/global/pause-menuPause overlay
PlayfieldHeader@/components/global/playfield-headerPer-station header (toolbar variant)
PrimaryActionButton@/components/global/primary-action-buttonStation action buttons
GameDndProvider@/lib/dndDnD context for safety-gate and sequence stations
useMiniGameCompletion@/lib/hooks/use-mini-game-completionStation score calculation
useGameCountdown@/components/global/use-game-countdownPer-station timers
calculateWeightedScore@/lib/scoringScore normalization
assignGrade@/lib/scoringGrade assignment in results
GameShell@/components/layout/game-shellTheme wrapper
GamePageShell@/components/layout/game-page-shellMenu page centering

File Structure

games/site-sprint-relay/
├── store.ts                    # Zustand store with transition engine
├── config.ts                   # Stations, time limits, grades, stars
├── types.ts                    # GamePhase, StationConfig, RelayConfig, StationProps
├── scoring.ts                  # Time bonus, run total, accuracy
├── index.ts                    # Barrel export
├── image-gen.config.ts         # Image generation pipeline config
├── components/
│   ├── phase-runner.tsx        # Dynamic station loader + registry
│   ├── run-hud.tsx             # In-game HUD (time, score, station dots)
│   ├── run-results.tsx         # End-of-run results screen
│   └── station-intro.tsx       # Between-station modal
├── content/
│   ├── relays.ts               # Relay config builder
│   ├── image-prompts.ts        # AI image generation prompts
│   └── pools/
│       ├── safety-gate.ts      # Safety puzzle content (local)
│       └── measure-place.ts    # Measurement content (local)
├── hooks/
│   ├── use-game-actions.ts     # Persistence wrapper
│   └── use-game-audio.ts       # Audio management
└── phases/
    ├── safety-gate/index.tsx   # → SafetyGatePlayfield
    ├── measure-place/index.tsx # → MeasurePlacePlayfield
    ├── power-station/index.tsx # → CircuitPlayfield
    ├── flow-station/index.tsx  # → ConnectionPlayfield
    └── sequence-station/index.tsx # → DragOrderPlayfield

lib/mini-games/safety-gate/
├── types.ts                    # PuzzlePieceDef, SafetyGateConfig, SafetyGatePlayfieldProps
├── jigsaw-path.ts              # SVG path generation (tabs, blanks, viewBox)
├── jigsaw-piece.tsx            # TrayPiece, BoardPiece, DragOverlayPiece, colors
├── puzzle-board.tsx            # Grid of droppable slots with outlines
├── safety-gate-playfield.tsx   # Main playfield (DnD, split-panel layout)
├── scoring.ts                  # evaluatePuzzle()
└── index.ts                    # Barrel export

app/(games)/site-sprint-relay/
├── layout.tsx                  # GameShell + max-w-4xl container
├── loading.tsx                 # Route loading state
├── page.tsx                    # Menu / difficulty selection
└── play/[difficulty]/
    ├── loading.tsx             # Play loading state
    └── page.tsx                # Main play page (state machine, HUD, phase runner)