Skip to content

App Setup & Lifecycle

Everything in Estella runs inside an App — the container that owns the world, the resources, the system schedules, and the frame loop. The editor’s play mode, an exported web/desktop game, a WeChat MiniGame, and a headless authoritative server all build the same App class; only the factory that assembles it differs. This page is the reference for those factories and for the app’s runtime surface.

The batteries-included entry point for a browser (or Electron) game. It takes the instantiated engine wasm module (ESEngineModule), initializes the renderer, and installs the full plugin stack:

import { createWebApp } from 'esengine';
const app = createWebApp(module, {
wasmBaseUrl: '/engine', // where physics.wasm / spine42.wasm live
colorSpace: 'linear',
getViewportSize: () => ({ width: innerWidth, height: innerHeight }),
});
await app.run();

All fields are optional. CreateWebAppOptions extends WebAppOptions (both importable from esengine) with wasmBaseUrl:

Option Type Default Meaning
wasmBaseUrl string — Base URL the side-module artifacts (physics.wasm, spine38.js/.wasm, …) are served from — usually the directory esengine.wasm itself came from. When set (and no explicit sideModules is given) it builds a fetch SideModuleHost for you.
sideModules SideModuleHost built from wasmBaseUrl, else none The realm’s acquirer for optional native modules. Realms that inline their modules (Playable, WeChat) pass a host directly instead of a URL.
plugins Plugin[] [] Extra plugins, appended after the default set.
getViewportSize () => { width, height } — Viewport size callback for the camera system (and the WebGPU init size; 800×600 if absent).
glContextHandle number — A pre-registered WebGL2 context handle to render into; without it the module creates its own context via initRenderer().
backend 'webgl2' | 'webgpu' 'webgl2' 'webgpu' boots the injected-device path — the host must have acquired a GPUDevice and passed it as the module factory’s preinitializedWebGPUDevice before instantiation.
canvasSelector string '#canvas' CSS selector for the WebGPU swapchain canvas (must be in the DOM). WebGL ignores it.
colorSpace 'gamma' | 'linear' 'gamma' Project color space. 'linear' renders in linear light (sRGB decode on sample, linear blending, explicit encode in the final blit). Must be declared at app creation — shaders compile against it.
randomSeed number — Seed the engine’s randomness so the run reproduces — a replay, a bug report, an assertion about a frame. Absent, every run differs, which is what a player wants of particles. Subsystems take independent named streams, so adding an emitter cannot change what something else rolled.
ySortLayers number 0 Bitmask of render layers (bits 0–31) that sort by world Y within the layer (top-down occlusion).
screenFit ScreenScalingData off (scaleMode: -1) Project camera fit: letterboxes a design resolution into the actual aspect. Only installed when scaleMode is a real mode (≥ 0); SCREEN_FIT_OFF (-1) keeps the legacy Canvas-or-raw fit.

createWebApp first wires the core (renderer-facing) plugins, then the full content stack, in this order:

  1. Core: corePlugin (render facades), the camera plugin, assetPlugin, prefabsPlugin, inputPlugin, sceneManagerPlugin — plus a fresh RenderPipeline.
  2. UI: uiPlugins — the composed UI pipeline (layout, text, text input, masks, render order, interaction, drag, focus, safe area).
  3. Base content: timerPlugin, lifecyclePlugin, animationPlugin, audioPlugin, videoPlugin, particlePlugin, trailPlugin, mesh2dPlugin, tilemapPlugin, postProcessPlugin, timelinePlugin, perceptionPlugin, fsmPlugin, btPlugin, navPlugin, replicationPlugin.
  4. Spine: a SpinePlugin instance (it pulls its per-version wasm runtime from app.sideModules on demand — nothing loads unless a scene uses Spine).
  5. Your plugins, in the order given.

Physics is not in the default set: Physics2DPlugin needs the physics wasm module. The shipped runtime acquires 'physics' from the side-module host and installs the plugin automatically when the project declares physics or the scene contains physics components; when composing manually, construct Physics2DPlugin yourself (it and loadPhysicsModule are exported from esengine; the full API lives on esengine/physics).

createHeadlessApp(module, options) builds an app with the full simulation stack and no presentation — no renderer, no render pipeline, no input device, no UI. This is the authoritative-server shape (same wasm module, same gameplay code, same fixed-tick loop), also useful in workers and tests:

import { createHeadlessApp, runHeadless } from 'esengine';
const app = createHeadlessApp(module, { plugins: [gameplayPlugin] });
const stop = runHeadless(app, { fps: 60 });
// … later
stop();

HeadlessAppOptions has two fields, both optional:

