Tier 2 · System design

System design before code

Box every new system into a fixed shape (data, runtime, authoring tool) before writing code.

Box every new system into a fixed shape (data, runtime, authoring tool) before writing code.

Every other rule in this library is a PROHIBITION. Prohibitions leave infinite design space, so an agent that obeys all of them still invents a new bad shape each time and the debt returns wearing a different face. This file is the positive half: the shape is prescribed, and anything that is not this shape is wrong even when it breaks no named rule.

Two layers, two shapes, one seam between them. Pick by where the state lives, never by convenience.

The declaration (write it BEFORE the code, in the plan or report)

SYSTEM:        <name>
RESPONSIBILITY:<one sentence>
OWNS (writes): <the one piece of state it is the SOLE writer of>
READS:         <what it consumes, and from whom>
PUBLISHES:     <the event / component it raises, and the payload>
BLAST RADIUS:  <the exact subscriber list, and how you enumerated it>
ORDER:         <UpdateInGroup / execution order, or the lifecycle hook>
TEARDOWN:      <the paired removal for every subscription, allocation and registration>
AUTHORING:     <where a human inspects and changes this without an agent and without editing C#>
  • If RESPONSIBILITY needs the word "and", it is two systems. Split it now; splitting later means untangling two states that have grown into each other.
  • If BLAST RADIUS cannot be enumerated, STOP — that is a coupling defect, found. See CHAIN-MAP-IS-A-DECOUPLING-AUDIT. Fix the coupling; do not proceed with a guess and do not widen the search to compensate.
  • If OWNS names state another system also writes, the design is already wrong. One writer. Every other system reads.
  • TEARDOWN is written at the same time as the registration, never "added later". Statics survive domain reload; a subscription with no paired removal is a leak with a delayed fuse.
  • If AUTHORING is "read the code", the system is not designed yet. Every feature ships the surface a human operates it from, in the same slice as the feature. See Shape C below; "n/a" is a legitimate answer only for a system with no numbers, no catalog and no inspectable state, and you write why rather than leaving the line blank.
  • The declaration is cheap (9 lines) and it is where design failures are catchable. After the code exists they cost a refactor.

Shape A — MonoBehaviour / presentation / flow layer

Canonical annotated code: examples/system-ui-split.md. That file is the REFERENCE IMPLEMENTATION, not an illustration — a new manager/controller/view matches it or explains why in the declaration.

gameplay system → static event → data system → static event → view → UnityEvent → animation / sfx
  • Manager owns exactly one responsibility and is the sole writer of that state. It publishes what happened; it never reads or calls another manager. → the C# style page EVENT-ONLY-MANAGER-TOPOLOGY.
  • Emit facts, not other systems' conclusions. OnEnemyKilled(enemyId), never OnScoreEarned(10) — points are the score system's business, and the lookup source stays swappable precisely because combat never knew it.
  • A controller belongs to one manager and references only that manager. Everything else it needs arrives as an event it subscribed to.
  • The view holds only the refs it directly drives, subscribes to the system's static events, and fans out through designer-wired UnityEvents so animation/audio/other widgets need no C# refs.
  • Every reference is [SerializeField], wired at design time on a prefab. No scan, no Find, no GetComponent, no caching a scan. → the references page.
  • The static event IS the seam. A subscriber references the TYPE, never an Instance.

Shape B and the seam (DOTS / ECS)

The DOTS / ECS layer shape (Shape B) and the seam from ECS state to presentation now live in the DOTS & ECS section, on the ECS core mechanics page. Shape A, Shape C and the declaration below apply to every project.

Shape C — the authoring surface (every feature ships one)

Shapes A and B decide how the system is built. Shape C decides whether anyone but the agent that wrote it can operate it. A feature reachable only through an agent is a black box that agent built around its own job, and it is the failure mode this whole library exists to prevent — the debt is not in the code, it is in the fact that nobody else can move it.

