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 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.

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 event
export default (api) => {
return {
onTriggerEnter: (otherEid) => {
// Emit global event
events.emit("item_collected", { type: "coin", value: 10 });
}
};
};
// Script B: Listen for event
export 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 target
export 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 module
export const GameState = {
score: 0,
health: 100,
level: 1
};
// Script A: Modify state
export default (api) => {
return {
onTriggerEnter: (otherEid) => {
GameState.score += 10;
api.log("Score: " + GameState.score);
}
};
};
// Script B: Read state
export 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.position in 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.
PerformanceOptimized.ts
typescript
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);
}
};
};
Script Lifecycle - Web Engine Docs | Web Engine Docs