Engineering Policy (Beta)
The key words MUST, MUST NOT, SHOULD, and MAY are used as in RFC 2119. These are the working rules for all Beta implementation work; they exist so the Alpha cleanup cost (#215's 50-item audit) doesn't repeat.
Class hierarchies: behavior in bases, leaves earn their existence
Shared behavior MUST live in a base class with a clear interface — Enemy owns AI dispatch, status handling, damage intake; WolfEnemy / SmallEnemy-style subclasses exist only where behavior genuinely diverges (a different FSM, a different movement model). This applies to enemies, player classes, weapons, and shops alike.
- Data variation is not behavior. Stats, models, elements, spawn tables stay in resources (
EnemyData,WeaponData) — a new enemy that only differs in numbers MUST be a new resource, not a new script (per /states/enemies). - Leaf classes are one-offs that earn it. A PR adding a leaf class MUST state why the behavior can't be expressed in the base (a hook, a parameter, a resource field). "It was faster" is the answer we're guarding against.
- When the second similar thing appears, extract the base first. Don't ship sibling copies and dedup later — that was Alpha's pattern and it cost a milestone to undo.
- Every hierarchy gets a spec page. Expected behavior of the base contract (which methods subclasses override, which invariants hold) lives under /hierarchies — Enemy, Weapon, and the shop-screen composition contract are defined; new hierarchies add a page before/with implementation.
- Known exception — shop screens. Lazily-loaded 2D screens MUST NOT use cross-script
class_nameinheritance: it breaks the Android export (Could not resolve class "ShopBase"— seedocs/shop-dedup.md). Screens reuse via preloaded static helpers (ShopNav,ShopUI) — same grouping goal, composition mechanism.
Tech debt: blocked by default, loud when deliberate
The CI ratchet (code_graph.py --check) fails any PR introducing new duplication, dead code, complexity, or coupling. Accepting debt by growing a baseline is possible but MUST be acknowledged: the baseline-debt guard fails any PR that increases an accepted-debt count unless a commit carries a Debt-Accepted: <reason> trailer.
- Baseline decreases are wins and pass silently — shrink freely.
- Deliberate one-off debt belongs at the end of beta, if at all. A mid-beta
Debt-Acceptedis a prompt to ask why now? — the answer goes in the trailer.
Testing: two layers for anything that moves
Every gameplay mechanic — and especially attacks, timing windows, and frame-dependent behavior — MUST ship with both layers:
| Layer | What | Catches |
|---|---|---|
| Unit | Seeded test in test_runner.gd pinning the math / window classification / state transition (the test_element_status / test_player_states pattern) | formula and logic regressions, deterministically |
| Post-build | An autopilot probe — a matrix phase, smoke script, or sanity checkpoint that exercises the mechanic in the running game | integration breakage units can't see (the #335 lesson: the unit suite was green while the autopilot hung) |
A combat increment whose plan has only one layer is not done being planned. The unit layer is the merge gate in CI; the post-build layer runs in the sanity check (every merge) and the full matrix (every combat-behavior PR).
Enforced by
scripts/tools/code_graph.py --check— the four-way ratchet (CI, every PR).scripts/tools/check_baseline_debt.py— the debt-acknowledgment guard (CI, every PR).scripts/tools/autoplay/merge_gate.sh— blocks merges without a green sanity run for the exact HEAD.scripts/tools/autoplay/run_regression_matrix.sh— the full post-build layer (dev-box, before combat merges).