Audience: contributors extending or maintaining the codebase.
src/
├── core/ Pure TypeScript — no browser APIs, fully testable
│ ├── types.ts All shared type definitions
│ ├── config.ts Runtime exports from generated YAML config
│ ├── gameConfigSchema.ts Typed validation schema for config/game.yaml
│ ├── generatedGameConfig.ts Auto-generated from YAML (do not hand-edit)
│ ├── engine.ts Game state machine (createInitialState, processMoveAction, …)
│ ├── score.ts Scoring formula
│ ├── catalysts.ts Catalyst definitions
│ ├── protocols.ts Protocol definitions
│ ├── phases.ts Phase list (thin wrapper around PHASE_CONFIG)
│ ├── synergies.ts Synergy definitions
│ ├── signals.ts Signal definitions
│ ├── anomalies.ts Anomaly effect logic
│ ├── profile.ts ProfileState helpers
│ └── unlockConfig.ts Default unlock sets and costs
├── store/
│ ├── gameStore.ts Zustand store wrapping the engine
│ └── profileStore.ts Persistent profile store (localStorage)
├── ui/
│ ├── App.tsx Root component + screen routing
│ ├── style.css All styles
│ └── components/ Individual UI components
├── i18n/ Internationalisation (en / zh-CN)
├── theme/ Visual theme system
├── benchmark/ Benchmark agents + suites (Node-only)
├── ai/ AI agents (Node-only)
└── scripts/ CLI runners (Node-only)
The benchmark/, ai/, and scripts/ directories are excluded from the web
build via tsconfig.json.
GameState (in src/core/types.ts) is the single source of truth for a run.
It is immutable — every engine function takes a state and returns a new one.
Key fields:
| Field | Description |
|---|---|
screen |
Current screen: start, playing, forge, game_over, round_complete, run_complete |
protocol |
Active ProtocolId for this run |
phaseIndex |
0-based index into PHASES |
stepsRemaining |
Steps left in current phase |
energy |
Spendable energy |
output |
Accumulated output for current phase |
totalOutput |
Accumulated across all phases |
activeCatalysts |
IDs of currently equipped catalysts (max 6) |
unlockedCatalysts |
Available catalyst pool for this run (undefined = full pool) |
globalMultiplier |
Base multiplier accumulator (Forge utility and build effects) |
momentumMultiplier |
Current momentum bonus |
protocol |
Active protocol for this run |
activePattern / patternLevels |
Run-long archetype progression layer |
lastIntermissionMessage |
Player-facing feedback text for Forge outcomes |
ProfileState (in src/core/types.ts) is the persistent player record,
kept separate from GameState so the engine stays pure.
interface ProfileState {
unlockedCatalysts: CatalystId[];
unlockedSignals: SignalId[];
unlockedProtocols: ProtocolId[];
unlockedAnomalies: AnomalyId[];
unlockedAscensionLevel: AscensionLevel; // 0–8
metaCurrency: number; // Core Shards
}The profile is managed by useProfileStore (Zustand) in
src/store/profileStore.ts.
{
"unlockedCatalysts": ["corner_crown", "twin_burst", "..."],
"unlockedSignals": [],
"unlockedProtocols": ["corner_protocol"],
"unlockedAnomalies": ["entropy_tax", "collapse_field"],
"unlockedAscensionLevel": 0,
"metaCurrency": 0
}// profileStore.ts
function loadProfile(): ProfileState {
if (?debug=unlock_all) return allUnlocked;
try {
const raw = localStorage.getItem('merge_catalyst_progress');
if (raw) return { ...DEFAULT_PROFILE, ...JSON.parse(raw) };
} catch { /* storage unavailable */ }
return { ...DEFAULT_PROFILE };
}This ensures:
- First visit → DEFAULT_PROFILE (8 legacy catalysts only)
- Returning visit → persisted progress
- Incognito → DEFAULT_PROFILE (no crash, correct locked state)
?debug=unlock_all→ all catalysts visible (development only)
useProfileStore.setProfile() and unlockCatalysts() both call
persistProfile() which writes to localStorage synchronously. Errors (e.g.
quota exceeded in incognito) are silently swallowed to avoid crashing the game.
Start Screen
│ user selects protocol + clicks "Start Run"
▼
useGameStore.initAndStart(seed, protocol)
│ → createInitialState(seed, protocol) (screen: 'playing')
▼
Playing Screen
│ processMoveAction(state, dir) per move
▼
Phase Clear (output >= targetOutput) OR Steps Exhausted
│
├── Steps remain: forge screen
└── Steps exhausted: game_over
│
Any Phase Clear → Forge Screen (buyForgeItem / sellCatalyst / sellPattern / sellSignal / rerollForge / skipForge)
│
Phase 6 Clear → run_complete
- Forge purchases are blocked if the Catalyst is already owned.
- Duplicate purchase attempts must not consume Energy or mutate run state.
buyFromForgekeeps a hard engine guard even if UI checks fail.- Full-slot Catalyst acquisition requires explicit replacement and records feedback.
- Add
ProtocolIdliteral tosrc/core/types.ts - Add
ProtocolDeftoPROTOCOL_DEFSinsrc/core/protocols.ts - Add i18n keys (
protocol.<id>.name,protocol.<id>.description) - Add icon + difficulty tag to
src/ui/components/StartScreen.tsx
- Add
CatalystIdliteral tosrc/core/types.ts - Add
CatalystDeftoCATALYST_DEFSinsrc/core/catalysts.ts - Implement effect in
src/core/score.tsand/orsrc/core/engine.ts - Add i18n entries and unlock condition
- Append entry to
PHASE_CONFIGinsrc/core/config.ts{ phaseNumber: 7, targetOutput: 100, steps: 10, expectedOutput: 130, highSkillOutput: 220, challengeTier: 'big', }
- No engine changes needed — the engine reads
PHASES.lengthdynamically
- Score-based: check
state.totalOutputincalculateRunReward - Phase completion: hook into
advancePhasein engine - Discovery: add flag to
ProfileStateand set it on first encounter
npm run dev # start Vite dev server
npm run build # TypeScript + Vite production build
npm test # run all unit tests (vitest)
npm run benchmark # run baseline benchmark suite
npm run balance # run balance + pacing + round-stress suites, generate report
npm run balance:tune # run heuristic-driven auto-tuning loop + tuning artifacts
npm run report:run -- artifacts/run.json [out.md] # single-run Markdown + HTML report
npm run report:compare -- artifacts/before.json artifacts/after.json [out.md] # comparison report
npm run report:meta -- artifacts/run.json [out.md] # meta-health build ecosystem report
npm run docs:assets # generate Mermaid diagram SVGsnpm run balance is self-contained and writes outputs to artifacts/benchmark/latest/.
Typical loop:
- Tune values in
config/game.yaml - Run
npm run balance - Review
artifacts/benchmark/latest/balance_report.mdand charts - Compare with previous benchmark artifacts before committing tuning changes
Auto-tuning loop:
- Run
npm run balance:tune - Review
tuning_summary.md,before_vs_after.md,best_config.json, andbest_config.yaml - Apply accepted recommendations back into
config/game.yaml
| Tool | How to Activate |
|---|---|
| Unlock all catalysts | Add ?debug=unlock_all to URL |
| Export current run logs | End Screen → Export Run Log (JSON/CSV) |
| Export all local run logs | Add ?debug=export_logs on Start Screen |
| Run benchmark in CI | npx tsx src/scripts/runBenchmark.ts --suite smoke |
| Meta benchmark | npm run benchmark:meta |
Run logs are persisted in localStorage (mcata_run_logs) with schema version 2.0.0.
Exports use bundle schema run-log-export.v1 and include:
- run metadata (
runId, seed, timestamps, rounds/stages reached, final output, highest tier) - build snapshot (active boosts/combos/skills/style/rule + build label)
- per-step records (action, board before/after, score breakdown, triggered effects, energy before/after)
- derived analysis fields (avg output per move, avg moves per stage, energy earned/spent, late-game clear speed)
- config snapshot (
GAME_CONFIG+ balance version) for reproducibility/tuning
Export entry points:
- End Screen: player-facing
Export Run Log (JSON)/Export Run Log (CSV)for current run - Start Screen with
?debug=export_logs: developer-facing all-runs JSON/CSV + run-summary CSV
Analysis utility:
npm run runlog:analyze -- artifacts/run_a.json
npm run runlog:analyze -- artifacts/run_before.json artifacts/run_after.jsonThe utility parses exported bundles, prints summaries, and compares aligned run records.
Generate human-readable Markdown + HTML reports from exported run-log bundles:
# Single-run report (prints to stdout)
npm run report:run -- artifacts/my_run.json
# Single-run report written to file (also produces my_report.html)
npm run report:run -- artifacts/my_run.json artifacts/my_report.md
# Before-vs-after comparison (two bundles → before-vs-after diff)
npm run report:compare -- artifacts/before.json artifacts/after.json
# Multi-run comparison (three or more bundles → summary table)
npm run report:compare -- artifacts/run_a.json artifacts/run_b.json artifacts/run_c.json out.mdEach report covers:
- Run Overview — runId, seed, outcome, rounds/stages, highest tier, build identity
- Build Summary — active boosts/combos/skills, style, rule
- Pacing Summary — avg moves per stage, quick-clear phases, longest phase
- Economy Summary — energy earned/spent/balance, shop affordability
- Key Moments — highest-output move, strongest combo, turning point, failure point
- End-of-Run Diagnosis — what likely helped / limited the run most
Comparison reports additionally include:
- Side-by-side metric diff table with % changes
- Config diff (when bundle config snapshots differ)
- Automated pacing, economy, and tier-growth interpretation
Detect dominant, dead, trap, niche, and healthy build identities across a collection of runs:
# Meta-health report (prints to stdout)
npm run report:meta -- artifacts/my_run.json
# Meta-health report from multiple bundles, written to file (+ .html)
npm run report:meta -- artifacts/run_a.json artifacts/run_b.json artifacts/meta.mdThe meta-health report:
- Aggregates stats per build identity (pick rate, avg output, rounds cleared, tier, moves/stage, energy)
- Classifies each build as dominant 🔴, healthy 🟢, niche 🔵, dead ⚫, or trap 🟡
- Raises ecosystem-level flags (dominant centralisation, many dead builds, trap choices)
- Provides actionable suggestions (price/rarity/multiplier adjustments) for each non-healthy build
Classification thresholds (see src/scripts/metaHealthAnalysis.ts):
| Class | Condition |
|---|---|
| Dominant | pick rate ≥ 30% AND avg output ≥ 1.30× global average |
| Dead | pick rate < 10% AND avg output < 0.80× global average |
| Trap | pick rate ≥ 10% AND avg output < 0.85× global average |
| Niche | pick rate < 10% AND avg output ≥ global average |
| Healthy | everything else |
- Pure engine —
src/core/has zero browser API dependencies. - One source of truth — numeric tuning lives in
config/game.yaml. - Immutable state — every engine function returns a new state object.
- i18n everywhere — all user-facing strings use
useT()/t(). - No magic numbers — reference named constants from
config.ts(YAML-derived).