Web Engine Docs
Preparing documentation
Use the search bar to quickly find any topic
Preparing documentation
Use the search bar to quickly find any topic
Deep dive into script execution flow, lifecycle hooks, async patterns, and inter-script communication in Web Engine.
Understanding the script lifecycle is crucial for writing efficient, bug-free game logic. This guide covers execution order, lifecycle hooks, async operations, and communication patterns.
onAwake → onStart
onUpdate every frame
onDestroy on removal
Scripts follow a predictable execution order that ensures entities are initialized before they interact:
1. Factory Function (api) => { ... }↓ Called once when script is attached or Play Mode starts↓ Returns lifecycle hooks object2. onAwake(time)↓ Called immediately for all entities↓ Use for critical initialization↓ Other entities may not be ready yet3. onStart(time)↓ Called after ALL entities have awakened↓ Safe to reference other entities↓ Use for setup that depends on other entities4. onUpdate(dt, time)↓ Called every frame↓ Receives delta time (dt) and total time↓ Main game logic goes here5. Physics/Animation Events↓ onCollisionEnter, onTriggerEnter, onAnimationEvent↓ Can fire any time after onStart6. onDestroy(time)↓ Called when entity is destroyed or script removed↓ Use for cleanup (remove listeners, free resources)↓ Happens once at the end
Why Two Init Phases?
onAwake and onStart solve the dependency problem: onAwake sets up local state, onStart can safely reference other entities that have already awakened.
Called immediately when the entity becomes active, before any other logic. Use for critical initialization that must happen first.
export default (api) => {// Factory runs firstlet initialized = false;return {onAwake: (time) => {// Called immediatelyapi.log("Awakening at time: " + time);// Initialize critical stateinitialized = true;// Set up internal data structures// WARNING: Other entities may not be ready yet!}};};
Called once on the first frame after all entities have awakened. Safe to reference other entities and perform setup that depends on the scene being fully initialized.
export default (api) => {let targetEntity = null;return {onStart: (time) => {api.log("Starting at time: " + time);// NOW it's safe to find and reference other entitiesif (api.target.exists) {targetEntity = api.target;api.log("Found target!");}// Perform expensive setup// Load resources, initialize AI, etc.}};};
Called every frame. This is where your main game logic goes. Receives delta time (seconds since last frame) and total elapsed time.
export default (api) => {let rotation = 0;return {onUpdate: (dt, time) => {// dt: Delta time in seconds (typically ~0.016 at 60 FPS)// time: Total elapsed time since game started// Frame-rate independent movementrotation += 1.0 * dt; // 1 radian per second// Apply rotationconst rot = api.rotation;rot.y = rotation;api.rotation = rot;}};};
Always Use Delta Time
Multiply movement/rotation by dt to make your game frame-rate independent. Without it, movement will be faster on high-refresh displays and slower on low-end devices.
Called when the entity is destroyed or the script is removed. Use for cleanup: remove event listeners, free resources, notify other systems.
export default (api) => {let intervalId = null;return {onStart: (time) => {// Set up a timer (hypothetical)intervalId = setInterval(() => {api.log("Tick!");}, 1000);},onDestroy: (time) => {// Clean up resourcesif (intervalId) {clearInterval(intervalId);intervalId = null;}api.log("Destroyed at time: " + time);}};};
Physics collision and trigger events fire when bodies interact. These can happen at any time after onStart.
export default (api) => {return {onCollisionEnter: (otherEid) => {// Called when RigidBody collision beginsapi.log("Collided with entity " + otherEid);},onCollisionExit: (otherEid) => {// Called when RigidBody collision endsapi.log("Stopped colliding with " + otherEid);},onTriggerEnter: (otherEid) => {// Called when Sensor (trigger) overlap beginsapi.log("Trigger entered by " + otherEid);},onTriggerExit: (otherEid) => {// Called when Sensor overlap endsapi.log("Trigger exited by " + otherEid);}};};
Collision vs Trigger
Collision: RigidBody components. Bodies physically collide and affect each other.
Trigger: Sensor components. Bodies pass through but fire events.
export default (api) => {return {onAnimationEvent: (clipName, eventName) => {// Called when an animation event marker is hitapi.log(`Animation event: ${eventName} in clip ${clipName}`);// Use for syncing gameplay to animationif (eventName === "footstep") {api.audio.play(); // Footstep sound}}};};
Scripts support async/await for operations like spawning, raycasting, and scene loading. Be careful with timing and cancellation.
export default (api) => {return {onUpdate: async (dt, time) => {if (api.input.fireDown) {// Spawn returns a Promiseconst bulletEid = await api.spawn("Bullet", api.position, api.rotation);api.log("Spawned bullet: " + bulletEid);// You can continue logic after spawn completes// Set bullet velocity, etc.}}};};
export default (api) => {const rayOrigin = { x: 0, y: 0, z: 0 };const rayDir = { x: 0, y: -1, z: 0 };return {onUpdate: async (dt, time) => {// Ground check via raycastconst pos = api.position;rayOrigin.x = pos.x;rayOrigin.y = pos.y;rayOrigin.z = pos.z;const hit = await api.raycast(rayOrigin, rayDir);if (hit && hit.distance < 1.1) {api.log("On ground!");} else {api.log("In air");}}};};
Async in onUpdate
Using async in onUpdate means the next frame can start before async operations complete. For most cases this is fine, but be aware of race conditions.
export default (api) => {let isActive = true;return {onStart: async (time) => {// Long-running async operationawait someExpensiveOperation();// Check if entity is still alive before continuingif (!isActive) {return; // Don't continue if destroyed}// Safe to modify entityapi.log("Operation complete!");},onDestroy: (time) => {// Mark as inactive to cancel pending operationsisActive = false;}};};
Scripts can communicate via several patterns: global events, shared state, and direct entity references.
// Script A: Emit eventexport default (api) => {return {onTriggerEnter: (otherEid) => {// Emit global eventevents.emit("item_collected", { type: "coin", value: 10 });}};};// Script B: Listen for eventexport default (api) => {let score = 0;return {onStart: (time) => {// Subscribe to eventevents.on("item_collected", (data) => {score += data.value;api.log("Score: " + score);});},onDestroy: (time) => {// Unsubscribe to avoid memory leaksevents.off("item_collected");}};};
// Camera script: Follow targetexport default (api) => {return {onUpdate: (dt, time) => {if (!api.target.exists) return;// Access target's positionconst targetPos = api.target.position;// Follow targetconst pos = api.position;pos.x = targetPos.x;pos.y = targetPos.y + 2.0; // Above targetpos.z = targetPos.z - 5.0; // Behind targetapi.position = pos;api.lookAt(targetPos);}};};
// GameState.ts - Shared moduleexport const GameState = {score: 0,health: 100,level: 1};// Script A: Modify stateexport default (api) => {return {onTriggerEnter: (otherEid) => {GameState.score += 10;api.log("Score: " + GameState.score);}};};// Script B: Read stateexport default (api) => {return {onUpdate: (dt, time) => {if (GameState.health <= 0) {api.log("Game Over!");}}};};
State Management
Shared module state persists across Play Mode sessions. Reset state in onStart or onDestroy to avoid stale data.
dx*dx + dz*dz < threshold*thresholdapi.position in a local variable if used multiple times.export default (api) => {// Reuse objects (created once)const moveVec = { x: 0, y: 0, z: 0 };let frameCount = 0;return {onUpdate: (dt, time) => {frameCount++;// Cache position readconst pos = api.position;// Squared distance check (avoids sqrt)const dx = targetX - pos.x;const dz = targetZ - pos.z;const distSq = dx * dx + dz * dz;if (distSq < 100) { // 10 units squared// Close to target}// Throttle expensive operationsif (frameCount % 10 === 0) {// Only every 10 frames// await api.raycast(...);}// Reuse moveVecmoveVec.x = dx * dt;moveVec.z = dz * dt;api.controller.move(moveVec);}};};