Skip to content

Managers & Systems — the engine's logic roles

Status: implemented. (The original phased rollout has fully landed; see git history for the retired managers-rollout-plan.md tracker.)

Historical note: the scene-selector game used below as an example was the pre-#29 multi-game hub; it was removed in the one-project-per-game teardown. Its navigateBack/card-spawning are kept here only to illustrate the Manager roles — the Manager/System primitives themselves are unchanged.

Why this exists

Game logic was leaking into per-game setup.ts (inline registerUIAction closures) and init.ts (free functions like chess's handleAITurn, scene-selector's navigateBack). The root cause is an asymmetry, not a discipline problem:

  • A System is first-class. registerSystem(name, fn, priority, { actions }) gives per-frame logic a named home with an ordered tick, owned UIActions, and automatic cleanup. Nobody scatters per-frame logic, because it has an obvious place to go.
  • A Manager had no equivalent. There was no registerManager. So event-driven logic (AI turns, navigation, "new game", card spawning) had nowhere to live and leaked into init.ts/setup.ts as loose functions.

The fix is to make Manager a first-class registerable unit, symmetric to System. Then there is exactly one home per role, and setup.ts becomes a pure manifest while init.ts disappears.

The five roles

Don't force everything into two buckets. The codebase genuinely has five, and naming them is what keeps logic from sprawling.

RoleTicks?Driven byHoldsExamples
Systemyesthe frame clockper-frame transform of ECS statetimeSystem, animationSystem, shipShakeSystem, transformPropagationSystem, render sync, chessBoardSystem (canvas-remount poll), gameStatsSystem (ECS→store readback)
Managernoevents (clicks, scene swaps, SDK callbacks)long-lived state + a method surfaceSceneManager, actionRegistry, audio, (new) NavigationManager, TimeManager
Projectionon changestore subscription (dirty flag)mirrors state store→ECS, no logicuiTreeProjection, chessStateProjection / chessChatProjection, llmStateProjection / chatMessageProjection
StorenomutationsZustand state containergameStore, engineStore, debugStore, chessStore, llmStore
Trait / Service / Utilitynon/apure data / SDK wrapper / pure fnUIElement, ads, anchorLayout, render2DUtils

The litmus test

Does the logic produce a different result on frame N+1 than frame N with no input change? (easing, oscillation, physics, time accumulation) Yes → System. No → Manager.

A navigationSystem fails this test — navigation never reacts to time passing — so navigation is a Manager, not a System. Time passes the test for clock advance (System) but fails it for timeSinceGameStart (a derived read → Manager). Many features are both — see Time.

Projections are a System sub-role, not game logic

Seven of the registered "systems" are projections: they tick only to poll for change and mirror a store into ECS (or back). They're correctly Systems (they tick), but six of them hand-roll per-frame change detection. uiTreeProjection already solved this with a dirty flag (markUIDirty). A future registerProjection(name, store, syncFn) helper that subscribes to the store and runs syncFn only on change would turn those six pollers into event-driven syncs (see the rollout plan, Phase 6 — out of scope for the core work).

The Manager primitive

ts
interface ManagerDef {
  name: string;
  scope?: 'app' | 'scene' | 'game';   // default 'scene'
  scenes?: string[];             // scene-scope only: path substrings; omit = every scene
  games?: string[];              // game-scope only: active-game ids; omit = every game
  actions?: Record<string, UIActionHandler | UIActionDef>;  // SAME shape systems use
  init?(ctx: ManagerContext): void | Promise<void>;
  dispose?(ctx?: ManagerContext): void;
}
interface ManagerContext { world: World; scenePath: string; }

registerManager(def)      // mirrors registerSystem
unregisterManager(name)   // drops owned actions, calls dispose()

Scope: three tiers — scene by default, game and app opt-in

Scene-default makes the safe choice the default — a Manager's state can't leak across scenes unless you explicitly ask it to. Two coarser tiers opt in to longer lifetimes, each keyed on a different thing:

