Architecture Rules
Every MVT rule on one page. Numbered, categorized, and linkable. When a rule here conflicts with a guide page, this page is the definitive authority.
Related: Architecture Overview · Style Guide · Glossary
These are MVT architectural rules - they apply to any codebase using the MVT pattern, regardless of language, renderer, or code style conventions. For this project's code style conventions (naming, factory functions, barrel files), see the Style Guide.
Model Rules
| # | Rule | Common Examples | Rationale |
|---|---|---|---|
| M1 | Models own all domain state and logic. | Position, health, score, phase transitions, collision rules | Single source of truth. Views and tickers never hold domain state. |
| M2 | All state advances through update(deltaMs) only. | No setTimeout, setInterval, requestAnimationFrame, auto-playing tweens, Date.now(), performance.now() | Determinism. Same update() calls produce the same state. The ticker controls time. Time Management |
| M3 | Models must not reference views or the ticker. | No imports from view modules; no scene-graph objects in model state | Separation of concerns. Models are independently testable. |
| M4 | Model state must use domain-level terms, not presentation-specific ones. | Fractional row/col for grid games; world-units for open arenas; named states ('inflating') rather than pixel values or hex colours | Presentation independence. Models work unchanged regardless of screen size or rendering technology. Models |
| M5 | Parent models delegate update(deltaMs) to child models. Cross-model concerns live in the parent. | Root game model calls ship.update(dt) then checks collisions | Hierarchical composition. Each child is independently testable. Model Composition |
View Rules
| # | Rule | Common Examples | Rationale |
|---|---|---|---|
| V1 | Views are stateless. They read state and write to the presentation output. | No domain logic, no autonomous animations, no internal domain state | Views are pure projections that can be replaced or multiplied without affecting behaviour. Views |
| V2 | refresh() runs once per frame, after all models have updated. | Read bindings, set sprite positions and text labels | Frame-consistent snapshots. No view sees a half-updated world. |
| V3 | refresh() must be idempotent. | Calling it twice with the same model state produces the same result | Predictability. No hidden side effects accumulate across frames. |
| V4 | refresh() must not mutate models, emit domain events, or trigger state transitions. | Domain actions are relayed through on*() bindings, not called directly in refresh() | Views are read-only projections. |
| V5 | All binding values must be re-read in refresh(), never cached at construction time. | Call bindings.getScore() inside refresh(), not in the factory closure | Bindings are reactive. Values may change between frames. Bindings in Depth |
| V6 | Presentation state is allowed only when purely cosmetic and the model does not need to know about it. | Animated score counter, fade-in on phase change | The exception, not the rule. Presentation State |
| V7 | When a view needs elapsed time for presentation animation, receive it through a binding. Views must not invent their own notion of time. | A getClockMs() binding driving an easing tween | Keeps presentation animations deterministic, frame-rate-independent, and testable. Presentation State |
| V8 | MVT views can target any output technology. | Canvas scene graph, DOM, audio system, debug panel | MVT is not coupled to a particular renderer. |
| V9 | View trees do not need to mirror model trees. | Multiple views from one model; models with no view; decorative views with no model | Domain structure and presentation needs are different concerns. Bindings decouple the two trees. View Composition |
Ticker Rules
| # | Rule | Common Examples | Rationale |
|---|---|---|---|
| T1 | Each frame follows a strict sequence: update, refresh, render. Never interleave or skip steps. | model.update(deltaMs) then view.refresh() then renderer draws | Models settle first, then views read a stable snapshot. The Ticker |
| T2 | Cap deltaMs to a safe maximum. | e.g. 100ms cap to handle backgrounded tabs | Prevents huge time leaps that could break non-leap-safe models. |
| T3 | The ticker contains no domain logic and no rendering code. | No collision checks, no sprite creation | Separation of concerns. It is purely a timing orchestrator. |
| T4 | The ticker may pause, slow down, speed up, or single-step time. | Pause overlay, slow-mo debug, frame-by-frame stepping | Models stay in sync because they only see deltaMs. |
Bindings Rules
| # | Rule | Common Examples | Rationale |
|---|---|---|---|
| B1 | Bindings are the contract between a view and the world. get*() reads state; on*() relays user input. | getScore(): number, onDirectionChanged(dir): void | Explicit dependencies. The bindings type is a complete manifest of everything the view requires or provides. Bindings |
| B2 | Reusable leaf views use a bindings interface. Top-level application views may access models directly. | HUD panel takes bindings; game-view takes the game model | Leaf views stay decoupled and reusable. Top-level views are application-specific. Bindings in Depth |
| B3 | on*() bindings should usually be optional. | A gamepad view with optional onFireChanged?() | Keeps views usable in more contexts without forcing no-op handlers. |
| B4 | Bindings are wired at the view construction site. | Parent view maps getScore: () => game.score.score | Decoupling. The view does not know how it is connected to the outside. |
Hot-Path Rules
| # | Rule | Common Examples | Rationale |
|---|---|---|---|
| H1 | Minimise per-tick computation cost. Prefer O(1) lookups over repeated traversals; cache derived values. | Flat array indexed by row * cols + col instead of Map<string, T>; pre-computed neighbour lists instead of per-tick neighbour scanning | Saves CPU budget for the frame. Hot Paths |
| H2 | Avoid per-tick heap allocations in update() and refresh(). | array.map(), template-string keys, for...of on arrays, inline closures, spread | Minimises GC pressure. Hot Paths |
| H3 | Use index-based for loops and pre-allocated structures. | for (let i = 0; i < arr.length; i++), flat arrays indexed by row * cols + col | Avoids iterator and array allocation. |
| H4 | Use change detection when a binding changes rarely but triggers expensive work. | Watch a getGamePhase() binding; rebuild overlay only on change | Poll every frame, rebuild only on change. Change Detection |