Skip to content

Worked Examples

Each example implements the same feature using all three approaches - events, signals, and watchers - so you can compare them directly. Examples use classic arcade games and common interactive patterns that are universally understood.

Navigation: Overview · Push vs Pull · Events · Signals · Watchers · Comparison · Examples


Example 1: Score Display (Pac-Man)

Scenario: Pac-Man eats dots and moves every tick. The score changes infrequently (on dot-eat). The elapsed time changes every tick. The HUD view must update both - but the optimal strategy differs for each.

This tests the fundamental pattern: how does each approach handle a mix of infrequently-changing and per-tick state?

Events

typescript
// --- Model ---
interface ScoreEvents {
    'score-changed': number;
}

function createScoreModel(emitter: TypedEmitter<ScoreEvents>) {
    let score = 0;
    let elapsed = 0; // changes every tick

    return {
        get score() {
            return score;
        },
        get elapsed() {
            return elapsed;
        },
        addPoints(points: number) {
            score += points;
            emitter.emit('score-changed', score);
        },
        update(dt: number) {
            elapsed += dt;
            // No event for elapsed - it changes every tick.
            // Emitting 60 times/sec would cost ~3–12μs/sec in dispatch
            // overhead and create payload GC pressure for no benefit.
        },
    };
}

// --- View ---
function createScoreView(model: { score: number; elapsed: number }, emitter: TypedEmitter<ScoreEvents>): Container {
    const view = new Container();
    const scoreText = new Text({ text: '0', style: { fill: 'white' } });
    const timerText = new Text({ text: '0', style: { fill: 'grey' } });
    timerText.y = 30;
    view.addChild(scoreText, timerText);

    // Initial sync - events don't carry history
    scoreText.text = String(model.score);

    // Subscribe to infrequent change
    const onScoreChanged = (score: number) => {
        scoreText.text = String(score);
    };
    emitter.on('score-changed', onScoreChanged);

    view.onRender = () => {
        // Frequently-changing value - read directly every tick
        timerText.text = String(Math.floor(model.elapsed));
    };

    view.on('destroyed', () => {
        emitter.off('score-changed', onScoreChanged); // Must clean up
    });

    return view;
}

Observations:

  • Model must know about the emitter and explicitly emit on every mutation.
  • The model intentionally does NOT emit for per-tick values like elapsed - events are a poor fit for high-frequency state.
  • View must manually initialise (read model.score after subscribing).
  • View must clean up the subscription on destroy.
  • The view still needs an onRender loop for per-tick values - events alone are insufficient.

Signals

typescript
import { createSignal, createEffect, createRoot } from 'solid-js';

// --- Model ---

interface ScoreModel {
    readonly score: number;
    readonly elapsed: number;
    addPoints(points: number): void;
    update(dt: number): void;
}

function createScoreModel(): ScoreModel {
    const [score, setScore] = createSignal(0);
    const [elapsed, setElapsed] = createSignal(0);

    return {
        get score() {
            return score();
        },
        get elapsed() {
            return elapsed();
        },
        addPoints(points: number) {
            setScore((prev) => prev + points);
        },
        update(dt: number) {
            setElapsed((prev) => prev + dt);
            // This writes to a signal 60 times/sec, triggering dependency
            // tracking overhead and GC pressure on every tick - even though
            // the consumer would read it every tick anyway.
        },
    };
}

// --- View ---
function createScoreView(model: ScoreModel): Container {
    const view = new Container();
    const scoreText = new Text({ text: '0', style: { fill: 'white' } });
    const timerText = new Text({ text: '0', style: { fill: 'grey' } });
    timerText.y = 30;
    view.addChild(scoreText, timerText);

    const dispose = createRoot((dispose) => {
        createEffect(() => {
            scoreText.text = String(model.score); // auto-tracks score signal
        });
        createEffect(() => {
            timerText.text = String(Math.floor(model.elapsed));
            // This effect re-runs every tick because elapsed() changes every tick.
            // The dependency tracking overhead is pure waste here.
        });
        return dispose;
    });

    view.on('destroyed', dispose); // Must clean up reactive root

    return view;
}