Reference implementation: examples/designer-console.md. Mechanics and the tuning-layer split: the editor tooling page.

  • One host window per project; a feature contributes a TAB, never a window. The tab is discovered by reflection with no registration call, so there is no static list to leak across a domain reload. A one-shot utility contributes a tagged static method instead. Two windows over the same asset is ONE-PATH-CODE-SHAPE broken with a nicer UI.
  • The tool assembly graph is one-way and compiler-enforced. Domain editor assemblies reference a zero-dependency Contract assembly, never the shell. Tooling can then never cycle back into gameplay, and it can never become load-bearing.
  • The tab is a VIEW, never an owner. It reads and writes through the same asset, event or component the game uses. A value that lives only in the tab is a second source. A tab that reaches into a system is reporting a missing seam — that is the finding, not the fix.
  • The declaration's AUTHORING line names the surface before the code exists, for the same reason BLAST RADIUS does: retrofitting a tool means first discovering what the system's real inputs were.

Why this is the box

A prohibition list says what not to do and leaves the shape open. A blueprint fixes the shape, so the non-deterministic part of the work (naming, algorithm, the actual logic) varies while the structure does not. Tech debt in this codebase has never come from a clever algorithm; it came from a system that read something it did not own, so nobody could say what breaking it would affect.

Gates that back this file: tools/rule-check.sh (references, names, lookups) and tools/one-path-sweep.sh (second paths, manager topology). Neither can see a wrong SHAPE — that is what the declaration is for, which is why the declaration is written before the code and not after.

PLACEHOLDER-DIES-WITH-THE-SHAPE — when the real shape lands, the placeholder is migrated or deleted, never kept (HARD)

A placeholder is authored to unblock work before the model exists. The moment the model exists, that placeholder is live data pretending to be a decision, and every hour spent tuning behaviour against it is wasted twice — once making the change, once undoing it.

In the same slice that lands the shape, every existing instance is either migrated onto it or deleted. "We will migrate later" means the placeholder becomes the reference the next agent copies.

How placeholders announce themselves — treat each as a defect found, not a curiosity:

  • Instances that are identical where they should differ. Several bodies/items/factions carrying the same physical numbers is not a design; it is one placeholder pasted N times. Nothing in the artefact distinguishes "deliberate" from "copy-paste", so the next agent cannot tell either.
  • A field with no implementation behind it. A count, a flag or an id that nothing reads is a promise the data model does not keep. Either build what it describes or delete the field — leaving it advertises a capability that does not exist and it WILL be built against.
  • A value authored before the thing it references existed. A position placed before the terrain generator, a reference before the prefab, a threshold before the units settled. It was never measured against the real thing, so it is a guess with a timestamp.
  • A derived quantity that is authored by hand. If a number can be computed from other authored numbers, authoring it separately guarantees the two disagree eventually, and the disagreement is silent. Derive it and validate it; see the pipeline blueprint page INPUTS.

Do not build on top of a known placeholder. Fixing presentation, tuning, or behaviour against placeholder data produces a fix that is only correct for data you are about to throw away, and it hides the real defect underneath. When you find one mid-task: say so, land the shape and the migration first, and only then do the work that was asked. This outranks "do only what was asked" — the same way FIX-WHAT-YOU-LAND-ON does — because the alternative is delivering work that is already known to be wrong.

Record the retirement. The pipeline document (the pipeline blueprint page) names which stage produced each instance; when a placeholder is retired, the measured value that replaced it and the measurement that chose it go in the same commit. A number with no recorded measurement is the next placeholder.

  • BASELINE-IS-CAPTURED-ONCE — a scale/offset applier captures the authored baseline once per target, never from the current (already modified) value. Why: the captured value already contained the last application. Check: baselines are keyed by target and written only when absent; re-apply from the stored baseline. Status: locked (empirical, seen in shipped/active projects).
  • IDLE-PRESENTATION-KEEPS-THE-TURN-ACTOR — an idle/attract presentation must not drop the current-turn player. Why: presentation code wrote gameplay state. Check: presentation layers read turn state, never write it. Status: locked (empirical, seen in shipped/active projects).