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
Countdowncomponent - 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
MiniGameResulton 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
| From | Event | To |
|---|---|---|
menu | START_RUN | intro |
intro | BEGIN_COUNTDOWN | countdown |
countdown | GO | playing |
playing | STATION_DONE | stationComplete (or results if last) |
playing | TIME_UP | results |
playing | PAUSE | paused |
stationComplete | NEXT_STATION | playing |
stationComplete | TIME_UP | results |
stationComplete | START_RUN | intro |
paused | RESUME | playing |
paused | PAUSE | paused (no-op) |
results | START_RUN | intro |
| (any) | RESTART | intro |
| (any) | RESET_MENU | menu |
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.
| # | Station | Phase ID | Shared Mini-Game | Description |
|---|---|---|---|---|
| 1 | Safety Gate | safety-gate | SafetyGatePlayfield | Jigsaw puzzle with safety Q&A. Drag matching-shape pieces into slots. Correct + distractor pieces share shapes; player must pick the right answer. |
| 2 | Measure & Place | measure-place | MeasurePlacePlayfield | Place shapes at exact tape-measure positions. Drag shapes, extend tape to find markings, test placement accuracy. |
| 3 | Power | power-station | CircuitPlayfield | Circuit wiring challenge. Build or diagnose electrical circuits on a canvas. |
| 4 | Flow | flow-station | ConnectionPlayfield | Pipe/HVAC connection challenge. Place components on a grid to connect start to end with proper flow. |
| 5 | Sequence | sequence-station | DragOrderPlayfield | Drag-and-drop construction steps into correct assembly order. |
Phase Adapter Pattern
Each station adapter in phases/{station}/index.tsx:
- Picks content via
pickN(POOL, 1, difficulty)from the station's content pool - Passes
StationProps(difficulty, phaseId, timeLimitSeconds, getPauseCompensationMs, onComplete) to the shared playfield - Sets
hideTimersince 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 Location | Config Type | Content |
|---|---|---|
content/pools/safety-gate.ts (local) | SafetyGateConfig | 5 puzzles: PPE, Fatal Four, Confined Space, Safety Board, Scaffolding |
content/pools/measure-place.ts (local) | MeasurePlaceConfig | 5 configs with varying shape counts and tolerances |
lib/mini-games/circuit/content/pool.ts (shared) | CircuitChallengeConfig | 6 circuit wiring challenges (shared with Site Audit) |
lib/mini-games/connection/content/pool.ts (shared) | ConnectionConfig | 5 plumbing/HVAC grid configs (shared with Site Audit) |
lib/mini-games/drag-order/content/pool.ts (shared) | SequenceSet | 6 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
- A safety question is displayed (e.g., "What are the 4 required PPE items?")
- An optional reference image provides visual context (collapsible accordion, open by default)
- The puzzle board shows jigsaw-shaped empty slots in a grid
- The piece tray contains correct answers + 2x distractor pieces
- Each grid position has a unique jigsaw shape; pieces can only snap into slots with matching shapes
- For each slot, there are 3 pieces with the same shape (1 correct + 2 distractors) — player must choose the right answer
- "Check Puzzle" validates all slots; generic "not quite right" feedback on failure (no per-slot hints)
- Reset clears all pieces; Shuffle randomizes tray order
Grid Sizes by Difficulty
| Difficulty | Grid | Correct | Distractors | Total Pieces |
|---|---|---|---|---|
| Easy | 2x2 | 4 | 8 | 12 |
| Medium | 2x3 or 3x2 | 6 | 12 | 18 |
| Hard | 3x3 | 9 | 18 | 27 |
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 placedtotalRequired: 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-hiddenduring active drag
Files
| File | Purpose |
|---|---|
types.ts | PuzzlePieceDef, SafetyGateConfig, SafetyGatePlayfieldProps |
jigsaw-path.ts | SVG path generation for interlocking jigsaw shapes (tabs/blanks via cubic beziers) |
jigsaw-piece.tsx | TrayPiece (draggable), BoardPiece (placed in slot), DragOverlayPiece |
puzzle-board.tsx | Grid of droppable slots with empty outlines, grey background |
safety-gate-playfield.tsx | Main playfield with DnD, position assignment, color assignment, shape matching |
scoring.ts | evaluatePuzzle() — checks all slots for correct pieces |
index.ts | Barrel 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.
| Setting | Easy | Medium | Hard | Expert |
|---|---|---|---|---|
| Total time (sec) | 300 | 390 | 480 | 570 |
| Total time (formatted) | 5:00 | 6:30 | 8:00 | 9:30 |
| Content complexity | Basic 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:
- Per-station time budgets scale the time component fairly within and across difficulties.
- Per-difficulty time-bonus rate rewards harder modes more for unused clock time.
- End-of-run difficulty multiplier applied to
(stationTotal + timeBonus)so the finalscorewritten to the platform is strictly higher on harder modes.
Per-Station
Each station normalizes to 0–1000 max score via useMiniGameCompletion → calculateWeightedScore. 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).
| Station | Easy | Medium | Hard | Expert |
|---|---|---|---|---|
| safety-gate | 50 | 60 | 75 | 85 |
| measure-place | 45 | 60 | 70 | 85 |
| power-station | 50 | 65 | 80 | 100 |
| flow-station | 55 | 75 | 90 | 110 |
| sequence-station | 45 | 60 | 75 | 90 |
| sum | 245 | 320 | 390 | 470 |
| total relay time | 300 | 390 | 480 | 570 |
| 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)
| Difficulty | Time bonus / sec | Difficulty multiplier | Theoretical max |
|---|---|---|---|
| Easy | 3 | 1.00 | 5,900 |
| Medium | 5 | 1.15 | 7,993 |
| Hard | 8 | 1.30 | 11,492 |
| Expert | 12 | 1.50 | 17,760 |
For a skilled player who hits par per station (~850/station, full slack remaining), final scores land near:
| Difficulty | Stations | Time bonus | Subtotal | × multiplier | Final |
|---|---|---|---|---|---|
| Easy | 4250 | 55×3 = 165 | 4415 | × 1.00 | 4,415 |
| Medium | 4250 | 70×5 = 350 | 4600 | × 1.15 | 5,290 |
| Hard | 4250 | 90×8 = 720 | 4970 | × 1.30 | 6,461 |
| Expert | 4250 | 100×12 = 1200 | 5450 | × 1.50 | 8,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:
| Grade | Easy | Medium | Hard | Expert | Min Accuracy |
|---|---|---|---|---|---|
| S | 4500 | 6000 | 8500 | 13000 | 90% |
| A | 3500 | 4700 | 6500 | 10000 | 75% |
| B | 2300 | 3100 | 4300 | 6500 | 60% |
| C | 1100 | 1500 | 2100 | 3200 | 40% |
| D | 0 | 0 | 0 | 0 | 0% |
Stars
Based on score-to-max ratio (max = getMaxFinalScore(difficulty)), scaled by difficulty:
| Difficulty | 3 Stars | 2 Stars | 1 Star |
|---|---|---|---|
| Easy | 70%+ | 55%+ | 40%+ |
| Medium | 75%+ | 60%+ | 45%+ |
| Hard | 80%+ | 65%+ | 50%+ |
| Expert | 80%+ | 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
| Area | File | Purpose |
|---|---|---|
| Types | types.ts | GamePhase, GameEvent, StationConfig, RelayConfig, StationProps, StationResult |
| Config | config.ts | Station definitions, time limits, grade thresholds, stars, scoring constants |
| Store | store.ts | Zustand store with transition engine: relay state, station progression, pause management |
| Scoring | scoring.ts | calculateTimeBonus(), calculateRunTotal(), calculateAccuracy() |
| Content | content/relays.ts | getRelayConfig(difficulty) builds RelayConfig from stations + time |
| Content Pools | content/pools/*.ts | Difficulty-gated content for each station |
| Pool Utils | lib/mini-games/pools/utils.ts | PoolItem<T>, pickN() with seeded shuffle and difficulty filtering (shared) |
| Barrel | index.ts | Public exports |
Components
| Component | Purpose |
|---|---|
phase-runner.tsx | Dynamic import registry mapping phaseId → station component. Passes StationProps. Prefetch support. |
run-hud.tsx | Single-row HUD: time (with urgent/warning states), running score, station check indicators, mute/pause |
run-results.tsx | Wraps shared ResultsScreen with per-station breakdown + time bonus |
station-intro.tsx | Modal overlay showing station number, name, last score. "Go" button to continue |
Hooks
| Hook | Purpose |
|---|---|
use-game-actions.ts | Wraps useGameActionsBase("site-sprint-relay-state") for persistence |
use-game-audio.ts | Phase-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 Asset | Import | Usage |
|---|---|---|
ResultsScreen | @/components/global/results-screen | Wrapped by run-results.tsx |
Countdown | @/components/global/countdown | Pre-run 3-2-1-GO |
LevelIntro | @/components/global/level-intro | Pre-run briefing |
PauseMenu | @/components/global/pause-menu | Pause overlay |
PlayfieldHeader | @/components/global/playfield-header | Per-station header (toolbar variant) |
PrimaryActionButton | @/components/global/primary-action-button | Station action buttons |
GameDndProvider | @/lib/dnd | DnD context for safety-gate and sequence stations |
useMiniGameCompletion | @/lib/hooks/use-mini-game-completion | Station score calculation |
useGameCountdown | @/components/global/use-game-countdown | Per-station timers |
calculateWeightedScore | @/lib/scoring | Score normalization |
assignGrade | @/lib/scoring | Grade assignment in results |
GameShell | @/components/layout/game-shell | Theme wrapper |
GamePageShell | @/components/layout/game-page-shell | Menu 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)