Appearance
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 clientSet 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 script | Server-only (throws in a client script) |
|---|---|
input — keys, mouse, wheel, touch, gestures, typed text, gamepads, live | input.watchKeys / watchJump (read input directly instead) |
input.setMoveAxis / pressJump / pressInteract / setSprint — your own player's controls | the same calls naming another player |
input.setLookMode, input.setCommandKeys, tools.setHotbarBinds — this player's controls and keys | input.setGesturePrefs (read getGestures / getPinch instead) |
camera — type, position, FOV, zoom, orbit distance, shake, getEntity | players 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.findEntity | data, economy — the persistent stores |
audio.play, particles, local spawns and writes | tools mutations — give, remove, equip |
animation — clips and animator parameters, on this screen | game.damageEntity / healEntity / getEntityHealth and the health component — entity health stays in the room |
entity reads, blocks reads, own-backpack tools reads | physics on any body this machine does not simulate; nav queries |
physics.raycast / raycastAll — the world as this player sees it | vehicles; 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 player | players.isFlying for another player — flight is not sent to others |
game.getActiveSceneIndex / getSceneCount / getSceneNameAt, time.fixedDeltaTime | |
events.toServer, events.invokeServer | players.setProperty, room.setProperty (reads are fine) |
scene.setEnvironment — sky, fog, lighting, shadows, for this player only | scene.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 click | game.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
playerinonClientEventis stamped by the server from the sender's session. A client cannot claim to be someone else. - Treat
dataas 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, afteronUpdate.stream.write(key, value)works only while it runs.- A key keeps its value until written again, and
nullremoves 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 nextonUpdatewhen 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 orundefined,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 = …orentity.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.moveorphysics.*. - 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:
| Hook | Fires |
|---|---|
onServerEvent(key, data) | a server script's events.toClient aimed at this player |
onToolEquipped / onToolUnequipped / onToolActivated / onToolDeactivated | this player's tool, on this player's machine |
onPlayerJoined / onPlayerLeft | the room roster changing — a local scoreboard needs no round trip |
onPlayerRespawn | this 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.