Option Type Meaning
plugins Plugin[] Your gameplay plugins, appended after the base set.
sideModules SideModuleHost Optional-native-module acquirer (physics on the server).

Included: assetPlugin, prefabsPlugin, sceneManagerPlugin, timerPlugin, lifecyclePlugin, audioPlugin (silent on hosts without a device), and the gameplay-AI/replication set (perceptionPlugin, fsmPlugin, btPlugin, navPlugin, replicationPlugin).

Excluded — everything that exists to be seen: corePlugin (it wires render facades that only exist after initRenderer()), UI, particles, tilemap rendering, post-processing, timeline, Spine, input.

runHeadless(app, { fps? }) drives the app on a wall-clock setInterval (there is no requestAnimationFrame on a server), default 60 fps. Delta is measured, so the fixed-step accumulator keeps simulation cadence exact across timer jitter; a slow async tick never piles up re-entrantly. It returns a stop function. You can also drive the app manually with app.tick(dt).

A plugin is a plain object that registers systems and resources on the app:

import type { App, Plugin } from 'esengine';
import { defineResource, defineSystem, Res } from 'esengine';
const Score = defineResource({ value: 0 }, 'Score');
const scoreSystem = defineSystem([Res(Score)], (score) => { /* … */ });
export const scorePlugin: Plugin = {
name: 'score',
build(app: App) {
app.insertResource(Score, { value: 0 });
app.addSystem(scoreSystem);
},
};

That resource is plain data to keep the example about the plugin shape — but a resource can equally be a class instance with methods, which is how every engine subsystem publishes its API. See Plugins & Resources.

Member Type Meaning
name string? Optional but recommended: named plugins auto-register in the subsystem registry, and systems added inside build() inherit the name for liveness reporting.
dependencies PluginDependency[]? Prerequisites, each either a plugin name (string) or a resource definition. addPlugin throws if one is missing; addPlugins also uses the name edges to sort the batch.
before / after string[]? Soft ordering hints against other plugin names, honored when installing a batch via addPlugins.
build(app) required Register systems, resources, and events. Runs immediately on addPlugin.
finish(app) optional Runs once, after all plugins have built, when the app first ticks/runs — for wiring that needs the complete set.
cleanup(app?) optional Runs on app.quit(), in reverse install order.

PluginDependency = string | ResourceDef<any> — a name asserts install order; a resource def asserts the resource already exists.

Installation: app.addPlugin(plugin) (idempotent per instance — adding the same object twice is a no-op) or app.addPlugins([...]), which topologically sorts the batch by dependencies / before / after and throws on a cycle. If build() throws, the plugin is rolled back and the failure stays visible in the subsystem registry as an error entry.

flushPendingSystems(app) — module-level addSystem / addStartupSystem / defineBehavior calls (made at import time, outside any plugin) queue on the ambient context rather than on a specific app. The factories’ runtimes drain that queue into the app for you; call flushPendingSystems yourself when you build a bare App manually and still want those module-level registrations.

The members exported for users, grouped:

Member Meaning
app.world The ECS World — entities, components, queries.
app.insertResource(def, value) / app.getResource(def) / app.hasResource(def) The resource store systems read via Res(...). getResource on a never-inserted resource installs and returns the def’s default value — use hasResource to distinguish.
app.wasmModule The connected ESEngineModule, or null before connectCpp. Part of the embedding contract — see below.
app.pipeline The RenderPipeline, or null in headless apps.
app.subsystems The per-app SubsystemRegistry.
app.sideModules The realm’s SideModuleHost, or null (headless/test apps without one).
app.getPlugin(Ctor) The installed plugin instance of a class, if any.

connectCpp, app.wasmModule and world.getCppRegistry() belong to a different contract than the rest of this page: they are how a host — the editor, the packaged game shell, a custom runtime — binds an engine core to an app. A game never calls them, because the App it is handed is already connected.

They are marked @internal for that reason, and the types they speak in (ESEngineModule, CppRegistry, CppResourceManager) are exported from esengine/wasm rather than esengine. If you are writing a host, import them from there; if you are writing a game, you should never need to name them.