Observations:

  • No manual initial sync - the effect runs once on creation.
  • No explicit dependency declaration - auto-tracked.
  • Must manage reactive root lifecycle (disposed on view destroy).
  • Per-tick values like elapsed work but incur signal overhead with no benefit.
  • Requires SolidJS runtime.

Watchers

typescript
// --- Model (plain object) ---
function createScoreModel() {
    let score = 0;
    let elapsed = 0;

    return {
        get score() {
            return score;
        },
        get elapsed() {
            return elapsed;
        },
        addPoints(points: number) {
            score += points;
        },
        update(dt: number) {
            elapsed += dt;
        },
    };
}

// --- View ---
function createScoreView(model: { readonly score: number; readonly elapsed: number }): Container {
    const view = new Container();
    const scoreText = new Text({ style: { fill: 'white' } });
    const timerText = new Text({ style: { fill: 'grey' } });
    timerText.y = 30;
    view.addChild(scoreText, timerText);

    // Watch infrequently-changing state only
    const watcher = watch({
        score: () => model.score,
    });

    view.onRender = () => {
        const watched = watcher.poll();
        if (watched.score.changed) {
            scoreText.text = String(watched.score.value);
        }
        // Per-tick value - just read directly, no watcher needed
        timerText.text = String(Math.floor(model.elapsed));
    };

    return view;
}

Observations:

  • Model is a plain object - no emitters, no signal wrappers.
  • No cleanup obligations - removing the view from the stage is sufficient.
  • Infrequent changes (score) use a watcher; per-tick values (elapsed) are read directly. Both patterns coexist naturally.
  • No framework dependency.

Example 2: Ghost State Transitions (Pac-Man)

Scenario: Pac-Man's ghosts have multiple phases: 'chase', 'scatter', 'frightened', 'eaten'. When the phase changes, the ghost's appearance (sprite texture, animation speed, colour) must update. Additionally, a sound effect plays on certain transitions. The ghost's position changes every tick.

This tests transition detection - reacting not just to the new value, but to specific from→to transitions - alongside per-tick state.

Events

typescript
// --- Model ---
interface GhostEvents {
    'phase-changed': { from: GhostPhase; to: GhostPhase };
}
type GhostPhase = 'chase' | 'scatter' | 'frightened' | 'eaten';

function createGhostModel(emitter: TypedEmitter<GhostEvents>) {
    let phase: GhostPhase = 'scatter';
    let col = 0;
    let row = 0;

    return {
        get phase() {
            return phase;
        },
        get col() {
            return col;
        },
        get row() {
            return row;
        },
        setPhase(newPhase: GhostPhase) {
            if (newPhase === phase) return;
            const from = phase;
            phase = newPhase;
            emitter.emit('phase-changed', { from, to: newPhase });
        },
        update(dt: number) {
            // move ghost - no event (per-tick)
            col += dt * 0.01;
        },
    };
}

// --- View ---
function createGhostView(
    model: { phase: GhostPhase; col: number; row: number },
    emitter: TypedEmitter<GhostEvents>,
): Container {
    const view = new Container();
    const sprite = new Sprite();
    view.addChild(sprite);

    // Initial appearance
    applyPhaseAppearance(sprite, model.phase);

    const onPhaseChanged = ({ from, to }: { from: GhostPhase; to: GhostPhase }) => {
        applyPhaseAppearance(sprite, to);

        // Transition-specific logic
        if (to === 'frightened') {
            audioManager.play('power-pellet');
        }
        if (from === 'frightened' && to === 'chase') {
            audioManager.play('ghost-recover');
        }
    };

    emitter.on('phase-changed', onPhaseChanged);

    view.onRender = () => {
        // Per-tick position - direct read
        sprite.x = model.col * TILE_SIZE;
        sprite.y = model.row * TILE_SIZE;
    };

    view.on('destroyed', () => {
        emitter.off('phase-changed', onPhaseChanged);
    });

    return view;
}

