Skip to content

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

#RuleCommon ExamplesRationale
M1Models own all domain state and logic.Position, health, score, phase transitions, collision rulesSingle source of truth. Views and tickers never hold domain state.
M2All 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
M3Models must not reference views or the ticker.No imports from view modules; no scene-graph objects in model stateSeparation of concerns. Models are independently testable.
M4Model 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 coloursPresentation independence. Models work unchanged regardless of screen size or rendering technology. Models
M5Parent models delegate update(deltaMs) to child models. Cross-model concerns live in the parent.Root game model calls ship.update(dt) then checks collisionsHierarchical composition. Each child is independently testable. Model Composition

View Rules

#RuleCommon ExamplesRationale
V1Views are stateless. They read state and write to the presentation output.No domain logic, no autonomous animations, no internal domain stateViews are pure projections that can be replaced or multiplied without affecting behaviour. Views
V2refresh() runs once per frame, after all models have updated.Read bindings, set sprite positions and text labelsFrame-consistent snapshots. No view sees a half-updated world.
V3refresh() must be idempotent.Calling it twice with the same model state produces the same resultPredictability. No hidden side effects accumulate across frames.
V4refresh() 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.
V5All binding values must be re-read in refresh(), never cached at construction time.Call bindings.getScore() inside refresh(), not in the factory closureBindings are reactive. Values may change between frames. Bindings in Depth
V6Presentation state is allowed only when purely cosmetic and the model does not need to know about it.Animated score counter, fade-in on phase changeThe exception, not the rule. Presentation State
V7When 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 tweenKeeps presentation animations deterministic, frame-rate-independent, and testable. Presentation State
V8MVT views can target any output technology.Canvas scene graph, DOM, audio system, debug panelMVT is not coupled to a particular renderer.
V9View trees do not need to mirror model trees.Multiple views from one model; models with no view; decorative views with no modelDomain structure and presentation needs are different concerns. Bindings decouple the two trees. View Composition

Ticker Rules

#RuleCommon ExamplesRationale
T1Each frame follows a strict sequence: update, refresh, render. Never interleave or skip steps.model.update(deltaMs) then view.refresh() then renderer drawsModels settle first, then views read a stable snapshot. The Ticker
T2Cap deltaMs to a safe maximum.e.g. 100ms cap to handle backgrounded tabsPrevents huge time leaps that could break non-leap-safe models.
T3The ticker contains no domain logic and no rendering code.No collision checks, no sprite creationSeparation of concerns. It is purely a timing orchestrator.
T4The ticker may pause, slow down, speed up, or single-step time.Pause overlay, slow-mo debug, frame-by-frame steppingModels stay in sync because they only see deltaMs.

Bindings Rules

#RuleCommon ExamplesRationale
B1Bindings are the contract between a view and the world. get*() reads state; on*() relays user input.getScore(): number, onDirectionChanged(dir): voidExplicit dependencies. The bindings type is a complete manifest of everything the view requires or provides. Bindings
B2Reusable leaf views use a bindings interface. Top-level application views may access models directly.HUD panel takes bindings; game-view takes the game modelLeaf views stay decoupled and reusable. Top-level views are application-specific. Bindings in Depth
B3on*() bindings should usually be optional.A gamepad view with optional onFireChanged?()Keeps views usable in more contexts without forcing no-op handlers.
B4Bindings are wired at the view construction site.Parent view maps getScore: () => game.score.scoreDecoupling. The view does not know how it is connected to the outside.

Hot-Path Rules

#RuleCommon ExamplesRationale
H1Minimise 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 scanningSaves CPU budget for the frame. Hot Paths
H2Avoid per-tick heap allocations in update() and refresh().array.map(), template-string keys, for...of on arrays, inline closures, spreadMinimises GC pressure. Hot Paths
H3Use index-based for loops and pre-allocated structures.for (let i = 0; i < arr.length; i++), flat arrays indexed by row * cols + colAvoids iterator and array allocation.
H4Use change detection when a binding changes rarely but triggers expensive work.Watch a getGamePhase() binding; rebuild overlay only on changePoll every frame, rebuild only on change. Change Detection