scene (default)game (opt-in)app (opt-in)
Keyed onactive scene path (scenes filter)active game id (games filter)nothing — the whole session
init() fireswhen a matching scene loadswhen its game becomes activeonce, at registerManager
dispose() fireson every swap away, before the old world dieswhen the active game changes, not on in-game swapsonly at unregisterManager
Statereset per scene — cannot leakpersists across a game's scenespersists the whole session
Use forper-screen controllers, card spawning, single-scene controllers with an expensive init (e.g. the chess / llm-test LLM download)a controller genuinely spanning a game's scenes (e.g. the space-console camera across Station↔Warp)engine infrastructure (Time, Navigation) and global cross-game actions (return-to-hub)

Why game is keyed on the active game, not on register. The editor registers every game's systems up front, so "activate on register" would light up all games' game-scoped managers at once — which is how an LLM download once fired just from opening an unrelated scene. Keying on activeGameId (set on a real game switch, derived from the scene path otherwise) means only the active game's managers run, in both the editor and production. A single-scene controller with an expensive init therefore belongs in scene, never game.

Who owns the lifecycle: SceneManager

Because scene and game scope are both keyed on what's loaded, SceneManager owns Manager lifecycle — which is also why navigation belongs there (same owner). The hooks slot into the existing swap sequence in SceneManager.loadScene, with no new machinery:

setCurrentWorld(new)
  → if the game changed: dispose old game-scoped managers   (their world is about to die)
  → dispose old scene-scoped managers                       (their world is about to die)
  → releaseAllForScene(old)
  → oldWorld.destroy()
  → if the game changed: init new game-scoped managers that match the game filter
  → init new scene-scoped managers that match the scene filter   (via fireSceneCallbacks)

App-scoped managers are untouched by swaps — they init/dispose only at registerManager/unregisterManager.

Method access: singleton, not service-locator

A Manager is a plain singleton; registerManager only wires its lifecycle + actions. Other code calls its methods by importing it directly — no getManager(name) lookup.

ts
// chess/managers/ChessManager.ts — logic lives HERE
class ChessManager {
  name = 'chess';
  // Scene-scoped (single-scene game): the LLM download in init() is expensive,
  // so it must wait for the chess scene to actually load — NOT fire just because
  // the editor registered every game's systems up front.
  scope = 'scene' as const; scenes = ['chess'];
  actions = {
    'chess.newGame':    () => this.newGame(),
    'chess.boardClick': ({ payload }) => this.boardClick(payload),
  };
  init()   { this.startLLMDownload(); }
  dispose(){ this.cancelLLM(); }
  handleAITurn() { /* … */ }   // called from the move callback
  newGame()      { /* … */ }
}
export const chessManager = new ChessManager();

Write side & read side: the two registries

Managers and Systems expose their surface to UI through two symmetric registries.

  • Write side — actionRegistry (exists today). Named actions UI can call. UIAction bindings kind:'call' dispatch them. Owned by the Manager/System that registers them; gated by isSimRunning().
  • Read side — readSourceRegistry (new). Named values UI can bind. registerReadSource(name, getter) / unregisterReadSource(name). The binding resolver (bindingResolver.ts) resolves {name} against store state first, then registered getters. No per-frame projection — values are read live at resolve time.
ts
// any Manager, in init()
registerReadSource('timeSinceGameStart', () => timeManager.timeSinceGameStart);
registerReadSource('canGoBack',          () => navigationManager.canGoBack);

This is why we avoid copying Manager-derived values into a store via a per-frame projection (option A) — it would re-introduce the exact poller smell we're removing. The read-source registry keeps "values reach UI without a tick" and generalizes: a Back button binds disabled={!canGoBack}; a HUD binds Time: {timeSinceGameStart}; a score manager registers {score}.

Engine-global Managers

Engine built-ins are the one thing outside the per-game manifest — registered once at core startup (alongside registerEngineActions, in app/ecs/register.ts). Every game inherits them.

The actual gap that started this design: navigation logic was scattered across engine.loadScene (engine action), scene-selector/init.ts (its ownnavigateBack/selectGame), and App.tsx hash routing — with no back-stack anywhere.

NavigationManager owns the history stack (the missing piece — back() needs it) and exposes loadScene / back / canGoBack / replace. It backs onto SceneManager (which owns transitions). Built-ins become thin wrappers:

  • actions: engine.loadScene, engine.navigateBack
  • read source: canGoBack