Observations:

  • Events naturally carry from/to - the model packages transition data.
  • Transition-specific logic is clean and readable.
  • Per-tick position requires an onRender loop - events don't help.
  • Initial sync and cleanup are handled via the view lifecycle.

Signals

typescript
import { createSignal, createEffect, createRoot } from 'solid-js';

// --- Model ---

type GhostPhase = 'chase' | 'scatter' | 'frightened' | 'eaten';

interface GhostModel {
    readonly phase: GhostPhase;
    readonly col: number;
    readonly row: number;
    setPhase(p: GhostPhase): void;
    update(dt: number): void;
}

function createGhostModel(): GhostModel {
    const [phase, setPhase] = createSignal<GhostPhase>('scatter');
    const [col, setCol] = createSignal(0);
    const [row, setRow] = createSignal(0);

    return {
        get phase() {
            return phase();
        },
        get col() {
            return col();
        },
        get row() {
            return row();
        },
        setPhase(p: GhostPhase) {
            setPhase(p);
        },
        update(dt: number) {
            setCol((prev) => prev + dt * 0.01);
            // Writing to col 60 times/sec - signal overhead with no benefit
        },
    };
}

// --- View ---
function createGhostView(model: GhostModel): Container {
    const view = new Container();
    const sprite = new Sprite();
    view.addChild(sprite);

    const dispose = createRoot((dispose) => {
        // Phase changes - with previous value tracking
        let prevPhase: GhostPhase | undefined;
        createEffect(() => {
            const to = model.phase;
            const from = prevPhase;
            prevPhase = to;
            applyPhaseAppearance(sprite, to);

            if (to === 'frightened') {
                audioManager.play('power-pellet');
            }
            if (from === 'frightened' && to === 'chase') {
                audioManager.play('ghost-recover');
            }
        });

        // Per-tick position - effect re-runs every tick due to signal writes
        createEffect(() => {
            sprite.x = model.col * TILE_SIZE;
            sprite.y = model.row * TILE_SIZE;
        });

        return dispose;
    });

    view.on('destroyed', dispose);

    return view;
}

Observations:

  • With a property-based interface, the view must track previous state manually. SolidJS's on() helper could provide previous values, but requires exposing raw signal accessors in the interface (breaking consistency with events/watchers).
  • Per-tick position values are signals, so the position effect re-runs every tick with full dependency-tracking overhead.
  • Must dispose reactive root (hooked to view destroy).

Watchers

typescript
// --- Model (plain object) ---
type GhostPhase = 'chase' | 'scatter' | 'frightened' | 'eaten';

function createGhostModel() {
    let phase: GhostPhase = 'scatter';
    let col = 0;
    let row = 0;

    return {
        get phase() {
            return phase;
        },
        get col() {
            return col;
        },
        get row() {
            return row;
        },
        setPhase(p: GhostPhase) {
            phase = p;
        },
        update(dt: number) {
            col += dt * 0.01;
        },
    };
}

// --- View ---
function createGhostView(model: { readonly phase: GhostPhase; readonly col: number; readonly row: number }): Container {
    const view = new Container();
    const sprite = new Sprite();
    view.addChild(sprite);

    const watcher = watch({
        phase: () => model.phase,
    });

    view.onRender = () => {
        const watched = watcher.poll();

        if (watched.phase.changed) {
            const from = watched.phase.previous;
            const to = watched.phase.value;
            applyPhaseAppearance(sprite, to);

            // Transition-specific logic - previous is built in
            if (to === 'frightened') {
                audioManager.play('power-pellet');
            }
            if (from === 'frightened' && to === 'chase') {
                audioManager.play('ghost-recover');
            }
        }

        // Per-tick position - direct read, no watcher
        sprite.x = model.col * TILE_SIZE;
        sprite.y = model.row * TILE_SIZE;
    };

    return view;
}

