Skip to content

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 any onStart (initialize internal state here)
  • onStart() — once, after every entity's onAwake (safe to reference other scripts via getScript)
  • onUpdate(dt) — once per rendered frame; dt varies with the framerate
  • onFixedUpdate(dt) — once per physics step (20 Hz), just before it runs; dt is always Time.fixedDeltaTime
  • onCollision(other) — when this entity's rigid body contacts another (needs colliders; at least one dynamic)
  • onPlayerEnter(player) / onPlayerLeave(player) — trigger zones
  • onPlayerRespawn(player) — fires on every script when a player dies + respawns; reset NPCs/obstacles to their start here
  • onPlayerKeyDown(player, key) / onPlayerKeyUp(player, key) — a key you asked for with input.watchKeys, with the player who pressed it
  • onPlayerJump(player) — a player jumped, after you ask for it with input.watchJump

See the full list of hooks.

Update or FixedUpdate?

The split exists for a reason:

onUpdate(dt)onFixedUpdate(dt)
runsonce per rendered frameonce per physics step (20 Hz)
dtvaries with the frameratealways Time.fixedDeltaTime
use it forcameras, input, UI, animationforces, 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 / getKeyDownwatchKeys + onPlayerKeyDown
Readsthe local keyboardany player's keyboard
Single-player, play-test, standaloneworksworks, for the local player
Published multiplayeralways falseworks, per player
Tells you who pressedthere is only one playerplayer 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 wantUseLives for
Scratch while the game runs — cooldowns, targets, countersan instance field: this.timer = 0until play stops
Something the player keeps — coins, level, unlocksthis.data.set(playerId, key, value)across sessions, per player
Design-time data you authored in the editorgetData('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 availableUse instead
localStorage, sessionStorage, indexedDBdata for saving, as above
fetch, XMLHttpRequest, WebSocketevents to talk between scripts and players
window, document, globalThis, location, navigatorthe script APIs — ui for interface, input for keys, scene for the world
evalwrite 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')             // boolean

getComponent 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 dims

Fields that are baked into the physics body when it's built — collider.isTrigger, rigidBody.mass, rigidBody.gravityScalethrow 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:

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