Entry skill·skills/ignifx/references/gotchas.md
Gotchas
Traps in the engine as it stands (0.0.0, rendering and assets included), each with its replacement
and, where one exists, the error code you will see. The eighteen most common are repeated in
../SKILL.md.
App and platform#
- Do not assume WebGPU exists. There is no WebGL fallback. Call
isWebGpuAvailable()and show a fallback page;createApp({ canvas })rejects withIGX-0701when no adapter is available. - Do not call
app.step(dt)on a rendering app. Babylon Lite's loop already drives frames, so it throwsIGX-0105.stepis forcreateApp({ headless: true })only. - Do not touch an app after
app.dispose(). Every member throwsIGX-0106. Create a new app. - Do not read
app.worldorapp.litebeforecreateAppresolves. They throwIGX-0107.createAppisasyncbecause the engine and the extensions'registerhooks are. - Do not set
time.fixedDeltaTimeortime.maximumDeltaTimeto zero or a negative number. It throwsIGX-0108. ChangefixedDeltaTimebetween frames, not inside one. - Do not pass a settings section no extension registered. It throws
IGX-0407in development; a value that fails its schema throwsIGX-0408. Register the section from an extension first. - Do not rely on
app.lite.engine,app.lite.scene,world.lite, ortransform.lite. They are unstable escape hatches, excluded from the stability guarantees, and Babylon Lite ships breaking changes between minors. Ask for a first-class API instead. - Do not write
{placeholder}tokens in a log message.app.log.info(message, ...data)does no interpolation: the console sink printsmessageverbatim and appendsdataas extra console arguments, soapp.log.info("crate at y={y}", y)literally printscrate at y={y} 3.2. Pass a label and the values —app.log.info("crate at y:", y)— or interpolate yourself with a template literal.
Lifecycle and time#
- Do not integrate with
app.time.deltaTimeinsidefixedUpdate. It runs 0…N times per frame. Use thedtargument, which is alwaystime.fixedDeltaTime. - Do not write
async awake(),async update(), or anyasynclifecycle callback. A promise continuation resumes after the entire frame, outside every phase. Use a coroutine (startCoroutine,yield waitSeconds(…)) —yield promiseeven resumes on the firstUpdateafter the promise settles. - Do not add
overrideto a lifecycle callback or to a static. Neither the callbacks nor the statics (typeId,schema,requires,allowMultiple,executionOrder,updateWhenPaused) are members ofComponent/Script: the callbacks live onScriptCallbacks/ComponentHooksand the statics are matched structurally throughComponentStatics/ScriptStatics. Writestatic typeId = "mygame/Mover".overrideis only needed when a class extends one of your own concrete classes that already declares the static. - Do not expect
awakeon a component added withenabled: false.awakewaits until the component is first effectively enabled.onEnablefollows in flush A,onDisablefires synchronously at the transition, andstartruns once ever, in flush B. - Do not depend on
startorder between unrelated scripts. Onlystatic executionOrderand creation order are guaranteed. Coordinate with aSignal. - Do not expect
lateUpdateafter destroying a script duringupdate. Destruction is queued to the destroy flush, which runs afterlateUpdate, but the script stops receiving callbacks at once andisDestroyedis alreadytrue. - Do not mutate an entity or component after
destroy(). It throwsIGX-0101. CheckisDestroyedfirst. - Do not call
destroyImmediate()inside a lifecycle callback. It throwsIGX-0102; it exists for tools and tests. Usedestroy(). - Do not expect the fixed loop while paused.
app.pause()stops fixed steps entirely and skipsupdate/lateUpdateunless a class setsstatic updateWhenPaused = true. Every other phase keeps running, which is what lets a pause screen render. - Do not expect
app.step(dt)to advancedtseconds. The delta is clamped bytime.maximumDeltaTime, which is0.1by default, soapp.step(0.4)advances one tenth of a second andtime.droppedSecondsreports the rest. A headless test that wants 0.4 s of game time pumps 24 frames of1 / 60; raiseapp.time.maximumDeltaTimeonly if a long single step is really what you mean.
Scene graph#
- Do not remove a Lite mesh from the scene to hide something. Lite disposes a mesh removed
from its last scene, permanently. Set
entity.active = falseto hide, and usedestroy()only when the object is really finished with. - Do not use
entity.find("Body/Arm.L")in game code. Paths break the moment anyone renames or reparents a node. Declare anentityRef/componentReffield, orrequireComponent;findis allowed in tests, examples, and tools only. - Do not keep a plain class field pointing at an entity or component. Only
entityRefandcomponentReffields are tracked and nulled in the destroy flush. A plain field becomes a dangling reference; checkisDestroyedbefore using one. - Do not depend on the order of
world.findByTag(tag)orworld.components(Type). Both are live arrays maintained by swap-remove: membership is stable, order is not. Sort if you need one. - Do not call a world-space getter in a hot loop.
transform.position,.rotation,.eulerAngles,.lossyScale,.forward,.right, and.upallocate a fresh object each time. UsepositionToRef(out)and friends, or mutatelocalPosition/localRotation/localScalein place — they are live views over the Lite node. - Do not remove or disable a
Transform. Every entity has exactly one for its whole life (IGX-0205). - Do not parent an entity into its own subtree. It throws
IGX-0306.setParentdefaults toworldPositionStays: true; passfalseto keep local values instead. - Do not assume radians. Every public angle is in degrees (
rotate,eulerAngles,rotation2D,Quat.fromEulerDegrees); a radian API always carries aRadsuffix (Quat.fromEulerRadians,Quat.toEulerRadiansToRef).
Components, schemas, signals#
- Do not expect a plain class field to be saved. Only fields declared in
Component.define({...})/Script.define({...})are serialized, inspected, and documented. - Do not let the class name be the
typeId. Minifiers rename classes. Write an explicit, namespacedstatic typeId = "mygame/Mover"; a duplicate throwsIGX-0203and a missing one throwsIGX-0204at serialization time. - Do not name a schema field after a
Componentmember.enabled,update,entity, and the rest throwIGX-0607when the class is defined. - Do not forget
app.registerComponents([...]). A class the registry does not know cannot be resolved from a file, andrequiresvalidation (IGX-0201) and the single-instance rule (IGX-0202) run off the same metadata. - Do not call
signal.connect(handler)from a script without an owner. Pass{ owner: this }so the connection dies with the script; the custom lint rule flags the bare form. - Do not use
{ deferred: true }on aSignalyou constructed yourself. Deferred delivery needs the app's end-of-frame queue behind it, and a standalone signal throwsIGX-0103. - Do not use
NaNorInfinityin a serialized field. Encoding rejects them withIGX-0601; numbers are canonicalized to six decimal places so files round-trip byte-identically.
Rendering#
- Do not turn a rendering feature on after
app.start(). Babylon Lite appliesshadows,postProcessing,skeletons,boneControl,stencil,lightmaps,materialPlugins,asyncPipelines, anddeviceLostRecoveryonly before the scene is registered, and every one of them isfalseby default. Declare them insettings.rendering.features, or from an extension withctx.requireRenderingFeature(name); afterwards it throwsIGX-0704. A light withshadows.enabled = trueand noshadowsfeature simply gets no shadow pass. - Do not expect a mesh spawned at runtime to be visible on the next frame. A material family
that did not exist when the scene was registered takes Lite's runtime build path: spike S2.2
measured 3 extra frames before the mesh appeared, against 0–2 when the family had been warmed
and 0 when a mesh of that family was already drawn.
app.start()warms thebootpreload group; callapp.renderer.warmUp(materials)for anything you load later (ADR-0014). - Do not put two
Environmentcomponents in one world. The one enabled last wins and the world logsIGX-0705once. There is one image-based lighting setup per world. - Do not ask a point or hemispheric light for shadows. Lite has no cube-shadow generator, so
shadows.enabledon either throwsIGX-0703. Cast from a directional or spot light. - Do not forget an enabled
Camera. A world without one renders nothing and logsIGX-0706once — once per renderer, on the first frame it reconciles, not once perapp.step. The enabled camera with the highestprioritywins; ties break on creation order. A headless app has the same render sync, so a camera-less test prints the warning too; it is harmless there. Add aCameraentity, or passcreateApp({ headless: true, logLevel: "error" })(or"silent") to keep test output clean. - Do not expect
MeshRenderer.materials[1]to draw. This Lite version has one material per mesh: index 0 is used, later entries are accepted and ignored, and an empty array draws with the default material. The array shape is kept so files survive submesh support landing. - Do not
await app.renderer.captureScreenshot()without a running render loop. A frame has to be presented, so a headless app — or a stopped one — rejects withIGX-0707. - Do not write a colour as a hex string in settings or a file.
"#101014"is only ever a schema default. A settings value is a colour object ({ r, g, b, a }, sRGB 0–1) and a file value is[r, g, b, a]; anything else isIGX-0408orIGX-0605. - Do not attach a
PostProcessStackwithoutfeatures.postProcessing. It is declared at app start like the other features, because it picks the scene's whole render path: a post-process effect has to sample what the scene drew, a WebGPU canvas texture cannot be sampled, and so the scene is rendered into an offscreen target instead. Without the feature the stack logsIGX-0710once and does nothing. Two more rules for a stack you do enable:imageProcessingis always applied last whatever itsorder, and an effect once recorded is switched off rather than removed —stack.enabled = falsebypasses the chain and brings the plain scene back. - Do not point a
Lightby writing to the Lite light. The entity's transform is the light: a directional or spot light shines along the entity's forward (+Z) axis, a point or spot light sits at its position, and a hemispheric light's sky direction is the entity's up (+Y) axis. Useentity.transform.lookAt(target). The Lite light is deliberately unparented and ignifx rewrites its pose from the entity every frame the entity moves, so anything you write ontolight.lite.light.directionis overwritten. - Do not assume device loss is recoverable. Even with
features.deviceLostRecoveryon, Lite cannot rebuild PCF/CSM shadow generators or glTFEXT_lights_image_basedenvironments, so a scene using either reachesapp.events.onDeviceRecoveryFailed. Offer a page reload.
Assets#
- Do not call
loadwithout a matchingrelease. Handles are shared and reference-counted: two loads of one address answer with the same handle.using handle = app.assets.load(…)or an explicitrelease()— and a zero-reference asset is only unloaded afterassets.gcDelayseconds (default 5). - Do not read
handle.valuewhile the state is not"loaded". It throwsIGX-0501. Awaithandle.promise,yield handle.promisein a coroutine, or checkhandle.statefirst. - Do not expect a load to land mid-frame. Completed loads are delivered by one system in
PreUpdate, so a state flip you asked for duringupdateis observable on the next frame, at one consistent point. That is deliberate: "is this ready?" has one answer per frame. - Do not release the handle an
asset()field holds. The field never owned the reference — the scene instance that loaded the asset releases it on unload. Release only what your own code loaded or built. - Do not expect an in-code asset to survive a save.
MeshAsset.box(app),createMaterialAsset(app, …), and anything fromAssets.registerlive at amemory:address that names no file, so serializing a component holding one writesnulland reportsIGX-0602. Write a.material.jsonor ship a.glbwhen it has to round-trip. - Do not look for a mesh file format. There is none: geometry is a primitive built in code or
part of a
ModelAsset. A"mesh"address fails withIGX-0504. - Do not load a scene before registering the components it names. An unknown
typeIdisIGX-0307and the entity is built without that component. Callapp.registerComponents([...])first —virtual:ignifx/scriptsfrom@ignifx/vite-plugingenerates the list for you. - Do not hand-write a
memory:address, and do not assume an address is a URL. Addresses are resolved through the manifest;app.assets.resolveUrl(address)is what a loader fetches.
Callbacks the extensions deliver#
- Do not type a physics callback's parameter as
unknownand cast it. Core declaresonCollisionEnter?(collision: unknown)andonTriggerEnter?(trigger: unknown)onScriptCallbacksbecause it does not depend on the physics packages, but TypeScript's method parameters are bivariant: writingonTriggerEnter(trigger: TriggerEvent): void(orTriggerEvent2D) on your script satisfies the interface directly. The cast is not only unnecessary, it tripstypescript/no-unsafe-type-assertionunder the engine's lint settings. The payloads arrive only whenphysics()orphysics2d()is registered; without either, nothing calls them andworld.lite.simulationSceneisnull.