Observations:

  • watched.phase.previous provides transition data without manual tracking.
  • Per-tick position is a direct read - no watcher overhead.
  • Model is plain - no emitter, no signals.
  • No cleanup - removing view from stage is sufficient.

Example 3: GSAP Tween Integration

Scenario: A Breakout-style game. When the ball hits a brick, the brick plays a "shatter" animation using GSAP (scale up, fade out, remove). The game model marks the brick as destroyed; the view must animate the destruction. Assume GSAP's ticker has been replaced with Pixi's shared ticker (RAF-based), so both run in the same frame loop.

This tests integration with an external animation library - a common real-world pattern that reveals how each approach handles externally-driven state.

Events

typescript
// --- Model ---
interface BrickEvents {
    'brick-destroyed': { col: number; row: number };
}

function createBrickGridModel(emitter: TypedEmitter<BrickEvents>) {
    const bricks: boolean[][] = [
        /* initial grid */
    ];
    return {
        isBrick(col: number, row: number) {
            return bricks[row][col];
        },
        destroyBrick(col: number, row: number) {
            bricks[row][col] = false;
            emitter.emit('brick-destroyed', { col, row });
        },
    };
}

// --- View ---
function createBrickGridView(emitter: TypedEmitter<BrickEvents>, brickSprites: Map<string, Sprite>): Container {
    const view = new Container();
    // ... populate initial sprites into view ...

    const onBrickDestroyed = ({ col, row }: { col: number; row: number }) => {
        const key = `${col},${row}`;
        const sprite = brickSprites.get(key);
        if (!sprite) return;

        // Animate destruction with GSAP
        gsap.to(sprite, {
            alpha: 0,
            scaleX: 1.5,
            scaleY: 1.5,
            duration: 0.3,
            ease: 'power2.out',
            onComplete: () => {
                view.removeChild(sprite);
                brickSprites.delete(key);
            },
        });
    };

    emitter.on('brick-destroyed', onBrickDestroyed);

    view.on('destroyed', () => {
        emitter.off('brick-destroyed', onBrickDestroyed);
    });

    return view;
}

Observations:

  • Events are a natural fit here: "brick destroyed" is a discrete, one-off occurrence - exactly the kind of thing events model well.
  • GSAP runs on Pixi's shared ticker; no need to bridge tween values into a reactive system.
  • The animation fires directly from the event handler - simple and direct.

Signals

typescript
import { createSignal, createEffect, createRoot, batch } from 'solid-js';

// --- Model ---

interface BrickGridModel {
    isBrick(col: number, row: number): boolean;
    readonly lastDestroyed: { col: number; row: number } | null;
    readonly destroyVersion: number;
    destroyBrick(col: number, row: number): void;
}

function createBrickGridModel(): BrickGridModel {
    const bricks: boolean[][] = [
        /* initial grid */
    ];
    const [lastDestroyed, setLastDestroyed] = createSignal<{ col: number; row: number } | null>(null);
    const [destroyVersion, setDestroyVersion] = createSignal(0);

    return {
        isBrick(col: number, row: number) {
            return bricks[row][col];
        },
        get lastDestroyed() {
            return lastDestroyed();
        },
        get destroyVersion() {
            return destroyVersion();
        },
        destroyBrick(col: number, row: number) {
            bricks[row][col] = false;
            batch(() => {
                setLastDestroyed({ col, row });
                setDestroyVersion((v) => v + 1);
            });
        },
    };
}

// --- View ---
function createBrickGridView(model: BrickGridModel, brickSprites: Map<string, Sprite>): Container {
    const view = new Container();
    // ... populate initial sprites into view ...

    const dispose = createRoot((dispose) => {
        createEffect(() => {
            model.destroyVersion; // track the version signal via getter
            const brick = model.lastDestroyed;
            if (!brick) return;

            const key = `${brick.col},${brick.row}`;
            const sprite = brickSprites.get(key);
            if (!sprite) return;

            gsap.to(sprite, {
                alpha: 0,
                scaleX: 1.5,
                scaleY: 1.5,
                duration: 0.3,
                ease: 'power2.out',
                onComplete: () => {
                    view.removeChild(sprite);
                    brickSprites.delete(key);
                },
            });
        });
        return dispose;
    });

    view.on('destroyed', dispose);

    return view;
}

