Scripting Overview
Write custom game logic in JavaScript or TypeScript. Learn how to create, attach, and run scripts in Web Engine.
Scripts in Web Engine let you add custom behavior to entities. You write JavaScript (or TypeScript) functions that run every frame, with access to transforms, physics, input, and more.
Script Types#
Web Engine supports two scripting styles:
Function-Based
Simple closure pattern. Great for straightforward behaviors with minimal state.
Class-Based
Object-oriented pattern with lifecycle methods. Better for complex behaviors with local state.
Function-Based Scripts#
The simplest way to write a script. Export a factory function that takes theapi object and returns an update function or lifecycle object.
// Function-based script (simple)export default (api) => { // Initialization code runs once const speed = 1.0; api.log("Rotator initialized!"); // Return the update function return (dt, time) => { // This runs every frame const rot = api.rotation; rot.y += speed * dt; api.rotation = rot; };};The outer function (factory) runs once when the entity enters Play Mode. The inner function runs every frame. You can also return an object with lifecycle methods:
// Function-based script with lifecycle hooksexport default (api) => { const speed = 1.0; let isPaused = false; return { onStart: (time) => { api.log("Starting at time: " + time); }, onUpdate: (dt, time) => { if (!isPaused) { const rot = api.rotation; rot.y += speed * dt; api.rotation = rot; } }, onCollisionEnter: (otherEid) => { isPaused = true; api.log("Collision with entity " + otherEid); }, onCollisionExit: (otherEid) => { isPaused = false; }, onDestroy: (time) => { api.log("Destroyed at time: " + time); } };};Factory Pattern
The factory pattern allows you to create local state (closures) that persist between frames. Variables declared in the outer function are private to your script instance.
Class-Based Scripts#
For complex behaviors, extend ScriptComponent which provides a structured class-based pattern with explicit lifecycle methods.
export default class PlayerController extends ScriptComponent { speed = 5.0; jumpForce = 10.0; // Called immediately when entity becomes active onAwake(time: number) { this.api.log("Player awakened!"); } // Called once after all entities have awakened onStart(time: number) { this.api.log("Player ready!"); } // Called every frame onUpdate(dt: number, time: number) { const { move, jumpDown, sprint } = this.api.input; const currentSpeed = sprint ? this.speed * 2 : this.speed; // Movement if (move.x !== 0 || move.y !== 0) { this.api.controller.move({ x: move.x * currentSpeed * dt, y: 0, z: move.y * currentSpeed * dt }); } // Jumping if (jumpDown && this.api.controller.isGrounded) { this.api.applyImpulse({ x: 0, y: this.jumpForce, z: 0 }); } } // Physics collision event onCollisionEnter(otherEid: number) { this.api.log("Hit entity " + otherEid); } // Called when script is destroyed onDestroy(time: number) { this.api.log("Player destroyed"); }}The ScriptComponent base class providesthis.api and this.eidautomatically. All lifecycle methods are optional.
Lifecycle Methods#
Scripts support several lifecycle hooks that fire at specific times during the entity's lifetime:
onAwake(time)— Called immediately when entity becomes active, before any other logic. Use for critical initialization.onStart(time)— Called once on the first frame after onAwake. Safe to reference other entities that have awakened.onUpdate(dt, time)— Called every frame.dtis delta time in seconds,timeis total elapsed time.onDestroy(time)— Called when the entity is destroyed or script is removed. Use for cleanup.onTriggerEnter(otherEid)— Called when a trigger sensor overlap begins. Requires Sensor component.onTriggerExit(otherEid)— Called when a trigger sensor overlap ends.onCollisionEnter(otherEid)— Called when physics collision begins. Requires RigidBody component.onCollisionExit(otherEid)— Called when physics collision ends.onAnimationEvent(clipName, eventName)— Called when an animation event fires.
Lifecycle Order
The execution order is: onAwake → onStart → onUpdate (every frame) → onDestroy. Physics and animation events can fire at any time after onStart.
The Script API#
Scripts receive an api object that provides access to the entity and game systems:
// Entity IDapi.eid // number // Transformapi.position // Vector3 (read/write)api.rotation // Euler (read/write)api.lookAt(pos) // Face a point // Physicsapi.velocity // Vector3 (read/write)api.applyImpulse(force)api.raycast(origin, direction) // Character Controllerapi.controller.move(velocity)api.controller.isGrounded // Inputapi.input.move // Vector2 (WASD)api.input.look // Vector2 (mouse delta)api.input.jumpDownapi.input.sprint // Audioapi.audio.play()api.audio.stop() // Animationapi.animation.play("Walk")api.animation.crossFade("Run", 0.3) // Spawningawait api.spawn("Bullet", position, rotation)await api.loadScene("/scenes/level2.json") // Debugapi.log("Message")Pooled Objects
api.position and similar properties return pooled objects. Don't store references between frames — copy values if needed.
Attaching Scripts#
To attach a script to an entity:
- Select an entity in the Hierarchy panel.
- In the Inspector, click Add Component.
- Select Script from the list.
- Choose or create a script file.
Scripts are stored in your project's scripts/folder and can be reused across multiple entities.
Common Patterns#
State Machines#
export default (api) => { let state = "idle"; const states = { idle: (dt) => { if (api.input.move.length() > 0.1) { state = "walking"; api.animation.crossFade("Walk", 0.2); } }, walking: (dt) => { // Movement logic... if (api.input.move.length() < 0.1) { state = "idle"; api.animation.crossFade("Idle", 0.2); } } }; return (dt) => states[state](dt);};Timers & Cooldowns#
export default (api) => { let fireCooldown = 0; const fireRate = 0.25; // 4 shots per second return (dt) => { fireCooldown -= dt; if (api.input.fire && fireCooldown <= 0) { fireCooldown = fireRate; api.spawn("Bullet", api.position, api.rotation); api.audio.play(); } };};