Script Lifecycle
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.
Initialization
onAwake → onStart
Update Loop
onUpdate every frame
Cleanup
onDestroy on removal
Execution Order#
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 object 2. onAwake(time) ↓ Called immediately for all entities ↓ Use for critical initialization ↓ Other entities may not be ready yet 3. onStart(time) ↓ Called after ALL entities have awakened ↓ Safe to reference other entities ↓ Use for setup that depends on other entities 4. onUpdate(dt, time) ↓ Called every frame ↓ Receives delta time (dt) and total time ↓ Main game logic goes here 5. Physics/Animation Events ↓ onCollisionEnter, onTriggerEnter, onAnimationEvent ↓ Can fire any time after onStart 6. onDestroy(time) ↓ Called when entity is destroyed or script removed ↓ Use for cleanup (remove listeners, free resources) ↓ Happens once at the endWhy 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.
Lifecycle Hooks#
onAwake(time)#
Called immediately when the entity becomes active, before any other logic. Use for critical initialization that must happen first.
export default (api) => { // Factory runs first let initialized = false; return { onAwake: (time) => { // Called immediately api.log("Awakening at time: " + time); // Initialize critical state initialized = true; // Set up internal data structures // WARNING: Other entities may not be ready yet! } };};- DO: Initialize local state, set up data structures
- DO: Register with global systems (if they exist)
- DON'T: Reference other entities (they may not exist yet)
- DON'T: Perform expensive operations (keep it fast)
onStart(time)#
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 entities if (api.target.exists) { targetEntity = api.target; api.log("Found target!"); } // Perform expensive setup // Load resources, initialize AI, etc. } };};- DO: Reference other entities (they're ready now)
- DO: Perform expensive initialization
- DO: Set up cross-entity relationships
- DON'T: Assume onStart runs every frame (it's once only)
onUpdate(dt, time)#
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 movement rotation += 1.0 * dt; // 1 radian per second // Apply rotation const 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.
onDestroy(time)#
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 resources if (intervalId) { clearInterval(intervalId); intervalId = null; } api.log("Destroyed at time: " + time); } };};- DO: Remove event listeners
- DO: Clear timers/intervals
- DO: Notify other systems of removal
- DON'T: Try to modify the entity (it's being destroyed)
Event Hooks#
Physics Events#
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 begins api.log("Collided with entity " + otherEid); }, onCollisionExit: (otherEid) => { // Called when RigidBody collision ends api.log("Stopped colliding with " + otherEid); }, onTriggerEnter: (otherEid) => { // Called when Sensor (trigger) overlap begins api.log("Trigger entered by " + otherEid); }, onTriggerExit: (otherEid) => { // Called when Sensor overlap ends api.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.
Animation Events#
export default (api) => { return { onAnimationEvent: (clipName, eventName) => { // Called when an animation event marker is hit api.log(`Animation event: ${eventName} in clip ${clipName}`); // Use for syncing gameplay to animation if (eventName === "footstep") { api.audio.play(); // Footstep sound } } };};Async Patterns#
Scripts support async/await for operations like spawning, raycasting, and scene loading. Be careful with timing and cancellation.
Async Spawning#
export default (api) => { return { onUpdate: async (dt, time) => { if (api.input.fireDown) { // Spawn returns a Promise const 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. } } };};Async Raycasting#
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 raycast const 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.
Handling Cancellation#
export default (api) => { let isActive = true; return { onStart: async (time) => { // Long-running async operation await someExpensiveOperation(); // Check if entity is still alive before continuing if (!isActive) { return; // Don't continue if destroyed } // Safe to modify entity api.log("Operation complete!"); }, onDestroy: (time) => { // Mark as inactive to cancel pending operations isActive = false; } };};Script Communication#
Scripts can communicate via several patterns: global events, shared state, and direct entity references.
Global Event Bus#
// Script A: Emit eventexport default (api) => { return { onTriggerEnter: (otherEid) => { // Emit global event events.emit("item_collected", { type: "coin", value: 10 }); } };}; // Script B: Listen for eventexport default (api) => { let score = 0; return { onStart: (time) => { // Subscribe to event events.on("item_collected", (data) => { score += data.value; api.log("Score: " + score); }); }, onDestroy: (time) => { // Unsubscribe to avoid memory leaks events.off("item_collected"); } };};Target Component#
// Camera script: Follow targetexport default (api) => { return { onUpdate: (dt, time) => { if (!api.target.exists) return; // Access target's position const targetPos = api.target.position; // Follow target const pos = api.position; pos.x = targetPos.x; pos.y = targetPos.y + 2.0; // Above target pos.z = targetPos.z - 5.0; // Behind target api.position = pos; api.lookAt(targetPos); } };};Shared Module State#
// 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.
Performance Tips#
- Reuse objects: Create objects in the factory or onStart, reuse them in onUpdate to avoid GC pressure.
- Avoid sqrt: Use squared distance for comparisons:
dx*dx + dz*dz < threshold*threshold - Cache API calls: Store
api.positionin a local variable if used multiple times. - Limit raycasts: Raycasts are expensive. Use them sparingly or throttle (every N frames).
- Pool vectors: The engine provides pooled Vector3/Euler objects - don't create new ones.
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 read const 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 operations if (frameCount % 10 === 0) { // Only every 10 frames // await api.raycast(...); } // Reuse moveVec moveVec.x = dx * dt; moveVec.z = dz * dt; api.controller.move(moveVec); } };};