Observations:

  • Signals are a poor fit for discrete events. There is no "fire and forget" - you must change a value and have the effect detect the change. The version counter workaround recreates event semantics inside the signal model.
  • An alternative is to use SolidJS stores with array mutations, but the core issue remains: signals model state, not events.
  • For truly discrete events, an event emitter is more natural.

Watchers

typescript
// --- Model ---
function createBrickGridModel() {
    const bricks: boolean[][] = [
        /* initial grid */
    ];
    let lastDestroyed: { col: number; row: number } | undefined;
    let destroyVersion = 0;

    return {
        isBrick(col: number, row: number) {
            return bricks[row][col];
        },
        get destroyVersion() {
            return destroyVersion;
        },
        get lastDestroyed() {
            return lastDestroyed;
        },
        destroyBrick(col: number, row: number) {
            bricks[row][col] = false;
            lastDestroyed = { col, row };
            destroyVersion++;
        },
    };
}

// --- View ---
function createBrickGridView(model: ReturnType<typeof createBrickGridModel>): Container {
    const view = new Container();
    const brickSprites = new Map<string, Sprite>();
    // ... populate initial sprites ...

    const watcher = watch({
        destroyVersion: () => model.destroyVersion,
    });

    view.onRender = () => {
        const watched = watcher.poll();
        if (watched.destroyVersion.changed) {
            const { col, row } = model.lastDestroyed!;
            const key = `${col},${row}`;
            const sprite = brickSprites.get(key);
            if (!sprite) return;

            gsap.to(sprite, {
                alpha: 0,
                scaleX: 1.5,
                scaleY: 1.5,
                duration: 0.3,
                ease: 'power2.out',
                onComplete: () => {
                    view.removeChild(sprite);
                    brickSprites.delete(key);
                },
            });
        }
    };

    return view;
}

Observations:

  • Version stamp pattern works similarly to signals' version counter - both are working around the fact that poll/signal systems model state, not events.
  • The watcher approach is slightly more direct: poll, check if changed, act.
  • No cleanup needed. No framework dependency.
  • For truly discrete events, an event emitter remains the most natural choice across all three approaches.

Example 4: Asteroid Field (Asteroids)

Scenario: An Asteroids-style game. A dynamic collection of asteroids, each with a position updated every tick, each needing a corresponding sprite. Asteroids are created (when a large asteroid splits) and destroyed (when hit by a bullet) frequently.

This tests dynamic collections and per-frame updates - a combination that stresses lifecycle management and per-tick cost. It includes both frequently-changing state (positions) and infrequently-changing state (collection membership).

Events

typescript
// --- Model ---
interface AsteroidFieldEvents {
    'asteroid-added': Asteroid;
    'asteroid-removed': string; // asteroid ID
}

function createAsteroidField(emitter: TypedEmitter<AsteroidFieldEvents>) {
    const asteroids = new Map<string, Asteroid>();
    return {
        add(a: Asteroid) {
            asteroids.set(a.id, a);
            emitter.emit('asteroid-added', a);
        },
        remove(id: string) {
            asteroids.delete(id);
            emitter.emit('asteroid-removed', id);
        },
        update(dt: number) {
            for (const a of asteroids.values()) {
                a.x += a.vx * dt;
                a.y += a.vy * dt;
                // No event for position - changes every tick
            }
        },
        getAll() {
            return asteroids;
        },
    };
}

