Skip to content

Client scripts

Every script you have written so far runs on the authoritative runtime: in a published room that is the server. A client script is the other kind — it runs on each player's own machine. It sees that player's keyboard, mouse and camera on the frame they happen, and what it does stays on that player's screen.

Mark a script client-side with the Run Context selector in the script editor header: Server (the default, every existing script) or Client. That one setting decides everything else in this guide.

Right-clicking the script in the Project panel and choosing Info tells you which it is without opening it. From the Terminal, or the CLI, the same setting is a command:

runcontext AimDownSights          # read it
runcontext AimDownSights client   # move it to the client

Set it before you test rather than after. A script written for the client but left on the server still behaves in the play-test — that is one process, so it reads your mouse and moves your camera either way — and then does nothing at all in a real multiplayer room, where the server has neither.

Why a script would want to run on the client

Some things cannot wait for a server round trip, and some things are nobody else's business:

  • Aim-down-sights: hold the right mouse button, ease the camera FOV in, release it on unequip.
  • A replacement camera: take the camera over with camera.setType('scriptable') and drive it entirely from the map.
  • Instant UI: a button or key that reacts on the frame it was pressed, not a network delay later.
  • Local effects: a muzzle flash, a screen shake, a preview ghost that only this player should see.

Before client scripts, each of these needed an engine setting or simply could not be written. Now they are ordinary map code:

js
// Run Context: Client — attach to the weapon's tool entity.
class AimDownSights extends Behaviour {
  onUpdate(dt) {
    const aiming = this.input.getMouseButton(2)
    const target = aiming ? 45 : 70
    const cur = this.camera.getFov()
    this.camera.setFov(cur + (target - cur) * Math.min(1, dt / 0.12))
  }
  onToolUnequipped(player) { this.camera.setFov(70) }
  onPlayerRespawn(player) { this.camera.setFov(70) }
}

No watchKeys, no settings, no round trip. The script reads the button and writes the camera, because both are on the machine it runs on.

This same script works on phones with nothing added: a player who turns on visible mobile controls gets an Aim button, and while it is toggled on, input.getMouseButton(2) answers true — the button is the right mouse button as far as your script can tell.

The dividing line

A client script may do anything whose effect stops at its own player's screen. Anything another player or a saved value would see belongs to the server.

Callable in a client scriptServer-only (throws in a client script)
input — keys, mouse, wheel, touch, gamepads, liveinput.watchKeys / watchJump (read input directly instead)
camera — type, position, FOV, zoom, shakeplayers mutations — damage, heal, teleport, score, speed
cursor — lock, unlock, visibilityblocks.set / blocks.fill
ui — elements, handled locally on clickdata, economy — the persistent stores
audio.play, particles, local spawns and writestools mutations — give, remove, equip
entity reads, blocks reads, own-backpack tools readsphysics on any body this machine does not simulate; nav queries
physics.raycast / raycastAll — the world as this player sees itvehicles; agent — NPC steering; room.log
physics.* on your OWN character's body (a physics-body player template)players.setProperty, room.setProperty (reads are fine)
events.toServer, events.invokeServerscene.loadScene, events.toClient

A note on the two ray queries: a client script casts against the world as its own player currently sees it — the rendered scene, snapshots and all. That is exactly what a crosshair or hover check wants. Anything that must be fair to everyone — a hit that deals damage — is cast again on the server, from a server script, when the request arrives.

The failure is loud on purpose. Calling a server-only API from a client script throws an error that names the API and says what to do instead — in the editor play-test as well as in a published room, so you find out while authoring, not after publishing.

Talking across the boundary

A client script requests; a server script decides. The pair of calls:

js
// Client script — ask.
this.events.toServer('buy', { itemId: 'rocket' })

// Server script — decide, then answer.
onClientEvent(player, key, data) {
  if (key !== 'buy') return
  if (!this.economy.canAfford(player.id, prices[data.itemId])) {
    this.events.toClient(player, 'denied', { reason: 'coins' })
    return
  }
  this.economy.removeCoins(player.id, prices[data.itemId])
  this.tools.give(player.id, data.itemId)
  this.events.toClient(player, 'bought', { itemId: data.itemId })
}

// Client script — react.
onServerEvent(key, data) {
  if (key === 'denied') this.ui.showMessage('Not enough coins')
}

Rules that keep this safe and predictable:

  • The player in onClientEvent is stamped by the server from the sender's session. A client cannot claim to be someone else.
  • Treat data as untrusted. It came from a player's machine, which can send anything regardless of what your shipped script says. Validate before acting.
  • Delivery is next tick, in every runtime — the play-test queues locally with the same ordering a room has, so a map that works in the play-test works published.
  • Payloads must survive JSON (plain values, arrays, objects) and stay under 16KB. A payload that cannot cross throws at the send. Sends are rate-limited per player (60 per second).

