← Back
Play

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.

PhaseShared Mini-GameDescription
Safety BriefingMcqPlayfieldMultiple-choice quiz. Answer safety questions to pass orientation.
Gear UpTapSelectPlayfieldTap-to-select PPE from a grid. Pick the right gear, avoid decoys.
Spot the ProblemCardJudgePlayfieldCard-based judgment. Read a site condition, tap SAFE or HAZARD. Supports install check cards.
Know Your ToolsMcqPlayfieldTool identification quiz. Match hints to the correct tool name.
Tool CheckMultiSelectPlayfieldMulti-select tools for a project. Wrong picks cost points. Image preview overlay.
Read the PlanMcqPlayfieldSequential 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 OrderDragOrderPlayfieldDrag-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:

idUI badgeTitleDifficultyPhases
1TutorialFirst Day on SiteEasySafety Briefing → Gear Up → Spot the Problem → Know Your Tools
2Level 1Build a SawhorseEasyGear Up → Tool Check → Read the Plan → Measure & Cut → Build Order → Check Your Work
3Level 2Frame a Stud WallEasyGear Up → Tool Check → Spot the Problem → Read the Plan → Measure & Cut → Build Order
4Level 3Wire a Light SwitchMediumSafety Briefing → Gear Up → Spot the Problem → Tool Check → Read the Plan
5Level 4Build a Shelf UnitMediumGear Up → Tool Check → Read the Plan → Measure & Cut → Check Your Work
6Level 5Rough-In a Drain LineMediumSafety Briefing → Gear Up → Tool Check → Read the Plan → Build Order
7Level 6Frame a Door OpeningHardGear Up → Read the Plan → Measure & Cut → Spot the Problem → Build Order
8Level 7Hang DrywallHardSafety Briefing → Gear Up → Tool Check → Read the Plan → Measure & Cut → Check Your Work
9Level 8Wire a Kitchen CircuitHardSafety Briefing → Gear Up → Read the Plan → Measure & Cut → Spot the Problem → Build Order
10Level 9Set Concrete FormsExpertSafety Briefing → Gear Up → Tool Check → Read the Plan → Measure & Cut → Check Your Work
11Level 10Final WalkthroughExpertGear 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)

RegistryFilePurpose
Reference packscontent/reference-packs.tsMulti-page images + captions for the drawer.
Plan read scenarioscontent/plan-read-scenarios.tsitems[] of mcq steps (prompt, choices, correctIndex, optional imageId).
Dimension read challengescontent/dimension-read-challenges.tsDrawing image + MCQ + revealedTargetInches (must match the phase blueprint target).
Install check hazardscontent/hazard-install-scenarios.tsMerged into hazard-judge config.ts; conditions with mode: "install_check", planImageId, fieldImageId.
Build order configsphases/build-order/config.tsBuildOrderConfig with steps[] and correctOrder.
Check work configsphases/place-verify/config.tsCheckWorkConfig 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:

Difficulty3 Stars2 Stars
Easy90%+65%+
Medium85%+60%+
Hard80%+55%+
Expert75%+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.

FromEventTo
menuSTART_RUNlevelIntro
levelIntroSTART_GAMEwalking
walkingARRIVEphaseIntro
walkingPAUSEpaused
phaseIntroBEGIN_COUNTDOWNcountdown
countdownBEGIN_PHASEphaseActive
phaseActiveCOMPLETEphaseComplete
phaseActivePAUSEpaused
phaseCompleteADVANCEwalking or results
pausedRESUME(previousPhase)
resultsSTART_RUNlevelIntro

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

HookLocationPurpose
usePhaseTimerhooks/use-phase-timer.tsThin wrapper around shared useGameCountdown (timeLimitSeconds / onExpire).
usePhaseCompletionhooks/use-phase-completion.tsTBO convenience wrapper over shared useMiniGameCompletion; pre-binds TBO pause compensation and scoring config. Used by measure-place and place-verify (non-extracted phases).
useThemedPortalRoothooks/use-themed-portal-root.tsClient-only mount + getTradesThemePortalRoot for portaled overlays inside the themed host.
useBodyScrollLockhooks/use-body-scroll-lock.tsLocks document.body overflow while active; optional Escape via ref-stable callback (reference drawer, tool preview).
useLevelFromParamshooks/use-level-from-params.tsResolves level from route params, owns runKey for canvas remount and showTutorial state.
usePhasePrefetchhooks/use-phase-prefetch.tsPrefetches phase playfield chunks during idle states (levelIntro, walking, phaseIntro, countdown).
usePersistOnResultshooks/use-persist-on-results.tsSaves 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).

PieceLocationPurpose
Timer (ring UI)GameTimerRingPlayfields import GameTimerRing as TimerRing from @/components/game/game-timer-ring.
AnswerButton@/components/game/answer-buttonPlatform multiple-choice row (import from components/game).
McqPlayfieldlib/mini-games/mcq/mcq-playfield.tsxShared timed MCQ shell; TBO phases use thin adapters that pass phase time limit and pause compensation.
PhaseTransitioncomponents/phase-transition.tsxPhase intro/complete overlays (compose GameOverlayPanel).
ReferenceDrawercomponents/reference-drawer.tsxGameFloatingActionButton + 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 (in hooks/) owns the PixiJS Application lifecycle: creation, canvas mount, resize listener, and cleanup. It returns appRef and readyRef for the consumer. SiteWalkCanvas uses it and sets up the SceneRenderer once the app is ready.
  • SiteWalkCanvas is dynamically imported (next/dynamic) since it only renders during the site walk phase.
  • SceneRenderer manages a layered scene: far background (parallax), mid-ground props (cullable = true for 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() calls Assets.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 AssetImportUsage
ResultsScreen@/components/game/results-screenWrapped by run-results.tsx
Countdown@/components/game/countdownPre-phase 3-2-1-GO
useGameActionsBase@/lib/hooks/use-game-actionsPersistence base
extractTopicProgress@/lib/hooks/use-game-actionsLevel progress extraction
calculateAccuracy@/lib/scoringDelegated by calculatePhaseAccuracy
assignGrade, getGradeStyle@/lib/scoringGrade assignment in results
sharedImage@/lib/images/registryShared tool/PPE/material image paths
SHARED_SPRITE_STYLE@/lib/images/promptsExtended by game-specific SPRITE_STYLE
getDifficultyBadgeClass@/lib/ui/difficultyDifficulty badge styling

Persistence

  • Wraps useGameActionsBase("trades-build-off-state")
  • Save state: { levelProgress: { level_N: { bestScore, stars, phaseResults } }, lastPlayedLevel }
  • getProgress uses extractTopicProgress to map stored entries to LevelProgress
  • On run completion: submitScore(cumulative) -> updateLevelProgress() -> signalComplete() (first completion only)
  • Guest mode: versioned localStorage