// --- View ---
function createAsteroidFieldView(
    field: ReturnType<typeof createAsteroidField>,
    emitter: TypedEmitter<AsteroidFieldEvents>,
): Container {
    const view = new Container();
    const sprites = new Map<string, Sprite>();

    const onAdded = (a: Asteroid) => {
        const s = new Sprite(asteroidTexture);
        sprites.set(a.id, s);
        view.addChild(s);
    };
    const onRemoved = (id: string) => {
        const s = sprites.get(id);
        if (s) {
            view.removeChild(s);
            sprites.delete(id);
        }
    };

    emitter.on('asteroid-added', onAdded);
    emitter.on('asteroid-removed', onRemoved);

    // Initial sync for asteroids that already exist
    for (const a of field.getAll().values()) onAdded(a);

    view.onRender = () => {
        // Per-frame position sync - events don't help here,
        // so we fall back to direct reads
        for (const [id, a] of field.getAll()) {
            const s = sprites.get(id);
            if (s) {
                s.x = a.x;
                s.y = a.y;
            }
        }
    };

    view.on('destroyed', () => {
        emitter.off('asteroid-added', onAdded);
        emitter.off('asteroid-removed', onRemoved);
    });

    return view;
}

Observations:

  • Events handle add/remove well - discrete occurrences.
  • Per-frame position updates still require direct reads in onRender - events can't/shouldn't fire 60 times per second per asteroid.
  • Cleanup requires two off() calls (hooked to view destroy).
  • Initial sync loop is needed for pre-existing asteroids.

Signals

typescript
import { createSignal, createEffect, createRoot } from 'solid-js';
import { createStore, produce } from 'solid-js/store';

// --- Model ---

interface AsteroidFieldModel {
    readonly asteroids: Asteroid[];
    add(a: Asteroid): void;
    remove(id: string): void;
    update(dt: number): void;
}

function createAsteroidFieldModel(): AsteroidFieldModel {
    const [asteroids, setAsteroids] = createStore<Asteroid[]>([]);

    return {
        get asteroids() {
            return asteroids;
        },
        add(a: Asteroid) {
            setAsteroids(produce((list) => list.push(a)));
        },
        remove(id: string) {
            setAsteroids((list) => list.filter((a) => a.id !== id));
        },
        update(dt: number) {
            setAsteroids(
                produce((list) => {
                    for (const a of list) {
                        a.x += a.vx * dt;
                        a.y += a.vy * dt;
                    }
                }),
            );
            // Triggers store reactivity every tick - all position effects re-run
        },
    };
}

// --- View ---
function createAsteroidFieldView(model: AsteroidFieldModel): Container {
    const view = new Container();
    const sprites = new Map<string, Sprite>();

    const dispose = createRoot((dispose) => {
        createEffect(() => {
            const currentIds = new Set<string>();
            for (const a of model.asteroids) {
                currentIds.add(a.id);
                let s = sprites.get(a.id);
                if (!s) {
                    s = new Sprite(asteroidTexture);
                    sprites.set(a.id, s);
                    view.addChild(s);
                }
                s.x = a.x;
                s.y = a.y;
            }
            // Remove sprites for asteroids that no longer exist
            for (const [id, s] of sprites) {
                if (!currentIds.has(id)) {
                    view.removeChild(s);
                    sprites.delete(id);
                }
            }
        });
        return dispose;
    });

    view.on('destroyed', dispose);

    return view;
}

Observations:

  • update() triggers the store's reactivity on every tick (positions change every frame). The effect re-runs every frame, iterating the full asteroid array - same O(n) cost as the other approaches, plus signal overhead.
  • produce() (Immer-like) avoids creating new arrays but adds its own overhead.
  • The filter call in remove() creates a new array, triggering a full re-run.
  • SolidJS excels at list diffing inside JSX with <For>, but outside a component context (Canvas/WebGL rendering), you are doing the diffing manually - undermining the automatic-reactivity benefit.

Watchers