Member Meaning
app.addSystem(sys) Register on Schedule.Update.
app.addStartupSystem(sys) Register on Schedule.Startup (runs once, before the first frame’s schedules).
app.addSystemToSchedule(schedule, sys, { runBefore?, runAfter?, runIf? }) Any schedule, with a run condition and ordering edges — added to any the definition already declared.
app.addSystemSet(set) / app.addSystemSetToSchedule(schedule, set) Register a whole SystemSet; members inherit the set’s runIf and ordering, and other systems can order against the set’s name.
app.removeSystem(systemId) Remove by the def’s _id symbol.
app.addEvent(eventDef) Register an event channel (double-buffered; swapped each frame).
Member Meaning
app.run() Start the requestAnimationFrame loop.
app.tick(delta) Advance exactly one frame (seconds) — how headless and test hosts drive the app.
app.quit({ keepRenderer? }) Stop, run plugin cleanup in reverse order, despawn all entities, tear down the renderer (unless keepRenderer, used by hot reload).
app.setFixedTimestep(s) / app.getFixedTimestep() The FixedUpdate cadence. Default 1/60.
app.setMaxDeltaTime(s) Clamp on a single frame’s delta. Default 0.25.
app.setMaxFixedSteps(n) Cap on fixed-step catch-up per frame. Default 8. A time budget additionally caps catch-up so a too-heavy sim degrades to slow motion instead of freezing.
app.setPaused(v) / app.isPaused() User pause. While paused only Schedule.Last runs.
app.holdExecution() Returns an idempotent release function. While any owner holds execution, automatic frames, tick, stepping and native game input are held. Audio and video plugins cooperate.
app.isExecutionHeld() / app.onExecutionChange(fn) Query the hold, or observe it with an initial notification and disposer. Observers are withdrawn on quit.
app.stepFrame() Queue exactly one full frame while paused (editor “step”).
app.setPlaySpeed(x) / app.getPlaySpeed() Time scale, clamped to 0.1–4.0. Default 1.0.
app.setTargetFrameRate(fps) / app.getTargetFrameRate() Frame cap; 0 (default) = uncapped (vsync).

Within one frame the schedules run: First → the fixed trio (FixedPreUpdate / FixedUpdate / FixedPostUpdate, zero or more times from the accumulator) → PreUpdate → Update → PostUpdate → Last. Startup systems are flushed before the first frame.

Use an execution hold when a host requires the game to stop rendering and interacting:

const release = app.holdExecution();
// Other host owners may acquire their own holds.
release(); // the last release resumes automatic execution, preserving user pause

The input funnel discards held keys and press edges; UI gesture captures are cancelled rather than clicked after resuming. Audio resume and video play cannot override the hold. Paused, stopped or completed videos remain so after release. Startup systems that had not begun are retained, and resuming waits for an interrupted asynchronous startup before the first new frame. The frame clock is rebased so host pause time does not become a simulation catch-up interval.

Already-started asynchronous systems can still settle. The engine prevents later systems and completed-frame observers from running for that interrupted frame; arbitrary JavaScript promises, timers and external network requests are owned by the game. Use onExecutionChange to abort or suspend those tasks and restart them after release. Quit invalidates outstanding release functions.

Member Meaning
app.onError(fn) Observe any system throw ((error, systemName)).
app.onSystemError(fn) Decide per throw: return 'continue' or 'pause' (pauses the rest of the frame).
app.onWasmError(fn) Observe errors crossing the wasm bridge.
app.waitForPhysics() / app.isPhysicsReady Await / query the async physics wasm init (no-op / false without Physics2DPlugin).
app.enableStats() Turn on timing collection.
app.getSystemTimings() / app.getPhaseTimings() / app.getFrameScopes() Per-system / per-schedule / sub-frame CPU timings this frame (null when stats are off).
app.measureFrameScope(name, fn) Time fn as a named sub-frame scope (a single-branch no-op when stats are off).
app.getEntityCount() Live entity count.

lifecyclePlugin (installed by default in both factories; configure with new LifecyclePlugin(options)) publishes the Lifecycle resource — a LifecycleManager that tracks page visibility/focus and can auto-pause the game:

import { Lifecycle } from 'esengine';
const lifecycle = app.getResource(Lifecycle);
const off = lifecycle.on((event) => {
if (event === 'pause') saveGame();
});
Member Type Meaning
visible boolean Current page/app visibility.
focused boolean Window focus (tracked; changing it emits no event).
autoPause boolean Whether hiding the page pauses the app. Default true; writable at runtime, seeded from new LifecyclePlugin({ autoPause }).
on(listener) / off(listener) — Subscribe to events; on returns an unsubscribe function.

LifecycleEvent is 'show' | 'hide' | 'pause' | 'resume', and a LifecycleListener is (event: LifecycleEvent) => void. The semantics:

  • hide / show fire on every visibility change (browser visibilitychange; wx.onHide / wx.onShow on WeChat).
  • pause fires only when the lifecycle actually paused the app: the page hid, autoPause is on, and the app wasn’t already paused. resume fires only when the lifecycle un-pauses its own pause — a manual app.setPaused(true) is never overridden by the page becoming visible.
  • Headless hosts (Node, workers) have no visibility signal; the resource still exists and stays visible.