Asking and waiting for the answer

events.toServer is fire-and-forget: the reply, if any, arrives later through onServerEvent, and matching a reply to the request that caused it is your bookkeeping. When the client needs the answer itself — a price check, a server-side raycast, anything request-then-result — use the awaitable pair instead:

js
// Client script — ask, and wait for the reply.
async onToolActivated(player) {
  try {
    const quote = await this.events.invokeServer('quote', { itemId: 'rocket' })
    this.ui.showMessage(`That costs ${quote.price} coins`)
  } catch (err) {
    this.ui.showMessage(err.message)
  }
}

// Server script — answer by returning a value.
onClientInvoke(player, key, data) {
  if (key !== 'quote') return          // not our key — leave it for another script
  return { price: prices[data.itemId] ?? 0 }
}

onClientInvoke is a separate hook from onClientEvent, and the difference is the shape of the conversation: an event fans out to every server script that listens, an invoke has exactly one answer. Server scripts are asked in dispatch order and the first one whose handler returns anything other than undefined answers the request — return undefined for keys that are not yours, so the right script gets its turn. Returning a Promise works: the reply is sent when it settles.

The promise always settles, and rejection is loud on purpose:

  • The handler threw, or the Promise it returned rejected — the promise rejects with that message.
  • No server script answered the key — rejected with an error that says so, in the play-test too.
  • The reply could not survive JSON or its 16KB cap — rejected with the reason.
  • No reply arrived within 5 seconds — rejected by the timeout, so a map can never hang on a lost reply. This covers a dropped connection and also a handler whose returned Promise never settles, in the play-test as well as in a room.
  • The client is not connected to a room at all — rejected immediately rather than after the wait.

So always await inside try/catch. Everything else follows the toServer rules: the request payload obeys the same JSON and size limits, shares the same per-player rate budget, player is stamped from the sender's session and data is untrusted. Delivery is next tick and the reply takes at least one more — an invoke costs a round trip, so keep toServer for anything that does not need an answer.

Local writes and the server snapshot

A client script may move entities, spawn them and destroy them — locally. Two rules govern what happens next:

  • An entity the server replicates (anything it moves or simulates) takes a client script's write until the next server snapshot overwrites it. Good for smoothing and prediction; wrong for anything meant to last.
  • An entity a client script spawns exists only on that player's machine, is never replicated, and persists until the script destroys it. That is what makes a muzzle flash free: nobody else pays for it, and nobody else sees it. It is visual: nothing simulates physics on a player's machine, so a local spawn moves only where a script moves it — it does not fall or collide.

Hooks in a client script

A client script keeps the ordinary lifecycle — onStart, onUpdate (once per rendered frame, at the display's rate), onFixedUpdate (at the map's physics rate, for fixed-cadence motion), onDestroy — and hears the events that concern its own player:

HookFires
onServerEvent(key, data)a server script's events.toClient aimed at this player
onToolEquipped / onToolUnequipped / onToolActivated / onToolDeactivatedthis player's tool, on this player's machine
onPlayerJoined / onPlayerLeftthe room roster changing — a local scoreboard needs no round trip
onPlayerRespawnthis player's own respawn — the moment to reset FOV and overlays
onPlayerDied(player, respawnIn)any player's death — check player.id for your own; the death-overlay moment, with the respawn countdown in seconds

Player reads follow the same replication: players.getHealth, players.getMaxHealth and players.getAvatar answer for every player in the room, not just your own — a teammate's health bar over their head is a client script and a worldToScreen call.

World-contact hooks — onCollisionEnter, onPlayerEnter, onInteract, onPlayerKeyDown and friends — are resolved by the server and delivered to server scripts only. A client script that defines one gets a warning at compile time, in the editor, because it would never fire anywhere.

The trigger hooks are the exception. onTriggerEnter, onTriggerStay and onTriggerExit fire on a client script when the LOCAL player overlaps the entity's trigger collider — the other ref is that player, tagged Player. A trigger is a volume and an overlap, not a contact the server has to arbitrate, so each machine can answer it about its own player. That is exactly the shape a per-player pickup wants: spawn a coin on each player's machine, give it a trigger collider and a client script, and one player taking theirs takes nothing from anyone else. onPlayerEnter stays server-side even so: its name promises any player, and a client only ever sees itself arrive.

The source of a client script ships

A published map strips gameplay script source so players cannot read it. A client script is the one exception, by definition: the player's machine runs it, so the player's machine receives it. That is the deal you accept when you flip the selector to Client:

  • Never put a secret in a client script — no keys, no prices you rely on, no cheat-proof logic.
  • Never trust it to enforce anything. Enforcement lives in server scripts, which stay stripped.

If a value must stay hidden, keep it server-side and let the client ask through events.toServer.