scene-selector stops re-implementing navigateBack — it uses the engine built-in.

Time (System + Manager)

Time is the canonical case of a feature that is both roles, and they compose rather than compete:

  • timeSystem (System, unchanged) — advances one monotonic, pause-aware Time.elapsed every frame. The single source of "now"; every other system depends on it, so it is never reset.
  • TimeManager (Manager, new) — captures event anchors (offsets into elapsed) and exposes derived reads. No tick.
ts
class TimeManager {
  private anchors = new Map<string, number>();
  private now() { return getTime().elapsed; }              // pause-aware clock (System)

  init() {
    onPlayStateChange(s => { if (s === 'playing') this.mark('gameStart'); });
    onWorldSwap(() => this.mark('sceneLoad'));              // re-anchors every scene swap
  }

  // ── generic layer (open-ended; games invent their own) ──
  mark(name: string)      { this.anchors.set(name, this.now()); }
  timeSince(name: string) { return this.now() - (this.anchors.get(name) ?? this.now()); }

  // ── fixed accessors (sugar over the generic layer; never duplicate state) ──
  get deltaTime()          { return getTime().delta; }
  get timeSinceGameStart() { return this.timeSince('gameStart'); }
  get timeSinceSceneLoad() { return this.timeSince('sceneLoad'); }
}

Properties that fall out for free:

  • Anchors are offsets, never resetstimeSinceX = elapsed − anchorX, so adding game-start time can't perturb rotate3D/animation.
  • Pause / editor Play-Stop work without special-casing — derived from the already-pause-aware Time.elapsed; the 'playing' transition re-stamps gameStart.
  • Generic + fixed both shipmark('levelStart')/timeSince('levelStart') gives games arbitrary stopwatches with zero new systems or traits; the fixed accessors let UI bind {timeSinceGameStart} without knowing a magic string.

The general lesson: a System maintains a base quantity each frame; a Manager captures event anchors and exposes the derived API on top of it. Same shape recurs — physics System integrates / Manager raycast(); animation System advances / Manager play(clip).

The manifest model

setup.ts becomes a pure manifest — it declares what a game has, never how it behaves. init.ts is deleted; its logic moves into named Manager singletons.

ts
// space-console/setup.ts — wiring only, zero logic bodies
registerSystems([cameraDistanceSystem, stripeTimeSystem, shipShakeSystem, engineFlameSystem]);
registerManagers([cameraManager]);     // owns setCameraDistance action + debugStore writes
registerShaders([stripes, matcap, planet]);
Today (scattered)After (owned)
chess/init.ts: handleAITurn/newGame/handlePlayerChat + ad-hoc registerUIActionChessManager (scene scope) — methods + actions map
scene-selector/init.ts: private navigateBack/selectGame + spawnGameCardsnav → engine NavigationManager; cards → SceneSelectorManager (scene scope)
space-console/setup.ts: inline setCameraDistance closureCameraManager owns it

The final shape: three tiers, each with one obvious home.

  • System (ticks) → registerSystems
  • Manager (events; scene/game scope) → registerManagers
  • Engine Manager (global) → registered by core once

Decision log

  1. Five roles are fine — don't force two.
  2. Manager is first-class and symmetric to System (registerManager, owned actions, init/dispose).
  3. Manager scope defaults to scene, game-scoped is opt-in.
  4. SceneManager owns Manager lifecycle (rides the existing swap sequence).
  5. Manager = plain singleton; method access by direct import, not a locator.
  6. Engine built-in Managers (NavigationManager, TimeManager) registered once at core startup.
  7. setup.ts = manifest; init.ts deleted; logic lives in named Managers.
  8. Navigation is a Manager, backed by SceneManager, owning the history stack; scene-selector drops its private navigateBack.
  9. Time = timeSystem (System) + TimeManager (Manager); anchors are offsets into a never-reset elapsed.
  10. TimeManager ships generic anchors (mark/timeSince) with fixed accessors on top.
  11. UI reads Manager values via a read-source registry (option B) — the read-side mirror of actionRegistry — not a per-frame store projection.

Built with Modoki.