Every app carries a SubsystemRegistry at app.subsystems answering “which engine modules are loaded, ready, stepping, or errored”. Named plugins register automatically; async plugins (physics) drive their own initializing → ready/error transitions.

for (const s of app.subsystems.getStatuses()) {
console.log(s.id, s.phase, s.activity, s.lastError ?? '');
}
  • SubsystemPhase — the recorded lifecycle: 'registered' → 'initializing' → 'ready', with 'error' terminal.
  • SubsystemActivity — liveness, derived at read time from watchdog beats (each system run “pets” its owning plugin): 'stepping' (a beat within the last 400 ms), 'idle' (ready but stale — e.g. physics frozen in edit mode), 'inactive' (never beat; liveness unknown). Load ≠ run — this is the distinction the editor’s status UI shows.
  • SubsystemStatus — one entry per subsystem, install order: id, displayName, phase, activity, detail, lastError (retained across later transitions), dependsOn (declared plugin-name deps, for cascade context), phaseAgeMs.
  • SubsystemEvent — one recorded transition (id, phase, detail?, atMs, seq); recentEvents(limit?) returns the retained tail (capped at 128).
  • subscribe(fn) notifies on phase transitions (not per-beat) and returns an unsubscribe.

The registry is per-app (per realm) — the editor’s edit and play realms report independently.

Optional native subsystems ship as standalone wasm modules loaded on demand, so the base engine download stays small: physics (Box2D), the per-version Spine runtimes, the Basis Universal texture transcoder, and the software video decoder. Each is an emscripten pair — <file>.js glue plus <file>.wasm — acquired uniformly through a SideModuleHost; only the transport differs per realm.

SIDE_MODULES is the single source of truth mapping ids to artifacts:

SideModuleId Artifacts Contents
'physics' physics.js / .wasm Box2D physics.
'spine:3.8' / 'spine:4.1' / 'spine:4.2' spine38 / spine41 / spine42 .js/.wasm One Spine runtime per skeleton format version.
'basis' basis.js / .wasm Basis Universal KTX2 transcoder (compressed textures).
'videodec' videodec.js / .wasm MPEG-1 software video decoder.

SPINE_VERSIONS is the readonly list ['3.8', '4.1', '4.2'], and spineModuleId(version) builds the corresponding id.

A host answers acquire(id) with a promise of the instantiated module (cached per id, failures cached as null so a missing artifact isn’t refetched). The built-in transports:

  • createFetchSideModuleHost(baseUrl) — fetch the glue and wasm from a base URL (what wasmBaseUrl builds); used by the editor, web, and desktop.
  • createEmbeddedSideModuleHost(...) — modules inlined as base64 (Playable ads).
  • createWeChatSideModuleHost(...) — WeChat’s precompiled module factories.

You normally never call acquire yourself — set app.sideModules (via wasmBaseUrl or sideModules) and the Spine plugin and physics install pull from it when needed.

When you edit project code while the editor plays, the editor tries a state-preserving hot swap before falling back to a full reload. It re-imports your bundle inside an isolated probe context via probeRegistrations(register), which returns a ProbedRegistrations:

  • fingerprint — a digest of the user component schemas the bundle declared (shape, not values). Unchanged fingerprint → the live World is kept and only system function bodies are swapped in place; changed → full reload.
  • pending — the systems the bundle queued, fed to App.hotSwapSystems (which rejects the swap — again forcing a full reload — if a user system was added, removed, or renamed).

What your code must respect: component identity is stable by name. defineComponent('Health', …) re-run after a re-import resolves to the same component identity, so re-imported systems’ queries reach the live storage. Consequently, renaming a component or behavior is a structural change (full reload + state loss for that data), and per-entity start hooks run again after a reload — write them as idempotent setup (see Scripting).

  • Prefer the factories. createWebApp / createHeadlessApp encode the supported plugin orders; hand-rolling App.new() plus a plugin list is for engine embedders.
  • Name your plugins. A name costs nothing and buys observability: the subsystem registry entry, liveness attribution for the plugin’s systems, and usable dependency errors.
  • Declare colorSpace and backend up front. Both are baked in at creation (shader compilation, device injection) — they cannot be toggled on a live app.
  • Don’t fight the lifecycle auto-pause. Turn it off with new LifecyclePlugin({ autoPause: false }) (or lifecycle.autoPause = false) rather than un-pausing in a listener; the manager only ever resumes a pause it made itself.
  • Route “is X loaded?” through app.subsystems. It distinguishes loaded from running (phase vs activity) and retains the last error — better than probing resources.