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, gestures, typed text, gamepads, liveinput.watchKeys / watchJump (read input directly instead)
input.setMoveAxis / pressJump / pressInteract / setSprint — your own player's controlsthe same calls naming another player
input.setLookMode, input.setCommandKeys, tools.setHotbarBinds — this player's controls and keysinput.setGesturePrefs (read getGestures / getPinch instead)
camera — type, position, FOV, zoom, orbit distance, shake, getEntityplayers mutations — damage, heal, teleport, score, speed
cursor — lock, unlock, visibility, an image (setImage)blocks.set / fill / fillSphere / setRegion / clearRegion / setLightRegion / setBiomeRegion / setBiomeColumns / setBiomeColors on the room's grids
every blocks write on a grid that is each player's own — this player's copy, on this machine only
ui — elements, handled locally on click, found with game.findEntitydata, economy — the persistent stores
audio.play, particles, local spawns and writestools mutations — give, remove, equip
animation — clips and animator parameters, on this screengame.damageEntity / healEntity / getEntityHealth and the health component — entity health stays in the room
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, getComponent('navMeshAgent') and navMeshAgent component writes — NPC steering; room.log
physics.* and getComponent('rigidBody') on your OWN character's body (a physics-body player template)any other body's rigidBody (its handle and component writes), and any body's type (bodyType, isKinematic, enabled, adding or removing a rigidBody, or adding, removing or writing null to a characterController, boundaryBox or waterZone on an entity with a rigidBody or an NPC's navMeshAgent)
characterController.move / isGrounded on your OWN character (a player template with a characterController)
players.getSpeed / getJumpForce / isFlying — your own playerplayers.isFlying for another player — flight is not sent to others
game.getActiveSceneIndex / getSceneCount / getSceneNameAt, time.fixedDeltaTime
events.toServer, events.invokeServerplayers.setProperty, room.setProperty (reads are fine)
scene.setEnvironment — sky, fog, lighting, shadows, for this player onlyscene.loadScene, events.toClient
debug.drawLine — in debug mode
game.createTexture — a texture filled with pixels, on this screen only (custom shaders)game.createTexture in a server script (a room's server draws nothing)
game.saveFile / openFile — a .json or .txt file this player saves, or one they choose to open, from their clickgame.saveFile / openFile in a server script return false (a room's server has no player's browser)

Two more calls throw in a client script, and a server script cannot do them in a room either: characterController on any entity but your own character, and game.setTimeScale / time.timeScale =. A room runs no script character controller except each player's own, and every player in it shares one clock, so both work only in the editor play-test and standalone builds, from a server script.

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.

Your character's state on other screens ​

Everyone else sees your character from the pose your machine reports: where it is, which way it faces, its animation clip. A custom character often has more to show: a slime's squash, a lean, a charge glow, an emote. The character decides what that is. Write it in onSerialize on your machine, and read it in onDeserialize on everyone else's.

js
// Run Context: Client — attach to the character's visual, the part other players see.
class SlimeSync extends Behaviour {
  // On your machine, 20 times a second after onUpdate: what others need to draw you.
  onSerialize(stream) {
    stream.write('scale', this.entity.scale)
  }

  // On everyone else's machine, when your state changes.
  onDeserialize(player, stream) {
    const body = this.game.getEntity(player.entityId)
    const scale = stream.read('scale')
    if (body && scale) body.scale = scale
  }
}

Every machine runs this one script on its own character. It writes that character's squash and draws everyone else's.

Writing:

  • onSerialize(stream) runs in client scripts and the player template's scripts on the machine that simulates the player, 20 times a second, after onUpdate. stream.write(key, value) works only while it runs.
  • A key keeps its value until written again, and null removes it. Only changes are sent, so a value at rest costs nothing. Round a value that drifts (Math.round(s * 100) / 100) so that it can rest.
  • A value is a finite number, a string of up to 64 characters, a boolean or an array of up to 4 numbers (a position, a color). A player's state holds up to 16 keys and 512 bytes. A write that breaks a rule throws, in the play-test as in a room.

Reading:

  • onDeserialize(player, stream) fires before the next onUpdate when that player's state changed, once however many changes arrived, and once for every player who already has state when your scripts start, so a player who joins late sees everyone as they are. Never for your own player.
  • stream.read(key) answers the value or undefined, stream.has(key) whether it is set, stream.keys() every key.
  • players.getState(player, key) reads the latest value at any time, in any script: your own as your scripts last wrote it, anyone else's as last received. A server script reads the room's copy.

On a player-template map, player.entityId in a client script is that player's character as drawn on your screen. Its scale, material and visibility are yours to set; its position and rotation follow its owner. The player template's own scripts hear onDeserialize too, but their player carries no entityId: dress other characters from a client script.

The state comes from the player's own machine, and a modified client can write anything within the limits. Use it for how a character looks. Anything that decides the game (damage, score, cooldowns) belongs to a server script, which a client script asks with events.toServer.

A map switch clears every player's state; the new map's scripts write their own. In the play-test there is nobody else: onSerialize runs and players.getState answers your own values. To see onDeserialize, use Test Online with a second player.

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.
  • Tags and layers work the same way: a server script's entity.tag = … or entity.layer = … reaches every player, and a client script's stays on that player's machine until a server script changes the same entity's tag or layer. A layer change does not move collisions or raycasts: see Changing a layer while the game runs.
  • 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.
  • Your own character on a player-template map runs on your machine, and the room shows everyone the pose it reports. A client script's write to it is the character's: turning it, or moving a character that has no characterController, is drawn at once and reported like any move. Hiding one of its parts changes your screen only. A character with a characterController or a physics body keeps the position its simulation gives it; move those with characterController.move or physics.*.
  • Another player's character on a player-template map is drawn from their reports: its position and rotation follow them every frame. Its scale, material and visibility are yours to set, which is where their state is shown.

The same last-write rule covers the other settings a client script can make for its own player. A server script's later change to the same thing applies over it: its command keys or hotbar binds, a clip it plays on the same entity, its look mode. A client script's own controls (setMoveAxis, setSprint) are let go when its scripts are torn down, on a reconnect or a map switch, and start again from its onStart.

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
onSerialize(stream)20 times a second after onUpdate: write this player's state for everyone else
onDeserialize(player, stream)another player's state changed

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.