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:
| Site | Classification |
|---|---|
season2Extra* hazards/items/platforms/wind zones | Level 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 gates | Player’s concern, already solved, unrelated to Season |
HAZARD_REVEAL_DISTANCE_S1/_S2 | The one genuinely global, cross-level numeric constant with no per-level home |
markGameCompleted() season gate | Marginal 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.