Appearance
Scripting basics
Scripts attach to entities and run on the authoritative server. Each script is a class that extends Behaviour (a MonoBehaviour-style component): instance fields hold per-entity state, and you override lifecycle methods (onStart, onUpdate, …). Inside a method, this.entity, this.game, this.players, this.math, … give you the engine API.
The Script Editor autocompletes the whole API as you type, and the Script API reference lists every global, method, and hook.
Your first script
In the Project panel choose New → Script and you get a class to fill in:
js
class Spinner extends Behaviour {
speed = 90 // public field — editable per-entity in the Inspector
onUpdate(dt) {
this.entity.rotate([0, this.speed * dt, 0]) // speed° per second
}
}Attach it to an entity (add a Scripts component pointing at this script) and press Play. Each entity that uses the script gets its own instance — its own speed, its own state.
Lifecycle methods
Override the ones you need:
onAwake()— once, before anyonStart(initialize internal state here)onStart()— once, after every entity'sonAwake(safe to reference other scripts viagetScript)onUpdate(dt)— once per rendered frame;dtvaries with the framerateonFixedUpdate(dt)— once per physics step (20 Hz), just before it runs;dtis alwaysTime.fixedDeltaTimeonCollision(other)— when this entity's rigid body contacts another (needs colliders; at least one dynamic)onPlayerEnter(player)/onPlayerLeave(player)— trigger zonesonPlayerRespawn(player)— fires on every script when a player dies + respawns; reset NPCs/obstacles to their start hereonPlayerKeyDown(player, key)/onPlayerKeyUp(player, key)— a key you asked for withinput.watchKeys, with the player who pressed itonPlayerJump(player)— a player jumped, after you ask for it withinput.watchJump
See the full list of hooks.
Update or FixedUpdate?
The split exists for a reason:
onUpdate(dt) | onFixedUpdate(dt) | |
|---|---|---|
| runs | once per rendered frame | once per physics step (20 Hz) |
dt | varies with the framerate | always Time.fixedDeltaTime |
| use it for | cameras, input, UI, animation | forces, impulses, velocity changes |
Rendering and physics run at different rates: your display might be 60 Hz or 144 Hz, but physics always steps at 20 Hz. Forces you apply pile up until the next step consumes them — so a force applied from onUpdate is multiplied by however many frames landed in that step (about 3× at 60 fps, 7× at 144 fps) and behaves differently on every machine. onFixedUpdate runs exactly once per step, so what you ask for is what gets simulated, identically in the editor, a standalone build and on the multiplayer server.
js
class Hover extends Behaviour {
lift = 900
onFixedUpdate() { // physics — one call, one step
physics.applyForce(this.entity.id, [0, this.lift, 0])
}
onUpdate() { // visuals — as smooth as the display
this.camera.lookAt(this.entity.position)
}
}If you write in the C# style, Update() and FixedUpdate() map to these respectively.
TIP
A rule of thumb: if you're calling anything on physics, it belongs in onFixedUpdate. The reverse holds for edge input: input.getKeyDown / getKeyUp report a single frame's press or release, and a fixed step doesn't run once per frame — a fast frame may run no step (the press is missed) and a slow frame may run two (it's seen twice). Read held state (input.getKey) in onFixedUpdate; handle presses in onUpdate. The same caveat applies anywhere with a fixed step.
Reading input
There are two ways to read a key, and which one you want depends on whose keyboard you mean.
input.getKey('w') and friends read the keyboard of the machine the script is running on. That is exactly right for a single-player game, the editor play-test and a standalone build:
js
class Torch extends Behaviour {
onUpdate() {
if (input.getKeyDown('t')) this.toggle()
}
}In a published multiplayer game the script runs on the room's server, which has no keyboard — so those reads are always false there, and a mechanic built on them stops working the moment you publish. For a key that has to work in a room, name the keys you want and read them as hooks:
js
class Torch extends Behaviour {
onStart() {
this.input.watchKeys(['t']) // the keys this map cares about
}
onPlayerKeyDown(player, key) {
if (key === 't') this.toggle(player.id) // and WHO pressed it
}
}This second form works everywhere. In single-player it fires for the one player, so there is no reason not to reach for it by default if your game might ever be multiplayer.
input.getKey / getKeyDown | watchKeys + onPlayerKeyDown | |
|---|---|---|
| Reads | the local keyboard | any player's keyboard |
| Single-player, play-test, standalone | works | works, for the local player |
| Published multiplayer | always false | works, per player |
| Tells you who pressed | there is only one player | player argument |
Key names are the same either way: 'w', 'space', 'shift', 'arrowup', '1'. Movement keys need no watching — WASD, jump, sprint and E already drive the player. Only keys you ask for are reported, so a room carries your controls rather than everything a player types.
When a player jumps
Jumping has its own hook, because a jump and the jump button are not the same event:
js
class JumpSound extends Behaviour {
onStart() {
this.input.watchJump()
}
onPlayerJump(player) {
this.audio.playAt('woosh', player.position)
}
}onPlayerJump fires on the jump itself — the player was on the ground and has just left it. A press while already airborne, while swimming, or still held from the last jump produces no jump and does not fire. A jump from the on-screen touch button or a double tap does fire, and no key name could have described those. Same shape as the key hooks otherwise: declare it once in onStart, it fires on every scripted entity, and it carries the player who jumped.
If what you actually want is the space bar, that is watchKeys(['space']).
This covers the built-in player. A map that turns the built-in player off and drives its own characterController decides for itself what counts as a jump, so watch the key there instead.
There is more on this, including tool clicks and aiming, in Multiplayer scripting.
TIP
Read scene state in onStart, not in a field initializer — fields are set while the entity is still being wired up, so this.entity isn't available yet at that point.
Instance state
Use instance fields (this.health) for per-entity state — they persist across ticks and every entity has its own copy:
js
class Enemy extends Behaviour {
health = 100
onCollision(other) {
if (other.tag === 'Bullet') {
this.health -= 25
if (this.health <= 0) this.game.destroyEntity(this.entity.id)
}
}
}Where state should live
Three different stores, and picking the wrong one is the most common way to lose a player's progress:
| You want | Use | Lives for |
|---|---|---|
| Scratch while the game runs — cooldowns, targets, counters | an instance field: this.timer = 0 | until play stops |
| Something the player keeps — coins, level, unlocks | this.data.set(playerId, key, value) | across sessions, per player |
| Design-time data you authored in the editor | getData('MyTable') | read-only, ships with the game |
this.storage also exists and is easy to mistake for a save file. It is not one. It is scratch memory for a single script instance, held in RAM, gone the moment play stops, and invisible to every other script — the same lifetime as this.timer above, just kept out of the Inspector's public-field list. Reach for a plain field instead; it reads better and does the same thing.
Saves go through data, and every method takes a player id first, because saved data belongs to a player. The calls are synchronous, and get returns undefined for a key the player has never had — so supply your own fallback:
js
class Coins extends Behaviour {
onStart() {
const me = this.players.getLocal()
if (!me) return
const coins = this.data.get(me.id, 'coins')
this._label = this.ui.createLabel({ text: `Coins: ${coins != null ? coins : 0}` })
}
collect(player, amount) {
const total = this.data.increment(player.id, 'coins', amount) // survives a reload
this._label.set({ text: `Coins: ${total}` })
}
}players.getLocal() returns null on the authoritative multiplayer server, which is why the example bails out early. In a script that must run server-side, get the id from players.getAll() or from whichever hook handed you the player instead.
There is no writable store that is both global and persistent. If several players need to share saved state — a world record, a shared unlock — write it under one well-known id that every script agrees on:
js
const WORLD = 'world'
this.data.set(WORLD, 'bestTime', seconds)What scripts can't reach
Scripts run in a sandbox, so the browser's globals are not there. Reaching for one gets you undefined rather than an error, which is worth knowing before you spend time on it:
| Not available | Use instead |
|---|---|
localStorage, sessionStorage, indexedDB | data for saving, as above |
fetch, XMLHttpRequest, WebSocket | events to talk between scripts and players |
window, document, globalThis, location, navigator | the script APIs — ui for interface, input for keys, scene for the world |
eval | write the code |
This is deliberate, and it is about where your script ends up rather than about trust in you. A script ships inside the map, so it runs on other people's machines when they play your game, and on the server in multiplayer. Scripts the AI writes for you run the same way. Nothing in a game script legitimately needs to read a page's cookies or open a socket, so that whole surface is closed and the API is the way through.
getData('TableName') covers the other common reason people reach for storage: design-time data you authored in the editor and want to read at runtime. It ships with the game and is read-only.
Inspector fields
Public fields with a simple default — a number, string, boolean, or [x, y, z] vector — appear in the Inspector under the script, so you can tweak them per entity without editing code. Each entity keeps its own values; the code default is used until you override it.
js
class Patrol extends Behaviour {
speed = 2 // number input
loop = true // checkbox
home = [0, 0, 0] // x / y / z inputs
}Fields whose name starts with _ stay private (not shown in the Inspector).
Field attributes
Annotate fields with comments to control how they appear:
js
class Patrol extends Behaviour {
// [Header("Movement")]
// [Range(0, 10)]
speed = 2 // becomes a slider 0–10
// [Tooltip("Loops back to the start")]
loop = true
}// [Header("…")]adds a section label above the field.// [Range(min, max)]turns a number field into a slider.// [Tooltip("…")](or a trailing// comment) shows hover help.
Entity references
Mark a field // [Entity] with a null default to assign another entity to it from a dropdown in the Inspector — like dragging a GameObject into a slot:
js
class Turret extends Behaviour {
// [Entity]
target = null
onUpdate() {
if (!this.target) return
const t = this.game.getEntity(this.target) // live handle — read AND write
if (t) t.position = [t.position[0], t.position[1] + 0.1, t.position[2]]
this.getScript(this.target, 'Health')?.damage(1) // or call its scripts
}
}this.target holds the chosen entity's id. Turn it into a live handle with this.game.getEntity(this.target) (read and write its transform/components), use this.getScript(this.target, '…') to call its scripts, or this.game.findEntityById(this.target) for a read-only snapshot.
Reading & writing components
Scripts can read and edit the components on an entity — its meshRenderer, rigidBody, lookAt, navMeshAgent, and so on. These methods are available as this.* on a Behaviour, and the game version takes an entityId to reach other entities.
js
this.getComponent('meshRenderer') // read a snapshot (copy) — or null
this.setComponent('meshRenderer', { color: '#ff0000' }) // merge-patch existing fields
this.addComponent('lookAt', { target: id }) // add (or overwrite) a whole component
this.removeComponent('lookAt') // drop it (you can't remove 'transform')
this.hasComponent('rigidBody') // booleangetComponent returns a live write-back handle. Reading gives the component's current values; writing a field patches the real component and broadcasts the change, exactly as setComponent would:
js
this.getComponent('pointLight').intensity = 5 // the light actually dimsFields that are baked into the physics body when it's built — collider.isTrigger, rigidBody.mass, rigidBody.gravityScale — throw rather than accept a write that couldn't take effect. The error names the field and what to do instead. rigidBody also carries live simulation members (velocity, AddForce, friction…); see the RigidBody handle.
You can also change a component wholesale:
setComponent(type, patch)is the ergonomic editor — it merges your patch onto the component's current fields and leaves the rest alone (it creates the component if it isn't there):js// turn a light red and dim it — its other fields are untouched this.setComponent('pointLight', { color: '#ff3344', intensity: 0.5 })addComponent(type, data)sets the whole component, overwriting any existing one. Use it when you want to replace everything — including the build-time fields a live write refuses:jsthis.addComponent('rigidBody', { type: 'dynamic', mass: 2, linearDamping: 0.1 })
Edits reach the live entity immediately, so engine systems that read components — lookAt, followTarget, navMeshAgent, the renderer — pick them up the same tick. See Transform constraints for driving those from a script.
A few patterns
Move forward at a constant speed (frame-rate independent):
js
class Mover extends Behaviour {
speed = 4
onUpdate() {
// entity.forward is a direction vector; time.deltaTime keeps it frame-rate independent
this.entity.translate(this.math.mul(this.entity.forward, this.speed * this.time.deltaTime))
}
}Spawn a prefab on a timer (coroutine):
js
class CoinSpawner extends Behaviour {
onStart() { this.startCoroutine(this.loop()) }
*loop() {
while (true) {
this.game.instantiate('Coin', [this.math.randomRange(-5, 5), 1, 0])
yield wait(2)
}
}
}Chase the nearest player:
js
class Chaser extends Behaviour {
speed = 3
onUpdate(dt) {
const player = this.players.getAll()[0]
if (!player) return
const dir = this.math.normalize(this.math.sub(player.position, this.entity.position))
this.entity.translate(this.math.mul(dir, this.speed * dt))
}
}Reward a player who steps on a pad — add a Trigger component (box or sphere) to the entity plus this script; onPlayerEnter/onPlayerLeave fire when a player enters/leaves that zone:
js
class CoinPad extends Behaviour {
reward = 1
onPlayerEnter(player) {
this.economy.addCoins(player.id, this.reward)
this.ui.showMessage('+' + this.reward + ' coin!', player.id)
}
}Coroutines
For sequences over time — spawn waves, timed effects, step-by-step logic — use a coroutine: a generator method you start with this.startCoroutine(...) and pause with yield wait(seconds) or yield waitFrames(n).
js
class WaveSpawner extends Behaviour {
onStart() {
this.startCoroutine(this.spawnWaves())
}
*spawnWaves() {
for (let wave = 1; wave <= 3; wave++) {
this.ui.showMessage('Wave ' + wave)
for (let i = 0; i < wave * 3; i++) {
this.game.spawnEntity('Enemy', 'box', [i, 1, 0])
yield wait(0.5) // half a second between enemies
}
yield wait(3) // breather between waves
}
}
}Call the generator method (this.spawnWaves()) when you start it — the same shape as StartCoroutine(SpawnWaves()). startCoroutine returns an id for stopCoroutine(id), or call this.stopAllCoroutines().
Function-style scripts (still supported)
The older style still works — define top-level onStart() / onUpdate(dt) functions and use the globals directly (entity, game, …). Existing scripts keep running unchanged; new scripts default to the class style above.
js
function onUpdate(dt) {
entity.rotate([0, 90 * dt, 0])
}Everything available to scripts is in the Script API reference.
Finding something across every script
Ctrl+Shift+F (Cmd+Shift+F) opens Find in Scripts, the search sidebar in the Script Editor. It searches every code script in the project at once — useful for questions like "who else reads this.speed" or "where did I spawn that prefab" — and groups the hits under the script they came from. The Aa, ab and .* buttons switch on match case, whole word, and regular expressions.
Use the arrow keys to walk the results: each one is previewed in the code editor while the caret stays in the search box, so you can skim a whole set without losing your place. Enter or a click commits — the match is selected in the editor and focus moves there. Event Sheets are not searched, because their code is generated from the graph rather than typed.
Next steps
- Scripting your own player — turn off the built-in player and drive your own character + camera (the
camera,cursor,characterController, andinput.getMouseDeltaAPIs). - Transform constraints — make entities follow / face a target with no script (
lookAt,followTarget). - Exporting a standalone build — ship your map as an offline, single-player game.