Skip to content

Common Mistakes ​

A quick-reference table of mistakes commonly made in MVT codebases, with symptoms, causes, and fixes.

Related: Time Management · Hot Paths · Why Performance Matters · Bindings in Depth


Assumes familiarity with Models and Views.

Mistake Reference ​

#MistakeSymptomFix
1Using setTimeout in a modelNon-deterministic behaviour, tests are flakyUse paused GSAP timeline or manual timer
2Caching a binding at constructionView shows stale data after model changesRe-read bindings in refresh()
3Pixel coordinates in a modelModel tied to screen resolutionUse domain units
4Avoidable work every frameStutter, high CPU usage, frames that slow down as the scene growsFollow the hot path rules, skip inactive subtrees, reuse containers
5Forgetting to advance the timelineGSAP tweens never play, model state stallsCall timeline.time() in update()
6Zero-duration GSAP tweensset() callbacks skipped silentlyFloor distance to avoid zero duration
7Auto-playing a GSAP timelineModel advances on wall-clock timeCreate timeline with paused: true
8Domain logic in a viewUntestable logic, broken layer separationMove logic to the model
9View holding domain stateState lost on view recreation, untestableMove state to the model

Using setTimeout in a model ​

Symptom: Behaviour depends on real time, not model time. Tests that run fast may pass, but tests on slow machines fail. Pausing the ticker does not pause the model.

Cause: setTimeout and setInterval fire on wall-clock time, outside the ticker's control.

Fix: Use a paused GSAP timeline advanced in update(), or track elapsed time manually:

ts
// Instead of setTimeout(() => explode(), 500):
let explosionTimer = 500;

update(deltaMs) {
    if (explosionTimer > 0) {
        explosionTimer -= deltaMs;
        if (explosionTimer <= 0) {
            explode();
        }
    }
}

See Time Management.

Caching a binding at construction ​

Symptom: The view displays the initial value correctly but never updates when the model changes (e.g. score stays at 0).

Cause: The binding's return value is captured once at construction and never re-read.

Fix: Always read bindings inside refresh():

ts
// Wrong
const rows = bindings.rows(); // frozen

// Correct
function refresh(): void {
    const rows = bindings.rows(); // fresh each frame
}

See Bindings in Depth.

Pixel coordinates in a model ​

Symptom: Model tests break when screen resolution changes. Model is tied to a specific rendering setup.

Cause: Position, size, or velocity is expressed in pixels rather than domain units.

Fix: Use domain-appropriate units (tiles, world-units, grid indices). Let the view convert to pixels:

ts
// Model: domain units
readonly x: number;  // world-units

// View: convert to pixels
container.position.x = bindings.x() * SCALE;

See Models (Learn).

Avoidable work every frame ​

Symptom: Occasional stutter, from garbage collection pauses. High CPU usage. Frames that get slower as the scene grows, even when little is changing.

Cause: Code that runs every frame doing work it does not need to:

  • Allocating per game object: building strings, calling Object.values(), or using array methods such as .map() and .filter() for every enemy, bullet or particle creates garbage every frame. With a thousand game objects that can be tens of kilobytes per frame, which the engine must pause to collect.
  • Repeating work that hasn't changed: update() and refresh() run every frame, so do as little in them as the frame needs. For example, skip hidden or inactive parts of the scene by returning SKIP_DESCENDANTS, recompute a derived value only when its inputs change, and use change detection to skip expensive updates when nothing has changed.
  • Rebuilding short-lived items: building and destroying a container for each bullet or particle can be several times slower than reusing containers from a pool.

Fix: Follow the hot path rules:

ts
// Wrong - allocates every frame
const positions = enemies.map(e => e.position);

// Correct - no allocation
for (let i = 0; i < enemies.length; i++) {
    views[i].position.set(enemies[i].x, enemies[i].y);
}

Before rewriting anything, check what it actually costs: see Performance Measurements. Most games never get near the limits (Why Performance Matters).

Forgetting to advance the timeline ​

Symptom: GSAP tweens are appended but never play. Model state stays at initial values despite update() being called.

Cause: The timeline is created with paused: true (correct), but timeline.time() is never called in update().

Fix:

ts
update(deltaMs) {
    timeline.time(timeline.time() + deltaMs * 0.001);
    // ... orchestration ...
}

See Time Management.

Zero-duration GSAP tweens ​

Symptom: A set() call after a tween is silently skipped. State transitions that should happen at the end of a movement never fire.

Cause: When duration = distance / speed and distance is zero, the tween has zero duration. GSAP treats it as "already passed" on a paused timeline.

Fix: Floor the distance to ensure positive duration:

ts
const dist = Math.abs(targetCol - state.x) + Math.abs(targetRow - state.y) || 0.001;

See Time Management.

Auto-playing a GSAP timeline ​

Symptom: Model state advances on real time regardless of the ticker. Pausing the game does not pause animations. Tests are non-deterministic.

Cause: The timeline is created without paused: true, so GSAP's global ticker drives it.

Fix:

ts
// Wrong
const tl = gsap.timeline();

// Correct
const tl = gsap.timeline({ paused: true, autoRemoveChildren: true });

See Time Management.

Domain logic in a view ​

Symptom: Game behaviour depends on the view existing. Removing or replacing the view changes how the game plays.

Cause: The view contains collision checks, scoring logic, or state transitions that belong in the model.

Fix: Move all domain logic to the model. The view should only read state and update the presentation.

See Views (Learn).

View holding domain state ​

Symptom: State is lost when a view is destroyed and recreated (e.g. on screen resize). Tests need a full rendering setup to verify behaviour.

Cause: Application state is stored in view closures rather than in the model.

Fix: Move the state to the model. Views should be replaceable without losing any information the application depends on.

See Presentation State for the narrow exception where views may hold cosmetic animation state.