← Index · Prev · Next →

02 — Runtime Basics: loop, objects, and Babylon hooks

Behavior authors: this is usually your first engine chapter — it explains what runs when and how the kit hooks into Babylon.js. Skim 01 — Architecture first only if you need the two-file export model (glb + manifest).

App vs kit

Your game app (e.g. apps/playground/src/main.ts) owns the Babylon Engine, the Scene, and the browser render loop. The kit (@bjs/engine) owns loading a level into that scene and driving gameplay scripts.

LayerTypical ownerRole
EngineAppWebGL/WebGPU context, runRenderLoop, getDeltaTime()
SceneAppBabylon scene graph — meshes, lights, cameras, physics plugin
LevelLoader / LevelKitLoad manifest + glb; container for entities, subsystems, update loop
Entity / BehaviorKitOne Blender object + its SCRIPT instances and component attachments
TransformNode / PhysicsBodyBabylonActual nodes and Havok bodies the kit creates from manifest data

The kit never calls engine.runRenderLoop — that is always the app. Without it, OnUpdate never runs.

App bootstrap (once, at startup)

Typical order in main.ts:

  1. Fetch manifest (optional but required for large-world rendering) — FetchAndValidateManifest then CreateLevelEngine(canvas, antialias, manifest) from core/bootstrap.ts when the Blender scene enables Large World Rendering.
  2. const scene = new Scene(engine)
  3. await EnableHavokPhysics(scene, …) — must run before Load; registers Havok on the Babylon scene.
  4. Register behaviors (BehaviorRegistry + AutoRegisterBehaviors).
  5. const level = await loader.Load(manifestUrl, manifest) — async; builds everything and calls level.Begin() inside FinalizeLevel (see 04 — Load Pipeline).
  6. engine.runRenderLoop(() => scene.render()) — starts the per-frame loop.

Debug keys (C/I), fallback cameras, and resize handlers are app concerns — see 09 — Workflow.

Load time vs run time

PhaseWhenWhat happens
LoadLevelLoader.Load() until it returnsAppend glb, build entities, ApplyEntityComponents (the component registry), InstantiateScripts (no lifecycle hooks yet), second pass for entity refs, FinalizeLevel, then Begin() → every OnStart once.
RunEvery frame after runRenderLoopscene.render() → Babylon observables → Level.RunFrame → every OnUpdate.

OnStart is not called from the render loop. It runs once at the end of load, after physics bodies, triggers, and constraints exist.

The frame loop (how OnUpdate hooks into Babylon)

Each browser frame, the app calls scene.render(). Babylon fires onBeforeRenderObservable callbacks, then steps physics, then draws. The kit registers Level.RunFrame on that observable in Level.Begin().

sequenceDiagram
  participant App as main.ts
  participant Engine as Engine.runRenderLoop
  participant Scene as Scene.render
  participant Particles as onBeforeRender_first
  participant Level as Level.RunFrame
  participant Physics as Havok_step
  participant Draw as GPU_draw
  participant Msdf as onAfterRender

  App->>Engine: requestAnimationFrame tick
  Engine->>Scene: render()
  Scene->>Particles: empty emitter positions
  Scene->>Level: onBeforeRenderObservable
  Note over Level: InputManager.Process
  Note over Level: all behavior.OnUpdate
  Note over Level: Level.AddUpdater callbacks
  Note over Level: InputManager.EndFrame
  Scene->>Physics: physics engine step
  Scene->>Draw: meshes shadows post
  Scene->>Msdf: MSDF text pass

Key facts:

Inside RunFrame, order is fixed:

  1. InputManager.Process() — poll gamepad, evaluate actions, fire callbacks
  2. Every behavior's OnUpdate(deltaSeconds) (entity iteration order unspecified)
  3. Registered Level.AddUpdater callbacks (offset/follow cameras)
  4. InputManager.EndFrame() — so WasPressedThisFrame edges last exactly one frame

Interactive trace: trace-runtime-loop · trace-lifecycle.

Behavior lifecycle (summary)

Full scripting detail: 05 — Scripting. Input polling: 12 — Input.

HookWhen
OnStart()Once, end of load (Level.Begin), after all entity refs resolve
OnUpdate(deltaSeconds)Every frame, inside RunFrame (see above)
OnDestroy()Level.Dispose — remove observers you added in OnStart
OnMessage(message, source)Triggers, 3D GUI clicks, or Entity.SendMessage — not tied to the frame loop

Hooks must be PascalCase (OnStart, not onStart). Scale continuous motion by deltaSeconds; Babylon velocities are already per-second — do not multiply those by deltaSeconds.

Other kit systems on the timeline

SystemBabylon hookWhenKit entry
BehaviorsonBeforeRenderObservableBefore physics/draw each frameLevel.RunFrame
Input actionsSame observer + keyboard observableProcess/EndFrame inside RunFrameInputManager
Particles (empty emitters)onBeforeRender (insert first)Before RunFrameWireParticleEmitterTracking
Physics (Havok)Internal to scene.render()After beforeRender callbacksEnableHavokPhysics
Animation clipsAnimationGroupBabylon animation system each frameApplyAutoPlayAnimations
Post / atmosphereCamera / effectsSetup in FinalizeLevel; post after BeginApplyPostProcessing
MSDF textonAfterRenderObservableAfter main drawWireMsdfTextRendering

Physics vs OnUpdate

Havok steps after your OnUpdate callbacks for that frame. Practical rules:

Practical rules for behavior authors

Where to go next