Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Tunables Design (2026-08)

Tunables & data-driven design (2026-08-08)

Companion to ARCHITECTURE-REVIEW-2026-08.md §1 — split out into its own doc because it’s a reusable discipline for this codebase, not a one-off note buried in a bigger refactor writeup. Covers: what actually varies by season, why that’s two different problems, and how each gets modeled — including a same-day reversal on one of the two.

Two different problems, not one “tunables system”

Content that varies by season — extra hazards/items/platforms/wind zones. Fixed by giving each level its own explicit per-season LevelData object (spread the previous season’s, override arrays), replacing the season2Extra* splice-at-load-time pattern. This stays a plain data shape — a level’s hazard list isn’t an entity-component relationship, it’s just “what’s in this level’s data.” No reversal here; per-level LevelData per season is still the right fix, unrelated to the recs question below.

Bare numeric constants that vary by season — e.g. HAZARD_REVEAL_DISTANCE_S1 = 130 / HAZARD_REVEAL_DISTANCE_S2 = 85, picked by season === 2 ? ... : ... in main.ts. A two-constant ternary is a 2-way switch by construction — it doesn’t extend to a third season, it has to be rewritten. This is the one that needed real design discussion.

What’s already established practice, elsewhere

Checked before proposing anything: MUD’s own answer to “structured, tunable config data” is a table with a schema and a key (mud.dev/world/tables, e.g. keyed by player) — the same idiom this codebase already half-uses for SEASON2_LAYERS[id] (keyed by level id). The general game-design term for the same move — pulling tunable/balance values into a data structure instead of scattering constants through control flow — is data-driven design. Both point the same direction: don’t hardcode a value per branch, look it up from a table keyed by whatever varies.

How the decision actually got made (kept as the record, not just the answer)

Three passes, same day, worth keeping because the reasoning survived better than either extreme:

Pass 1 — plain table, no recs. Reasoning: “a season isn’t an entity being queried, it’s a single global config lookup” — using the same test that correctly justified recs for Player equipment (a real entity, proven by a regression test showing two Player instances have independent equipment), but applied to today’s 2-season reality rather than to what’s actually being planned.

Pass 2 — overridden toward recs. Seasons are becoming a real, growing population — Season 3 is planned work, not speculative — and a Record<number, T> assumes every season defines every column; a genuinely-new-to-Season-3 tunable would mean optional fields sprinkled everywhere, the same shape problem season2Extra* already has today, one level up. Sparse, heterogeneous per-entity data queried generically is what recs components are for, plus a real synergy with the generic debug-panel idea (ARCHITECTURE-REVIEW-2026-08.md §2 addendum).

Pass 3 — corrected by precisely defining ECS, then classifying every real site against it (not against pre-ECS code habits). ECS earns its place only when (a) there’s a real population of independently-existing things carrying heterogeneous data, and (b) something needs to query across a subset of them generically — not just “look up this one specific thing I already know the identity of.” Having multiple keys in a lookup table doesn’t satisfy (b) on its own — every enum-keyed config object would qualify if it did. Going through §1’s full 16-site inventory against this bar:

SiteClassification
season2Extra* hazards/items/platforms/wind zonesLevel content — dissolves into per-season LevelData fields, no component
SEASON2_LAYERS[id] (music)Also dissolves — becomes a musicLayer field directly on each season’s LevelData
DANCE_LEVEL_ID/UPPERWORLD_LEVEL_ID (the Upperworld build)Level-existence — a mechanic exists because a season-specific LevelData references it via a portal. Already correctly shaped (clouds_s2.ts/tanzstunde_s2.ts)
hasSoccerShoes equipment gatesPlayer’s concern, already solved, unrelated to Season
HAZARD_REVEAL_DISTANCE_S1/_S2The one genuinely global, cross-level numeric constant with no per-level home
markGameCompleted() season gateMarginal one-off meta boolean, not worth componentizing

Almost everything dissolves into §1’s already-agreed per-season-LevelData refactor with zero new machinery — including everything the Upperworld build touched, which independently arrived at the correct level-content-scoped shape without needing this doc at all. The one real, clean candidate — precisely because it’s global and cross-level, not because “seasons are a population” in the abstract — is HazardRevealDistance.

The design, as built

Season is a real recs entity; the one genuinely-global tunable is a component on it, same createWorld/defineComponent/setComponent/getComponentValue API the equipment refactor already uses on Player — not a hand-rolled port, the actual installed library. Implemented in src/game/season.ts:

export const seasonsWorld = createWorld();
const SEASON_1 = createEntity(seasonsWorld, undefined, { id: 'season1' });
const SEASON_2 = createEntity(seasonsWorld, undefined, { id: 'season2' });

const HazardRevealDistance = defineComponent(seasonsWorld, { value: Type.Number }, { id: 'HazardRevealDistance' });
setComponent(HazardRevealDistance, SEASON_1, { value: 130 });
setComponent(HazardRevealDistance, SEASON_2, { value: 85 });

main.ts’s one read site is now hazardRevealDistanceFor(season), replacing the old HAZARD_REVEAL_DISTANCE_S1/_S2 constant pair and its ternary. Season 3 adding a genuinely new global tunable is a new defineComponent call in season.ts, not a new constant pair plus a rewritten ternary at the call site.

Scope note

Music-layer selection and level-existence checks (DANCE_LEVEL_ID etc.) do not become Season components, despite varying by season — per the classification above, they’re level content and dissolve into §1’s per-season LevelData refactor instead. Don’t force everything through season.ts just because the module exists; re-check which category any new season-variant value actually falls into first.

Status

Implemented, verified, and merged to main: src/game/season.ts + src/game/season.test.ts (3 unit tests). tsc --noEmit clean, vitest run 10/10, both Playwright smoke suites re-verified end-to-end on trullala via tools/fleet_test.sh (6/6 PASS) — Minigolf’s hazard-reveal behavior (the one thing this touches) unchanged. The content half of §1 (the per-level LevelData-per-season restructure itself) is also done — see ARCHITECTURE-REVIEW-2026-08.md §1 — so §1 is now fully closed, both halves.