Trades Build Off
Narrative-driven multi-phase mini-game orchestrator. Each level tells a trade story through a series of phases that test safety knowledge, tool identification, and building skills. Phases are composable — each level picks from a pool of phase types and configures them with scenario-specific content.
For job-site order, level-by-level phase layout, and when blueprints / the reference drawer appear (design consistency review), see trades-build-off-narrative-flow.md.
Gameplay
Each level runs through a variable number of mini-game phases. Phases are selected per level to form a narrative arc:
Phase Types
Each phase is a thin adapter over a shared mini-game from lib/mini-games/. The adapter loads TBO-specific content and renders the mini-game with mapped props.
| Phase | Shared Mini-Game | Description |
|---|---|---|
| Safety Briefing | McqPlayfield | Multiple-choice quiz. Answer safety questions to pass orientation. |
| Gear Up | TapSelectPlayfield | Tap-to-select PPE from a grid. Pick the right gear, avoid decoys. |
| Spot the Problem | CardJudgePlayfield | Card-based judgment. Read a site condition, tap SAFE or HAZARD. Supports install check cards. |
| Know Your Tools | McqPlayfield | Tool identification quiz. Match hints to the correct tool name. |
| Tool Check | MultiSelectPlayfield | Multi-select tools for a project. Wrong picks cost points. Image preview overlay. |
| Read the Plan | McqPlayfield | Sequential MCQs driven by PlanReadScenario content. Optional image per item. |
| Measure & Cut / Place | (TBO-specific) | Mark length on stock, cut, optionally place on layout. Ruler mechanics, dimension-read bridges. |
| Build Order | DragOrderPlayfield | Drag-and-drop or tap-to-place assembly steps into correct sequence. |
| Check Your Work | (TBO-specific) | Sequential quality-verification checks on the assembled piece. |
Each phase has its own timer. Time is factored into scoring.
On-demand reference packet
During phaseActive or paused, a reference packet FAB (fixed, above the pause overlay) opens a centered dialog portaled to the [data-theme] root (GameShell) so semantic tokens match the rest of the game. The overlay uses bg-background/80 (per game-development overlay pattern); the panel matches phase chrome (rounded-xl border-border/50 bg-card/95 backdrop-blur-sm). The timer does not pause while the dialog is open. Pages come from resolveReferencePages in content/reference-resolve.ts: level referencePackId, optional per-phase referencePackId, plus auto-injected images for the active measure/plan/hazard phase where configured.
Blueprint-oriented assets in image-gen.config.ts use a per-prompt model override (google/gemini-3-pro-image-preview) so technical drawings can use the highest-quality image model while other sprites keep the default flash model. Regenerate with bun run generate:images:trades -- --filter tbo- (add --force to replace existing files).
Level Structure
Levels are narrative-driven with variable phase composition. Tutorial plus 10 campaign levels across four difficulty tiers:
id | UI badge | Title | Difficulty | Phases |
|---|---|---|---|---|
| 1 | Tutorial | First Day on Site | Easy | Safety Briefing → Gear Up → Spot the Problem → Know Your Tools |
| 2 | Level 1 | Build a Sawhorse | Easy | Gear Up → Tool Check → Read the Plan → Measure & Cut → Build Order → Check Your Work |
| 3 | Level 2 | Frame a Stud Wall | Easy | Gear Up → Tool Check → Spot the Problem → Read the Plan → Measure & Cut → Build Order |
| 4 | Level 3 | Wire a Light Switch | Medium | Safety Briefing → Gear Up → Spot the Problem → Tool Check → Read the Plan |
| 5 | Level 4 | Build a Shelf Unit | Medium | Gear Up → Tool Check → Read the Plan → Measure & Cut → Check Your Work |
| 6 | Level 5 | Rough-In a Drain Line | Medium | Safety Briefing → Gear Up → Tool Check → Read the Plan → Build Order |
| 7 | Level 6 | Frame a Door Opening | Hard | Gear Up → Read the Plan → Measure & Cut → Spot the Problem → Build Order |
| 8 | Level 7 | Hang Drywall | Hard | Safety Briefing → Gear Up → Tool Check → Read the Plan → Measure & Cut → Check Your Work |
| 9 | Level 8 | Wire a Kitchen Circuit | Hard | Safety Briefing → Gear Up → Read the Plan → Measure & Cut → Spot the Problem → Build Order |
| 10 | Level 9 | Set Concrete Forms | Expert | Safety Briefing → Gear Up → Tool Check → Read the Plan → Measure & Cut → Check Your Work |
| 11 | Level 10 | Final Walkthrough | Expert | Gear Up → Spot the Problem → Read the Plan → Measure & Cut → Check Your Work |
Optional completionTitle, learningOutcomes, and completionSummary drive the results narrative.
Content registries (add new levels without new code)
| Registry | File | Purpose |
|---|---|---|
| Reference packs | content/reference-packs.ts | Multi-page images + captions for the drawer. |
| Plan read scenarios | content/plan-read-scenarios.ts | items[] of mcq steps (prompt, choices, correctIndex, optional imageId). |
| Dimension read challenges | content/dimension-read-challenges.ts | Drawing image + MCQ + revealedTargetInches (must match the phase blueprint target). |
| Install check hazards | content/hazard-install-scenarios.ts | Merged into hazard-judge config.ts; conditions with mode: "install_check", planImageId, fieldImageId. |
| Build order configs | phases/build-order/config.ts | BuildOrderConfig with steps[] and correctOrder. |
| Check work configs | phases/place-verify/config.ts | CheckWorkConfig with assemblyImageId and checks[] (verification steps with tool icons, explanations). |
Phase data keys (examples): scenario (hazard / safety quiz), planReadScenario, dimensionReadChallengeId, blueprint, mode, project, config (build-order / check-work), referencePackId (per-phase pack merge). Level fields: optional referencePackId, kind, campaignLevel, completionTitle, learningOutcomes, completionSummary.
Competencies
- PPE / site safety — Briefing, gear-up, classic hazard cards.
- Tool literacy — Tool ID, tool check (with imagery).
- Simple construction drawings & plan measurements — Read the Plan phase, dimension-read bridge on measure phases, reference packet.
- Mistakes in real installation work — Install-check hazard cards (plan vs field).
- Assembly sequencing — Build Order (correct order of construction steps).
- Quality verification — Check Your Work (dimension, alignment, position, plan-match checks).
Scoring
Each phase normalizes to 0-1000 max score:
- Completion (20%): correctCount / totalRequired
- Accuracy (50%): correctCount / (correctCount + penalties)
- Time (30%): remaining time as fraction of time limit
Run score = flat sum of phase scores (max 1000 × phase count per level).
calculatePhaseScore and calculatePhaseAccuracy live in scoring.ts. calculatePhaseAccuracy delegates to the shared calculateAccuracy from @/lib/scoring.
Letter grades on the results screen use getGradeThresholdsForLevel(difficulty, level.phases.length) so score floors scale when a level has more or fewer than four phases (same intent as the original four-phase defaults).
Score Model: Cumulative
Posted score = sum of best run scores across all levels. multiplier is always 1. Score breakdown stored in data JSON.
Stars
Based on score-to-max ratio, scaled by difficulty:
| Difficulty | 3 Stars | 2 Stars |
|---|---|---|
| Easy | 90%+ | 65%+ |
| Medium | 85%+ | 60%+ |
| Hard | 80%+ | 55%+ |
| Expert | 75%+ | 50%+ |
Orchestrator Architecture
The game module at games/trades-build-off/ acts as both a game and an orchestrator. Each mini-game phase is a self-contained module inside phases/ that conforms to the MiniGamePhase contract.
MiniGamePhase Contract
interface MiniGameProps {
difficulty: "Easy" | "Medium" | "Hard" | "Expert";
levelConfig: PhaseLevelConfig;
onComplete: (result: PhaseResult) => void;
}
interface PhaseResult {
phaseId: string;
score: number; // 0-1000
accuracy: number; // 0-100%
timeMs: number;
penalties: number;
metadata: Record<string, unknown>;
}
Any component that implements MiniGameProps can be plugged in as a phase. The orchestrator renders the active phase component via a registry lookup and manages transitions.
Dynamic Phase Composition
Each LevelConfig defines a phases array of PhaseLevelConfig objects. The phase-runner looks up the component from PHASE_REGISTRY by phaseId. This allows each level to use a different combination and ordering of phase types.
Phase Machine
menu -> levelIntro -> walking -> phaseIntro -> countdown -> phaseActive -> phaseComplete -> [next phase or results]
The store uses the shared transition engine (createTransitionEngine from @/lib/stores) with a TBO-specific config (TBO_ENGINE_CONFIG). Each store action delegates to transition(phase, event) which returns null (+ dev warning) for invalid transitions. Pause time compensation uses computePauseCompensationMs from the same package. See tests/games/trades-build-off/store-transitions.test.ts.
| From | Event | To |
|---|---|---|
menu | START_RUN | levelIntro |
levelIntro | START_GAME | walking |
walking | ARRIVE | phaseIntro |
walking | PAUSE | paused |
phaseIntro | BEGIN_COUNTDOWN | countdown |
countdown | BEGIN_PHASE | phaseActive |
phaseActive | COMPLETE | phaseComplete |
phaseActive | PAUSE | paused |
phaseComplete | ADVANCE | walking or results |
paused | RESUME | (previousPhase) |
results | START_RUN | levelIntro |
RESTART and RESET_MENU are global events (globalTransitions in the engine config) — they resolve from any phase without needing per-phase entries. restartRun resets to levelIntro (requires currentLevel); resetToMenu resets to menu and clears all state.
The store tracks currentPhaseIndex and an array of PhaseResult. Phase count is determined by currentLevel.phases.length. Each phase component manages its own timer and calls onComplete when done.
Shared Hooks & Components
Phase playfields share common behavior extracted into reusable hooks and components inside the game module. These are game-specific (not platform-level shared) because they depend on Trades Build Off scoring and types.
For architecture and client patterns (Zustand useShallow, optimizePackageImports, next/dynamic phase registry, pause-aware timers, portaled overlays, React.memo + stable list props), use game-development.md React + Game Engine Best Practices as the canonical reference; this doc focuses on TBO gameplay, content, and file map.
Hooks
| Hook | Location | Purpose |
|---|---|---|
usePhaseTimer | hooks/use-phase-timer.ts | Thin wrapper around shared useGameCountdown (timeLimitSeconds / onExpire). |
usePhaseCompletion | hooks/use-phase-completion.ts | TBO convenience wrapper over shared useMiniGameCompletion; pre-binds TBO pause compensation and scoring config. Used by measure-place and place-verify (non-extracted phases). |
useThemedPortalRoot | hooks/use-themed-portal-root.ts | Client-only mount + getTradesThemePortalRoot for portaled overlays inside the themed host. |
useBodyScrollLock | hooks/use-body-scroll-lock.ts | Locks document.body overflow while active; optional Escape via ref-stable callback (reference drawer, tool preview). |
useLevelFromParams | hooks/use-level-from-params.ts | Resolves level from route params, owns runKey for canvas remount and showTutorial state. |
usePhasePrefetch | hooks/use-phase-prefetch.ts | Prefetches phase playfield chunks during idle states (levelIntro, walking, phaseIntro, countdown). |
usePersistOnResults | hooks/use-persist-on-results.ts | Saves level progress, submits cumulative score, and signals first completion when phase is "results". |
UI Components
Timed quiz / judge chrome uses platform QuizStepCounter + SegmentedStepTrack directly in McqPlayfield (safety-briefing, tool-id, plan-read adapters) and JudgePlayfield (no TBO wrapper components).
| Piece | Location | Purpose |
|---|---|---|
| Timer (ring UI) | GameTimerRing | Playfields import GameTimerRing as TimerRing from @/components/game/game-timer-ring. |
AnswerButton | @/components/game/answer-button | Platform multiple-choice row (import from components/game). |
McqPlayfield | lib/mini-games/mcq/mcq-playfield.tsx | Shared timed MCQ shell; TBO phases use thin adapters that pass phase time limit and pause compensation. |
PhaseTransition | components/phase-transition.tsx | Phase intro/complete overlays (compose GameOverlayPanel). |
ReferenceDrawer | components/reference-drawer.tsx | GameFloatingActionButton + portaled GameOverlayPanel for reference sheets. |
Phase Metadata Registry
phase-meta.ts is the single source of truth for phase display metadata (icon, name, tagline, how-to-play description). Used by phase-transition.tsx, run-results.tsx, and the play page. Access via:
import { getPhaseMeta, getPhaseName } from "@/games/trades-build-off/phase-meta";
Image Utilities
lib/image-utils.ts exports getItemImageSrc(imageId) (shared tools + decoys under public/games/trades-build-off/) and getTradesBlueprintImageSrc(imageId) for game-local blueprint and tbo-* assets in public/games/trades-build-off/. lib/theme-portal-root.ts exports getTradesThemePortalRoot() for portaled UI (reference packet, tool image preview) so semantic tokens stay inside the themed host root ([data-theme]).
Site Walk (PixiJS)
The site walk is a 2D side-scrolling scene rendered with PixiJS. It plays between levels and during phase transitions to give spatial context to the construction site.
Architecture
hooks/use-pixi-app.ts # PixiJS Application lifecycle (init, resize, destroy)
components/site-walk/
├── site-walk-canvas.tsx # React wrapper — scene setup, input handling
├── scene-renderer.ts # PixiJS scene: backgrounds, character, props, stations, camera
└── index.ts # Barrel export
usePixiApp(inhooks/) owns the PixiJSApplicationlifecycle: creation, canvas mount, resize listener, and cleanup. It returnsappRefandreadyReffor the consumer.SiteWalkCanvasuses it and sets up theSceneRendereronce the app is ready.SiteWalkCanvasis dynamically imported (next/dynamic) since it only renders during the site walk phase.SceneRenderermanages a layered scene: far background (parallax), mid-ground props (cullable = truefor offscreen optimization), stations with interaction zones, and an animated character with walk cycle.- Pre-built offscreen arrow indicators toggle visibility instead of rebuilding each frame.
destroy()callsAssets.unload()to release GPU textures.- Scene configuration lives in
content/scenes.ts.
File Structure
games/trades-build-off/
├── store.ts # Orchestrator Zustand store
├── config.ts # Phase IDs, timing, grade thresholds
├── types.ts # MiniGameProps contract + game types (PhaseResult = MiniGameResult alias)
├── scoring.ts # Phase score calculation (delegates to shared calculateWeightedScore)
├── phase-meta.ts # Phase display metadata registry (icons, names, taglines, how-to-play)
├── phases/
│ ├── safety-briefing/ # Thin adapter → McqPlayfield
│ ├── gear-up/ # Thin adapter → TapSelectPlayfield
│ ├── hazard-judge/ # Thin adapter → CardJudgePlayfield
│ ├── tool-id/ # Thin adapter → McqPlayfield
│ ├── tool-spotter/ # Thin adapter → MultiSelectPlayfield
│ ├── plan-read/ # Thin adapter → McqPlayfield
│ ├── measure-place/ # TBO-specific (ruler mechanics, dimension-read bridges)
│ ├── build-order/ # Thin adapter → DragOrderPlayfield
│ └── place-verify/ # TBO-specific (sequential verification checks)
├── components/ # Orchestrator UI
│ ├── phase-runner.tsx # Renders active phase via PHASE_REGISTRY
│ ├── phase-transition.tsx # Phase intro/complete screens (uses phase-meta)
│ ├── run-hud.tsx # In-game HUD (score, phase progress)
│ ├── run-results.tsx # End-of-run results (wraps shared ResultsScreen)
│ ├── how-to-play.tsx # How-to-play overlay
│ ├── level-list.tsx # Level selection grid
│ ├── reference-drawer.tsx # FAB + portaled reference sheet (timer keeps running)
│ └── site-walk/ # PixiJS site walk scene
│ ├── site-walk-canvas.tsx
│ ├── scene-renderer.ts
│ └── index.ts
├── hooks/
│ ├── use-game-actions.ts # Persistence (wraps useGameActionsBase)
│ ├── use-game-audio.ts # BGM/SFX management
│ ├── use-level-from-params.ts # Level resolution from route params + run lifecycle
│ ├── use-persist-on-results.ts # Save progress + submit score on results phase
│ ├── use-phase-completion.ts # TBO convenience wrapper over shared useMiniGameCompletion
│ ├── use-phase-prefetch.ts # Prefetch phase chunks during idle states
│ ├── use-phase-timer.ts # TBO convenience wrapper over useGameCountdown
│ └── use-pixi-app.ts # PixiJS Application lifecycle (init, resize, destroy)
├── lib/
│ ├── image-utils.ts # Image path resolution (shared vs game-specific)
│ └── theme-portal-root.ts # Portal target for themed overlays
├── content/
│ ├── levels.ts # Level configurations
│ ├── scenes.ts # PixiJS scene configurations
│ ├── reference-packs.ts # Reference packet pages for the drawer
│ ├── plan-read-scenarios.ts # Plan-read phase content
│ ├── dimension-read-challenges.ts # Measure-phase drawing MCQ + target inches
│ ├── hazard-install-scenarios.ts # Install-vs-plan hazard scenarios
│ ├── reference-resolve.ts # Merges pages for ReferenceDrawer
│ └── image-prompts.ts # AI prompts (sprites, hazards, `TBO_BLUEPRINT_PROMPTS`)
├── image-gen.config.ts # Image generation pipeline config
└── index.ts
Shared Platform Usage
The game leverages the following shared infrastructure — do not reimplement these:
| Shared Asset | Import | Usage |
|---|---|---|
ResultsScreen | @/components/game/results-screen | Wrapped by run-results.tsx |
Countdown | @/components/game/countdown | Pre-phase 3-2-1-GO |
useGameActionsBase | @/lib/hooks/use-game-actions | Persistence base |
extractTopicProgress | @/lib/hooks/use-game-actions | Level progress extraction |
calculateAccuracy | @/lib/scoring | Delegated by calculatePhaseAccuracy |
assignGrade, getGradeStyle | @/lib/scoring | Grade assignment in results |
sharedImage | @/lib/images/registry | Shared tool/PPE/material image paths |
SHARED_SPRITE_STYLE | @/lib/images/prompts | Extended by game-specific SPRITE_STYLE |
getDifficultyBadgeClass | @/lib/ui/difficulty | Difficulty badge styling |
Persistence
- Wraps
useGameActionsBase("trades-build-off-state") - Save state:
{ levelProgress: { level_N: { bestScore, stars, phaseResults } }, lastPlayedLevel } getProgressusesextractTopicProgressto map stored entries toLevelProgress- On run completion:
submitScore(cumulative)->updateLevelProgress()->signalComplete()(first completion only) - Guest mode: versioned localStorage