typescript
// --- Model (plain) ---
function createAsteroidField() {
    const asteroids: Asteroid[] = [];
    let version = 0; // bumped on add/remove, NOT on position updates

    return {
        get version() {
            return version;
        },
        get asteroids() {
            return asteroids;
        },
        add(a: Asteroid) {
            asteroids.push(a);
            version++;
        },
        remove(id: string) {
            const idx = asteroids.findIndex((a) => a.id === id);
            if (idx >= 0) {
                asteroids.splice(idx, 1);
                version++;
            }
        },
        update(dt: number) {
            for (let i = 0; i < asteroids.length; i++) {
                asteroids[i].x += asteroids[i].vx * dt;
                asteroids[i].y += asteroids[i].vy * dt;
            }
            // No version bump - positions change every tick, no point watching
        },
    };
}

// --- View ---
function createAsteroidFieldView(field: ReturnType<typeof createAsteroidField>): Container {
    const view = new Container();
    const sprites = new Map<string, Sprite>();

    const watcher = watch({
        version: () => field.version,
    });

    view.onRender = () => {
        const watched = watcher.poll();

        // Rebuild sprite map only when asteroids are added/removed (infrequent)
        if (watched.version.changed) {
            const currentIds = new Set<string>();
            for (let i = 0; i < field.asteroids.length; i++) {
                const a = field.asteroids[i];
                currentIds.add(a.id);
                if (!sprites.has(a.id)) {
                    const s = new Sprite(asteroidTexture);
                    sprites.set(a.id, s);
                    view.addChild(s);
                }
            }
            for (const [id, s] of sprites) {
                if (!currentIds.has(id)) {
                    view.removeChild(s);
                    sprites.delete(id);
                }
            }
        }

        // Per-frame position sync - direct read, no watcher
        for (let i = 0; i < field.asteroids.length; i++) {
            const a = field.asteroids[i];
            const s = sprites.get(a.id);
            if (s) {
                s.x = a.x;
                s.y = a.y;
            }
        }
    };

    return view;
}

Observations:

  • Version stamp watches collection membership changes (add/remove), which are infrequent. Sprite creation/destruction only happens when the collection changes - same ID-based diffing as the signals approach, but triggered explicitly by the version stamp.
  • Per-frame position sync is a direct indexed loop with no watcher overhead. The watcher system is not used for values that change every tick - direct reads are simpler and cheaper.
  • No cleanup needed. No framework dependency.

Summary

ConcernEventsSignalsWatchers
Collection add/removeClean (discrete events)Verbose (store + produce)Version stamp
Per-frame position syncDirect read (events don't help)Effect re-runs (overhead)Direct read
Sprite lifecycleEvent handlersManual diffingVersion-triggered diffing
Cleanupoff() on destroydispose() on destroyNone
Initial syncManual loopAutomatic (effect)Automatic (first poll)
Per-tick state handlingSeparate onRender loop60 signal writes/secDirect read

This example illustrates a key insight: per-frame continuous values are best handled by direct reads, not any reactive mechanism. Watchers shine for infrequent state changes (collection membership, phase transitions); direct reads are optimal for per-frame values (positions, velocities). Events work well for discrete occurrences (add/remove) but offer nothing for per-frame updates. Signals handle both, but for per-frame values they add dependency-tracking overhead with no benefit.


Quick-Reference: Which Approach for Which Pattern?

PatternEventsSignalsWatchers
Scalar state → view sync⚠️ Manual init sync✅ Automatic✅ Automatic (with poll)
State transitions (from → to)✅ Payload includes both⚠️ Manual previous trackingprevious built into watcher
Discrete one-off events✅ Natural fit⚠️ Version counter workaround⚠️ Version stamp workaround
Per-frame continuous values❌ 60 emits/sec wasteful⚠️ 60 signal writes/sec✅ Direct read (no watcher needed)
Dynamic collections✅ Add/remove events⚠️ Store-based, verbose✅ Version stamp
External tween integration✅ Tween → event on complete⚠️ Bridge values to signals✅ Read tween target directly
Derived / computed values❌ Manual in every handlercreateMemo⚠️ Model-layer derivation / getter
Cross-cutting (audio, analytics)✅ Loose coupling⚠️ Signal access = coupling⚠️ Readable via bindings

Back to: Overview · Comparison