Skip to content

Multiplayer scripting

A published game runs as rooms: one server simulating the world, many players connected to it. Your scripts run on that server, and every connected player is a player object your code can act on.

The same code also runs in the editor play-test and in a standalone build — there it simply has one player, handled by the same hooks. Write the script once, for many players, and it works in all three places. This guide covers what that means in practice: the hooks that hand you players, the MapSetup pattern for configuring each one, and the handful of things that genuinely differ from single-player.

How many players share a room is your choice: maxPlayers in Game Settings, 1 to 100, 20 by default. A room is one simulation on one server, so a large room is worth play-testing at the size you set it. It is a cap on the room rather than on your game — set 2 and the platform opens a second room for the third player, so every room of a duel game is one match. See Game & Scene Settings.

A script can move that cap while the room runs: players.setMaxPlayers(n) reseats this room, and players.getMaxPlayers() reads what it seats now. The matchmaker and the server browser pick the new number up within seconds. Lowering it below the head count removes nobody; the room only stops admitting until enough players leave. It is per room, reset when the room switches map, and never saved, so a fresh room starts from Game Settings again.

js
class Lobby extends Behaviour {
  onStart() { this.players.setMaxPlayers(4) }        // a four-player lobby
  onClientEvent(player, key) {
    if (key === 'matchStarted') this.players.setMaxPlayers(this.players.getAll().length) // close the door
  }
}

Where your code runs

Where the game runsWhere scripts runHow many players
Editor play-testin the editor, locally1 (you)
Standalone buildin the player's browser1
Published game (rooms)on the room's servereveryone in the room

Because scripts live on the server in a room, changing the world through the API is all it takes for every player to see it — move an entity, damage a player, edit a block, play a sound, and the engine replicates it. You never send network messages yourself.

The golden rule: never assume "the" player

Single-player code can get away with a habit multiplayer punishes:

js
// Works in the play-test. In a room this is null — the server has no single "local" player.
const me = this.players.getLocal()

players.getLocal() answers on client runtimes (play-test, standalone) and returns null on the server. Multiplayer code receives its players instead, from hooks that carry one:

HookFires whenUse it for
onPlayerJoined(player)a player joins the roomper-map setup: tools, camera, speed, permissions
onPlayerLeft(player)a player leaves (data still readable)saving scores, dropping their items, cleanup
onInteract(player)a player presses E inside this entity's trigger zonedoors, shops, switches
onToolActivated(player)a player left-clicks with this tool equippedweapons, pickaxes, wands
onPlayerEnter(player) / onPlayerLeave(player)a player enters / exits this trigger zonecheckpoints, zones, safe rooms
onPlayerRespawn(player)a player dies and respawnsresetting obstacles, spawn protection

And when no hook applies, act on everyone:

js
for (const p of this.players.getAll()) {
  this.players.heal(p.id, 10)
}

onPlayerJoined fires once for the local player in the play-test too (right after every onStart), so a map configured this way behaves identically before it is ever published.

MapSetup: configure each player for this map

The standard pattern is one controller script, attached to any entity, that prepares every arriving player. Everything in it targets one player by id, so twenty players get twenty independent setups:

js
class MapSetup extends Behaviour {
  onPlayerJoined(player) {
    // Camera: lock this map to first person. (0, 0) pins the camera to the eyes —
    // scroll and pinch cannot pull it back out. Use (4, 12) for a bounded third person,
    // or skip the call entirely for the free default (0, 40).
    this.camera.forPlayer(player.id).setZoomRange(0, 0)

    // Starter items. tools.give takes the tool ENTITY's id — resolve it from the
    // entity's name in the Hierarchy. Tools fill the hotbar in the order you hand
    // them out; pass a slot to place one exactly (see "Hotbar slots" below).
    const pickaxe = this.game.findEntity('Pickaxe')
    if (pickaxe) this.tools.give(player.id, pickaxe.id)

    // Movement tuning, per player.
    this.players.setSpeed(player.id, 8)
    this.players.setMaxHealth(player.id, 150)

    // Your own per-player state — permissions, team, progress. Lives with the player.
    this.data.set(player.id, 'team', 'red')
    this.data.set(player.id, 'canBuild', true)

    // Announce it on the room's log (the backtick console — see Room logs below).
    this.room.log(player.username, 'joined —', this.players.getAll().length, 'in room')

    // A private HUD greeting only this player sees. Elements are removed through the
    // handle createLabel returns, so keep it in the closure.
    const hello = this.ui.createLabel({ playerId: player.id,
      text: 'Welcome, ' + player.username, position: [0.5, 0.1], fontSize: 22 })
    this.timer.delay(4, () => hello.remove())
  }

  onPlayerLeft(player) {
    // Their position and data are still readable here — last chance to record anything.
    const finalScore = this.players.getScore(player.id)
    this.data.set('match', 'lastLeaverScore', finalScore)
  }
}

What makes this multiplayer-correct:

  • Every call takes player.id. There is no ambient "current player" to reach for.
  • camera.forPlayer(id) writes one player's camera. A bare camera.setType(...) is a statement about the whole room — every player gets it. Both are useful; know which one you mean.
  • ui.createLabel({ playerId: id }) renders on one player's screen. Without playerId it is a shared HUD everyone sees.
  • The hook fires after the player has a spawn position, so players.teleport and tools.give work inside it.

Reading input: the biggest difference

In a room, the keyboard, mouse and touchscreen are on the player's machine — and your script is not. input.getKey, input.getMouseDelta, input.getGestures and friends answer where the input is: they work in the play-test and standalone, and report nothing on the server.

Multiplayer input arrives as events instead. For a KEY, say which keys your map cares about and read them as per-player hooks:

js
class Abilities extends Behaviour {
  onStart() {
    this.input.watchKeys(['r', 'f'])        // this map's controls, and nothing else
  }
  onPlayerKeyDown(player, key) {
    if (key === 'r') this.reload(player.id) // WHO pressed it, not just that it was pressed
  }
  onPlayerKeyUp(player, key) {
    if (key === 'f') this.stopCharging(player.id)
  }
}

Each client reports only the keys the map asked for, so a room carries your controls and not everything a player types. Names are the ones getKey takes: 'r', 'space', 'arrowup', 'shift'. Both hooks fire on every scripted entity, so the script that cares does not have to be the one anything touched. They work in the play-test and standalone too, firing for the local player, so a key mechanic behaves the same before and after you publish.

Movement keys need no watching — WASD, jump, sprint and E already drive the player and arrive as movement, onInteract and the tool hooks. Watch one only if you also want the raw press.

Jumping is the exception, and has a hook of its own:

js
class JumpRules extends Behaviour {
  onStart() {
    this.input.watchJump()
  }
  onPlayerJump(player) {
    this.data.increment(player.id, 'jumps', 1)
  }
}

onPlayerJump reports the jump, not the button. The player was on the ground and has just left it; a press while airborne, while swimming, or still held from the last jump never became a jump and never fires. A jump from the touch button or a double tap does, which is why watchKeys(['space']) is not the same thing — that reports a key, and plenty of players never press one.

It follows the key hooks in every other respect: declared once in onStart, fired on every scripted entity, carrying the player who jumped, and working the same way in the play-test. A map that runs its own characterController player is the one case it cannot see — that script owns the jump, so watch the key there.

A map may watch up to 32 distinct keys, which is far more than any real control scheme; call watchKeys as many times as you like, from as many scripts as you like, and the requests add up.

Keys do not fire while a player is typing — in chat, or in any text field your UI puts on screen. A key already held when they start typing reports its release, so hold state never gets stuck down because somebody stopped to talk.

Two things to design around. A key held when a player disconnects never reports its release, so undo hold state in onPlayerLeft as well as onPlayerKeyUp. And a press is a request, not a result: the room decides what it means, which is what keeps the game fair.

One exception to all of this: a characterController player template runs on each player's own machine rather than the server, so input.getKey does work inside it. See Scripting your own player.

The click of an equipped tool is the other workhorse:

js
// Attach to the pickaxe tool entity (the one MapSetup gives out). The entity needs a
// tool component; its script receives the tool hooks.
class Pickaxe extends Behaviour {
  onToolActivated(player) {
    // Where is this player looking? The click carries their aim; the server knows their eyes.
    const hit = this.blocks.hitFromPlayer(player.id, 6)   // reach: 6 units
    if (hit) this.blocks.set(hit.entityId, ...hit.cell, null)   // mine it
  }
}

A placement tool is the same shape with hit.place instead of hit.cell. A weapon needs a ray of its own — see below.

Blocks placed on a player

A room places a block on the tick the click arrives, and by then everyone has moved on their own machine. So a player can end up inside the cell that was empty when they last saw it — and inside solid blocks there is nothing to push them out of. They are stuck there for good.

The engine handles it: a block placed while the game runs stays soft for half a second, and a player found inside one during that window breaks it instead of being trapped by it. The break replicates like any other edit. Standing on the block you just placed under your feet, or pressing against one you put in front of you, is not being inside it and never breaks anything.

The window is per build, on the Block Grid component beside Players Can Edit: Anti-Stuck (s). Raise it for a game whose players are far away, set it to 0 for a map that means to bury people.

Refusing the placement instead is still yours to write — the engine never refuses one, so a builder that should not drop blocks on people checks the cell against the players it can see:

js
onToolActivated(player) {
  const hit = this.blocks.hitFromPlayer(player.id, 6)
  if (!hit?.place) return
  const [x, y, z] = hit.place
  for (const p of this.players.getAll()) {
    const [px, py, pz] = p.position
    if (Math.abs(px - x - 0.5) < 0.8 && Math.abs(pz - z - 0.5) < 0.8 && Math.abs(py - y - 0.5) < 1.5) return
  }
  this.blocks.set(hit.entityId, x, y, z, 'stone')
}

That check and the window do different jobs: yours stops the placements you can see coming, and the window catches the ones that were already in flight.

Driving the player the other way — script to character — works everywhere: input.setMoveAxis, input.pressJump, players.launch, players.setInputEnabled are all replicated. A cutscene that freezes players, or a bounce pad that launches them, is the same code in every runtime.

Hotbar slots

Tools fill the hotbar in the order you give them: first call to key 1, next to key 2, and so on. That is the default and it needs nothing from you.

Pass a slot when the layout is part of the design — weapons together, building tools somewhere else, and a deliberate space between the two so nobody reaches for a shovel mid-fight:

js
onPlayerJoined(player) {
  this.tools.give(player.id, rifle.id)          // key 1
  this.tools.give(player.id, shotgun.id)        // key 2
  this.tools.give(player.id, pickaxe.id, 6)     // key 7 — keys 5 and 6 stay empty
  this.tools.give(player.id, builder.id, 7)     // key 8
}

Slots are 0-based, so slot 6 is the key marked 7 and the last usable slot is 8. A slot that is already taken is refused rather than shuffled — two tools asking for one slot is a bug worth seeing, not one to paper over. Once a gap exists, a later give with no slot appends past everything rather than backfilling: the space you asked for stays yours.

The hotbar is nine slots, matching the number keys, and a map can ask for a tenth with gameSettings.hotbarSlots: 10 — that one is reached by the 0 key, because after 9 on a keyboard comes 0, not 10. A tool placed past your map's own count is still given, and a script can still equip it, but nobody can see it or press a key for it — the room logs a warning saying so, because asking for "slot 9" to mean the ninth key is the easy mistake here.

give hands back the instance id it created, which is the id remove and equip take:

js
const id = this.tools.give(player.id, rifle.id, 2)
this.tools.equip(player.id, id)                 // by the TOOL, not by counting keys

Equipping by instance id is worth reaching for whenever the backpack can change under you: a remove that closes the gap renumbers everything after it, so a slot you wrote down earlier may now hold something else, while the instance id still finds the tool it named.

Removal has the matching switch. By default the tools after the removed one close up, which is what a backpack normally does. Pass keepGap when the layout matters more:

js
this.tools.remove(player.id, instanceId)        // the rest close up
this.tools.remove(player.id, instanceId, true)  // the slot empties, nothing else moves

For a fixed loadout the second is usually what you want: losing one weapon mid-round should not slide the other three under different keys while the player is being shot at.

Pulling the trigger from a script

tools.activate(playerId) fires the equipped tool exactly as a click does, and tools.deactivate lets go again. Useful for a turret a player mounts, a scripted tutorial, or an NPC holding a real tool.

It obeys the same three rules a click does — an empty hand, a dead player, and a tool whose Can Activate is off all do nothing — so a script cannot fire a tool the player could not. It does NOT hold the trigger down for you: a held-fire weapon watches for the release, so pair the two.

js
this.tools.activate(player.id)
this.wait(0.5, () => this.tools.deactivate(player.id))   // a half-second burst

Dropping tools

Turn on Can Be Dropped on a tool and a player can throw it on the ground with Backspace. It lands a stride in front of them, anyone who walks over it picks it up, and it disappears after a few minutes if nobody does. tools.drop(playerId) does the same thing from a script, and takes an instance id when the tool to drop is not the one in hand.

The switch is OFF by default, which is the one place this deliberately differs from what you may expect: turning it on for everything would hand every player in every existing game the ability to scatter their loadout. So dropping is something a game opts into, per tool — and that makes it the switch that decides whether your game has an economy of tools or a fixed loadout.

A dropped tool is a marker rather than a solid object: you walk through it, not into it. A pile of abandoned tools in a doorway should not become a wall you never built.

tools.equip(player.id, slot) takes the same number give does, and an empty slot equips nothing.

How a tool looks in the hand

A tool entity lives in your map like anything else — usually parked out of sight, because what matters is how it looks once somebody is holding it. Four fields on the tool component decide that.

FieldWhat it does
gripScalehow big it is in the hand
gripOffsetwhere it sits, relative to the hand [x, y, z]
gripRotationhow it is turned, euler degrees [x, y, z]
useAnimation / useAnimDurationthe swing when it is used

Size

Leave gripScale alone and the engine sizes the tool for you: whatever you built is fitted to a standard held length, so a tool authored at any size comes out looking right in a hand. For most tools that is the end of it.

Set it and you are scaling that standard size:

gripScale: 1     exactly what leaving it blank does
gripScale: 2     twice as long
gripScale: 0.5   half

So there is no cliff — open the field, type the obvious number, and nothing moves.

The one thing the automatic fit cannot do is size tools relative to each other: it looks at each tool on its own, so a dagger and a greatsword both come out a hand's length. When the difference between two tools is the point, that is what gripScale is for.

The tool entity's own scale in the scene does not affect held size. A tool entity is parked out of sight, so its size out there is arbitrary — gripScale is the control, and scaling the entity with the gizmo will not change what the player sees in their hand.

Why the default is a fit

Held size used to have no map-side control at all. A published game drew the same small placeholder for every tool, so a tool's real dimensions were invisible there and nobody had reason to author them for a hand. When the game began drawing the actual tool, every map that had never tuned one suddenly had tools several times too large. Fitting by default is what puts those maps back where they were without anyone editing them — and gripScale is the control that should have existed all along.

Position and rotation

gripOffset and gripRotation are the fine adjustment, applied relative to the hand. A gun usually wants pushing forward along Z so the grip rather than the barrel sits in the palm:

gripOffset: [0, 0, 0.3]

The Inspector shows a live preview of the held tool as you change these, and it uses the same sizing the game does — so what the preview shows is what players get, in the play-test and in a room alike.

What the engine draws on every screen

A published game draws a tool hotbar, a health bar, a death notice, and floating usernames over other players. Defaults, not requirements — each has a Game Settings switch for a map that draws its own:

SettingWhat it does
hotbarSlotshow many hotbar slots are drawn, 1-9. A map with three tools can show three slots instead of six empty boxes
hotbarBindsextra keys per slot, and a custom key-corner label — [{ "slot": 8, "keys": ["c"], "label": "C" }]. The number keys always keep working; see Game settings
hideEmptyHotbarSlotsdraw only the hotbar slots that hold a tool. Off (the default) keeps empty boxes visible — right for a loadout that grows during play
hideHotbarno hotbar. The number keys and tools.equip keep working — this hides the display, it does not take the tools away
hideHealthBarno health bar or numbers. The death notice stays, so players always know they died
hideNameplatesno floating usernames — anonymous shooters, hide-and-seek. Combines with each player's own Settings toggle: either one off means off
respawnSecondsseconds between dying and respawning. Default 3; 0 is instant. Counted the same by the room, the death notice and the play-test

All of them live in Game Settings, are project-wide, and default to today's behaviour — a map that never touches them changes nothing.

One more death-related knob lives in Scene Settings instead, because it is a fact about each map's geometry rather than the project: killY, the Y below which a player falls to the void and dies. Blank is the classic -50. A mining map with caves at -200 lowers it and nothing else changes; sub-maps each keep their own, and a very deep value effectively removes the void.

Game & Scene Settings has the full list with which runtimes each one reaches.

Aiming: where a player is pointing

A weapon, a laser sight, a "what am I looking at" prompt — all of them need a ray, and the room hands you both ends of it. Three reads, and picking the right one is most of the work:

AnswersReach for it when
players.getLookDirection(id)which way they are pointing, as a unit vectoralways — this is the direction
players.getLookOrigin(id)where that direction is cast FROMyou are deciding what a shot HITS
players.getEyePosition(id)the player's actual eyesyou are asking what they can SEE

So a hitscan weapon is:

js
class Marker extends Behaviour {
  onToolActivated(player) {
    const from = this.players.getLookOrigin(player.id)
    const dir = this.players.getLookDirection(player.id)
    if (!from || !dir) return          // they have not reported a view yet — a fresh joiner
    const hit = this.physics.raycast(from, dir, 80)
    if (hit) this.game.damageEntity(hit.entityId, 20)
  }
}

getLookOrigin is not the eyes, and the difference is the whole point of it. The crosshair sits at the centre of the screen, so what it covers is whatever the camera's centre ray meets — and in third person the camera is pitched at the player's head, above their eyes. Fire from the eyes and every shot lands below what the player was pointing at, by the same distance at every range. getLookOrigin is the point on the crosshair's own line, so a ray from it hits exactly what the crosshair covered. Keep getEyePosition for line-of-sight questions: can this player see that door, is anything between them.

The direction is LIVE — it tracks their view every packet, so a weapon that keeps firing while the trigger is held follows where they are looking rather than freezing on the aim of the click that started the burst. (blocks.hitFromPlayer deliberately still answers for the CLICK: mining acts on the block the player clicked.) Both reads are null for a player the room does not know, and briefly for one who has only just joined, so guard them rather than assuming a vector.

One thing the client is never trusted for: where the ray starts. A reported origin is clamped to within a few metres of the position the server itself holds for that player, so the worst a doctored one can do is point somebody's own ray somewhere silly.

Where players spawn

Give a map several entities with a spawn component and it has a choice to make on every join and every respawn. There is no answer that suits every game, so the map says which it wants:

js
class Match extends Behaviour {
  onStart() {
    this.players.setSpawnRule({ pick: 'random', minDistance: 20 })
  }
}

minDistance filters: prefer pads at least that far from any other player. When none qualifies — a crowded room, a small arena — it is dropped rather than enforced, and the pad with the most room around it wins. A map that asked for distance is better served by as much of it as there is than by a refusal.

pick chooses among the pads that survive the filter:

pickBehaviourSuits
'first'authored order, the same pad every time while it is freea race, a tutorial, a hub — the default
'random'spread arrivals across the padsa deathmatch, anything with ten spawn points
'farthest'always the pad emptiest of playerskeeping opponents apart above all else

The default is 'first' with no distance, so a map that never calls this behaves exactly as it always did. The rule belongs to the map that set it: it is per room, and it resets when the room switches sub-map, so an arena's rule cannot follow the room into the corridor it loads next.

Whatever the rule says, nobody is ever spawned inside another player — that guarantee is not part of it and cannot be switched off. A respawning player is not counted among the players to avoid either, so they are not pushed away from the spot they just died on by their own body.

Per-player camera, UI and sound

Each player has their own screen; the API reflects that wherever it matters:

js
// One player's camera (a kill-cam, a cutscene only they see):
this.camera.forPlayer(player.id).setType('scriptable')
this.camera.forPlayer(player.id).lookAt([0, 5, 0])

// One player's HUD:
this.ui.createBar({ playerId: player.id, position: [0.5, 0.92], value: 1 })

// Everyone hears a positioned one-shot; each player's volume follows their own distance to it:
this.audio.play('explosion', [10, 2, 30])

// One player's room console (see Room logs below):
this.room.forPlayer(player.id).log('checkpoint reached')

Camera reads (camera.getPosition() and friends) deserve one honest caveat: a room has one camera per player, so on the server these read back what a script last set, not what any particular player is seeing. Set a shot, read it back, adjust — that works. Measuring a player's live view does not.

A player's account level

players.getAccountLevel(id) reads the level shown on a player's profile — earned across the whole platform, not in your game, and read-only here.

js
onPlayerJoined(player) {
  const level = this.players.getAccountLevel(player.id)
  if (level === null) return          // a guest — plan for this, plenty of players are guests
  this.ui.createLabel({ playerId: player.id, position: [0.06, 0.06], text: 'Level ' + level })
}

It returns null more often than you might expect, and a map has to handle that:

SituationResult
A signed-in player, a moment after they jointheir level
A guestnull
The first frames of a signed-in player's sessionnull — the lookup does not hold up the join
A play-test in the editoryour own level, or null when editing signed out
A standalone buildnull — an exported build has no accounts in it

The level is all a map gets. There is no coin balance: a platform balance is real money, and reading what is in another player's is not something a map gets to do. economy.getCoins(id) is your game's own currency, and that one is yours to read and to pay out. For progress that belongs to your game, keep your own with data.set and data.increment.

Chat commands

A line that starts with / is a command. Your server scripts see every chat line first in onPlayerChat(player, text); return true to keep the line as a command, and nobody else reads it. A line no script keeps goes out as chat.

js
onPlayerChat(player, text) {
  if (!text.startsWith('/')) return false
  const [cmd] = text.slice(1).toLowerCase().split(/\s+/)
  const staff = player.role === 'admin' || player.role === 'moderator'
  if (cmd === 'home') { this.players.teleport(player.id, this.home[player.id] ?? [0, 2, 0]); return true }
  if (cmd === 'god' && staff) { this.gods.add(player.id); return true }
  if (cmd === 'help') { this.ui.showMessage('/home' + (staff ? '  /god' : '') + '  /account  /ping', player.id); return true }
  this.ui.showMessage('Unknown command. /help lists them.', player.id)
  return true
}

What the chat ref tells you, on this hook only:

FieldMeaning
player.guesttrue for a guest. A guest cannot chat, but can command: keep some commands for accounts if your game needs a name that lasts.
player.role'admin' or 'moderator' for the platform's staff, absent for everyone else. Set by the platform from the account, never by the client, so a command behind it is safe.

Two commands belong to the platform and are answered before any script sees them, in the sender's own chat box: /account (name, level and staff role) and /ping (the player's round trip). A map cannot take them over, and should list them in its /help beside its own.

To answer in the chat box yourself, or to greet a player where they will read it, ui.showChat(text, playerId) puts a line in one player's chat box (everyone's without an id). It is not chat: it has no sender, nobody else sees it and nothing is logged. ui.showMessage is the toast on screen instead.

js
onPlayerJoined(player) {
  this.ui.showChat('Welcome, ' + player.username + '. /help lists the commands.', player.id)
}

A scoreboard of everyone in the room

Two things get conflated here, and they have different answers:

What you wantWhere it comes from
Rank the players who are in the room right nowThe room already holds it. Sort what you have.
A score that outlives the room, and resets each daydata.set, with the day stored alongside it

Ranking is a sort, not a lookup

Your script already knows every active player and whatever you have been scoring them on, so a live board needs no storage and no fetch at all — just an ordering of state you are already keeping:

js
onPlayerJoined(player) {
  this._players.set(player.id, { name: player.username, best: this._bestToday(player.id) })
}
onPlayerLeft(player) { this._players.delete(player.id) }

_ranked() {
  const rows = []
  for (const [id, s] of this._players) rows.push({ id, name: s.name, best: s.best })
  rows.sort((a, b) => (b.best - a.best) || (a.name < b.name ? -1 : 1))
  return rows
}

Sort by one thing and break ties with another. Without the tie-break, two players on the same score swap places every time you redraw, which reads on screen as a board that flickers.

"Today" without a reset

Store the number WITH the day it belongs to, and read it back only if the day still matches:

js
_today() {
  const d = new Date()
  return d.getUTCFullYear() + '-' + (d.getUTCMonth() + 1) + '-' + d.getUTCDate()
}

_bestToday(playerId) {
  if (this.data.get(playerId, 'odDay') !== this._today()) return 0   // a stale day reads as zero
  const v = this.data.get(playerId, 'odBest')
  return typeof v === 'number' ? v : 0
}

_record(playerId, score) {
  if (score <= this._bestToday(playerId)) return                     // a worse run changes nothing
  this.data.set(playerId, 'odBest', score)
  this.data.set(playerId, 'odDay', this._today())
}

Nothing is ever reset. At midnight the day stamp stops matching, so yesterday's number simply stops being read — it is never rewritten, only ignored. That means there is no job to schedule at midnight, and therefore no reset that can fire late, fire twice, or fire half way through and leave a board in pieces. A player who last played on Tuesday walks in on Wednesday on nought.

Two things worth being deliberate about:

  • The clock is the SERVER's. new Date() in a server script is the room's own clock, one clock for everyone in it. Do not read the clock on a client-owned character (see Where your code runs): that is the player's own machine, in their timezone, and they can set it backwards to farm a fresh day.
  • UTC, not local. If each player rolled over at their own midnight, two of them would be ranked against different days and the board would stop meaning anything.

players.setScore is a different tool: it records a score to the platform's stored scores, but a map cannot read the ranking back out, so it is not what a board you draw yourself is built from.

Drawing it

Build the container and every row it will ever need in ONE call. A container cannot adopt a child that was created on a later tick, so a board that adds rows as players arrive ends up with rows floating outside the box:

js
_buildBoard(playerId, s) {
  s.box = this.ui.createContainer({
    playerId, position: [0.015, 0.06], anchor: [0, 0],
    layout: 'vertical', gap: 6, padding: 16, width: 320, fitContent: 'height',
  })
  s.title = this.ui.createLabel({ playerId, text: 'TODAY SCORE', fontSize: 22, fontWeight: 'bold' })
  s.box.add(s.title)
  s.rows = []
  for (let i = 0; i < this.maxRows; i++) {
    const row = this.ui.createLabel({ playerId, text: '', fontSize: 20, richText: true })
    s.box.add(row)
    row.set({ visible: false })      // hidden until there is a player to put in it
    s.rows.push(row)
  }
}

Then only ever re-text it, and only the rows that actually changed:

js
if (was.text !== text) { row.set({ text }); was.text = text }

This runs for every player in the room, several times a second, and every set is a message on every one of those players' connections. Diffing the rows is the difference between a board and a flood.

One board per player rather than one shared one, because each viewer wants their own row highlighted — and it lets you always show the viewer even when they are not in the top few:

js
if (!picked.some((r) => r.id === viewerId)) {
  const at = ranked.findIndex((r) => r.id === viewerId)
  if (at >= 0) picked[picked.length - 1] = { ...ranked[at], rank: at + 1 }   // last row becomes "you"
}

World state replicates itself

Anything you change through the API reaches every player, including ones who join later:

  • Entities — move, spawn, destroy, animate; joiners receive the current world.
  • Runtime block edits — blocks.set / blocks.fill on a grid with Players Can Edit switched on. Edits are per room: every new room starts from the authored map. See the pickaxe above.
  • Player state — health, score, avatar morphs, tools; the room owns it.
  • Audio — one-shots reach everyone near them; an entity's audioSource is state, so a late joiner hears the loop that was already playing.

The corollary: state you keep in plain script fields (this.count = 0) lives on the server and is shared by everyone — which is usually exactly what a round timer or a boss health pool wants. State that belongs to one player goes in this.data.set(player.id, key, value). State that belongs to the whole world and must outlive the room goes in this.world (next).

Shared world data

A script field dies with the room. A player's save belongs to that player. Between the two sits what a persistent world remembers about itself: whether the dragon was slain and when it may return, how many planks the community put into the bridge, which stage the shared quest is at, who holds the north tower, whether the one-of-a-kind treasure has been taken. That is this.world, a set of JSON records by key, and it lives past room replacements and server restarts.

Where the records are kept is decided by the host, never by the script. A game pinned to a world server keeps them on that server's disk beside the world's blocks, so a snapshot backs them up together and a reseed starts both fresh. Any other room keeps them on the data service, one set per map, shared by every room of that map. The editor play-test and a standalone build keep them in the browser beside the player saves, and Clear Saved Data wipes both. Per-player things, such as whether this player already collected their share of a reward, stay in this.data.

Every call is async, and every record carries a revision. A write says which revision it read, and the store refuses it when the record has moved since. That is what stops two players, or two rooms, from both claiming one shared reward. Use update and let it re-read and retry:

js
class WorldBoss extends Behaviour {
  async onBossDefeated(killer) {
    const r = await this.world.update('boss:dragon', (boss) => {
      if (boss && boss.claimed) return undefined                 // someone got there first: write nothing
      return { claimed: true, by: killer.id, respawnAt: this.game.getRoomTime() + 3600 }
    })
    if (r && r.value.by === killer.id) this.ui.showMessage('You claimed the hoard', killer.id)
    else this.ui.showMessage('The hoard was already claimed', killer.id)
  }
}

The calls:

  • load(key) answers the record as { value, revision }, or null when there is none.
  • save(key, value, revision) writes and answers the new revision. With a revision it lands only when the record is still at it (0 means only when it does not exist yet); without one it lands regardless.
  • update(key, fn) loads, calls fn(value, revision) with a copy of the value (undefined when there is none), writes what fn returns, and retries on a conflict. Returning undefined writes nothing.
  • delete(key, revision) removes the record, under the same rule.
  • keys(prefix) lists the keys, sorted, so territory: finds every territory record.

A record that does not exist and a store that could not answer are different things. load resolves null for the first and rejects for the second, with an error whose code is unavailable. Never write defaults on a rejection: a script that treats an outage as a fresh world erases real progress the moment the store is back. Keep what you have, tell the player, try again later. The other codes are invalid, a bad key or value, and conflict, when update ran out of retries or a save named a stale revision.

Keys are up to 128 printable characters without spaces; values are JSON up to 64 KB, milestones rather than dumps. Save what the world must remember first. A boss's exact position and animation is room state and comes back with the room.

Room and player properties

A plain script field is shared by everyone on the server, but no client can read it. A property is the version every client CAN read: a small value the room states about itself or about one player, replicated to every client the moment it changes, and handed to a joiner with their first frame.

js
class Match extends Behaviour {
  onStart() {
    this.room.setProperty('phase', 'warmup')     // everyone reads the same phase
    this.room.setProperty('seed', 20260823)      // today's track, the same on every client
  }
  onPlayerJoined(player) {
    // Copy what is SAVED into what is SHARED: data is persistent, a property lives with the room.
    this.players.setProperty(player.id, 'skin', this.data.get(player.id, 'skin') ?? 'classic')
    this.players.setProperty(player.id, 'score', 0)
  }
  onClientEvent(player, key, value) {
    if (key === 'equip' && this.ownsSkin(player.id, value)) {
      this.data.set(player.id, 'skin', value)                 // saved
      this.players.setProperty(player.id, 'skin', value)      // shared
    }
  }
}

Any script, on the server or on any client, reads them with room.getProperty(key) and players.getProperty(playerId, key), and hears a change in onRoomPropertyChanged(key, value) and onPlayerPropertyChanged(player, key, value). Only the server writes: a client script asks with events.toServer.

Chat completion. Put the map's commands in the room property chatCommands and the chat box completes them with Tab: a list of strings, the command name first and then one word per argument - player completes a name in the room, give|take|set completes one of those words, and anything else (<amount>) is typed. The platform's own commands complete on every map.

js
onAwake() {
  this.room.setProperty('chatCommands', ['home', 'sethome', 'tpa player', 'pay player <amount>'])
}

The client half of the skin above is three lines, because on a player-template map every player's character — yours and everyone else's — is a GameObject on your screen, reachable through player.entityId (Roblox Player.Character):

js
// client script (runContext: client) — runs on every player's machine
class SkinApplier extends Behaviour {
  onPlayerPropertyChanged(player, key, value) {
    if (key !== 'skin' || !player.entityId) return
    const ball = this.game.getEntity(player.entityId)
    ball?.setComponent('meshRenderer', { customTextureAssetId: this.game.getAssetId(value, 'texture') })
  }
}

A remote's character is a local copy of the template's visual, posed from their reports each frame: dress it, hide it, read its position — but a transform you write is overwritten on the next frame.

What they are for: a skin, a team, a score this round, a ready flag, the match phase, a countdown's end time, a seed. Values are plain JSON, at most 1 KB each and 16 KB per owner, and every change is one message to every client, so keep documents in data and share the facts about them here. A property lives as long as the room and is never saved; data is the store that outlives it.

One map, configured per server

A server can carry settings for the maps that run on it: the map section of that server's config file, which the operator commits alongside the deployment. The map reads it as a plain object and decides what the keys mean, so the same map can be a hard-mode server here, a creative server there, and a server with its own world and its own spawn somewhere else.

js
class Match extends Behaviour {
  onStart() {
    const cfg = this.room.getServerConfig()
    this.difficulty = cfg.difficulty ?? 'normal'      // every key needs a default
    this.dayLength = cfg.dayLength ?? 600
    this.spawn = Array.isArray(cfg.spawn) ? cfg.spawn : null
  }
  onPlayerJoined(player) {
    if (this.spawn) this.players.setRespawnPoint(player.id, this.spawn)
    if (this.spawn) this.players.teleport(player.id, this.spawn)
  }
}

The object is a copy, and it is {} on a client, in the editor play-test, in a standalone build, and on any server that has no section for maps. That is what makes the defaults matter: a map that reads the config with a fallback for every key runs the same everywhere it has not been configured. Read it once in onStart; it does not change while the room lives. Anything a client must know, put in a room property.

Which servers a game runs on is the operator's choice in the admin dashboard, so a map cannot tell from the config which server it is on; it only sees what that server was given.

Motion nobody has to send: game.getRoomTime()

Everything above replicates because the room says so, and saying so costs bandwidth. Continuous motion is the expensive case: a platform the server nudges each tick is a message per tick per player, forever.

There is a way to have none of that. game.getRoomTime() is the ROOM's clock in seconds — the same number on the server and on every player's machine, unlike game.getTime(), which is each runtime's own scene time and starts whenever that machine loaded the map. Anything you can express as a function of that clock is in the same place on every screen without a byte being sent for it.

The shift is from accumulating to evaluating:

js
// Accumulated — every machine drifts its own way, so the server has to keep correcting them
this.offset += this.dir * this.speed * dt
entity.position = [base[0] + this.offset, base[1], base[2]]

// Evaluated — every machine computes the same answer, and there is nothing to correct
const t = this.game.getRoomTime()
const span = 2 * this.dist, period = 2 * span
let p = (t * this.speed + this.phase) % period
if (p < 0) p += period
entity.position = [base[0] + (p < span ? p : period - p) - this.dist, base[1], base[2]]

The second one has no state between frames. Two clients that have never exchanged a packet agree, and keep agreeing an hour later, because there is no error to accumulate.

Split the work like this:

WhereCost
The motion itselfa runContext: 'client' script on every player's machinenothing, ever
The parameters (base, speed, distance, phase)the server, sent with events.toClient('all', …)once, and again when they change
Anything authoritative — scoring, damage, collectionthe server, unchangedunchanged

The server does not stop knowing where things are. It evaluates the same function when it needs an answer, which is how it still places pickups on a moving platform or finds the top of a moving tower — it simply stops WRITING transforms, and writing is what replicated.

Two things to get right:

  • The function has to be written twice, once in the client script and once in the server script, because scripts cannot import each other. If the two ever disagree, nothing throws — things quietly drift apart from where they belong. Keep them adjacent, comment each as the twin of the other, and if it matters, test them against each other.
  • A client's clock trails the server by roughly half its round trip. That is a CONSTANT offset per player, not a growing one, which is exactly why this works: about 15cm at 100ms on something moving 3 units a second. Fine for a platform. Not fine for anything where two players must agree to the centimetre — keep that authoritative.

In the editor play-test and a standalone build there is one process and no room, so getRoomTime() is scene time and matches getTime(). A map written this way behaves the same in both.

Single-player vs multiplayer, side by side

Play-test / standalonePublished room
Scripts runlocallyon the room's server
players.getLocal()the playernull — use hooks or getAll()
input.getKey / mouse / gestureslive inputnothing — input arrives via hooks
input.watchKeys + onPlayerKeyDownfires for the local playerfires per player, for watched keys only
input.watchJump + onPlayerJumpfires for the local playerfires per player, on the jump itself
onUpdate(dt)once per rendered frameonce per server tick (20 Hz) — use dt, never assume a rate
camera.setX(...)the camerarelayed to every player; camera.forPlayer(id) targets one
Camera readsthe live camerawhat a script last set (see caveat above)
players.getLookDirection / getLookOriginthe local cameraeach player's own, reported every packet
players.setSpawnRuleaccepted, does nothing (one player)decides which pad each arrival gets
players.setMaxPlayers / getMaxPlayersremembered for the read, nobody to refusereseats the room; the matchmaker follows
players.getAccountLevelyour own level while signed ineach player's own; null for guests
ui.createX(...)the screeneveryone, unless { playerId: id }
debug.logeditor Console (standalone: needs the map's debug mode)not sent to players — use room.log
room.logeditor Console, prefixed [room]every player's backtick (`) console — needs the map's Debug Mode
room.forPlayer(id).logthe same one consolethat player's console only
Block editsroll back on Stoplive for the room, gone when it closes

Two of these bite people most:

onUpdate cadence — on the server it ticks at the simulation rate, not a display's framerate. Code that multiplies by dt is correct everywhere; code that assumes sixty calls a second is not.

Input — reading the keyboard directly is what does not carry over. input.getKey answers about the machine the script runs on, which in a room is the server. A key mechanic is written with input.watchKeys and onPlayerKeyDown instead, and anything else comes through a zone, a tool, or an on-screen button (ui.createButton presses work in rooms). Either way the room decides what a press means, which is what keeps the server authoritative and the game fair.

There is a second option when the thing a key drives is purely visual and local — a camera zoom, an overlay, an aim-down-sights hold: a script whose Run Context is Client runs on each player's machine and reads that player's input directly, no watchKeys needed. See the Client scripts guide for where that line sits.

Room logs: room.log

Two log methods, two audiences. debug.log is the development diagnostic — it prints in the editor Console and in a standalone build, and is never sent to a room's players, so engine noise and single-player tracing cannot spam a live game. room.log is the multiplayer channel: it reaches every player's in-game console — press the backtick key () to open it — tagged [room]` with the logging entity's name.

js
onPlayerJoined(player) {
  this.room.log(player.username, 'joined —', this.players.getAll().length, 'in room')
}

renders in the room console as:

[12:04:31.220] [room] [MapSetup] Miner joined — 3 in room

Logging to one player

A line about a whole room — a round starting, a boss spawning — belongs in everyone's console. A line about one player's own action does not, and room.forPlayer(id).log(...) sends it to that player alone, the same shape as camera.forPlayer(id):

js
onToolActivated(player) {
  const hit = this.blocks.hitFromPlayer(player.id, 6)
  if (!hit) return
  if (!this.data.get(player.id, 'canBuild')) {
    // Only this player sees it — the other nineteen are not debugging their neighbour.
    this.room.forPlayer(player.id).log('you cannot build here')
    return
  }
  this.blocks.set(hit.entityId, ...hit.cell, null)
  this.room.forPlayer(player.id).log('mined', hit.block, 'at', hit.cell.join(','))
}

Both forms take the same arguments and obey the same rules below. If the target has already left the room, the line goes nowhere — it is never widened into a broadcast.

Three rules keep it shippable:

  • Nothing is sent unless the map's Debug Mode (Game Settings) is on. A published game with it off leaks nothing, whatever its scripts log.
  • A script can flip that live with debug.setEnabled(true) — for example behind an admin check in onPlayerJoined — and silence a room again with debug.setEnabled(false).
  • Lines are capped (2000 characters), so a runaway logger cannot eat the room's bandwidth.

In the editor play-test, room.log lines land in the Console panel prefixed [room] — the same script reads the same way before it is ever published.

Testing a multiplayer map

  1. Press Play in the editor. Every hook in this guide fires for you as the one local player — MapSetup runs, the pickaxe mines, the camera locks. Most multiplayer logic is proven right here.
  2. Publish, open the game in two browser tabs, and join the same room. Watch for the things only a second player reveals: does a HUD element meant for one player show on both? Does a "the player" assumption pick the wrong one?
  3. Leave with one tab while the other stays — onPlayerLeft cleanup shows its gaps here.

Checklist

  • Every player-affecting call passes a player.id from a hook or getAll() — nothing assumes a single player.
  • No getKey / mouse / gesture reads in server logic; input arrives through onPlayerKeyDown (for keys the map declared with input.watchKeys), onPlayerJump (after input.watchJump), onInteract, tool hooks, zones, or UI buttons.
  • Hold state started in onPlayerKeyDown is also undone in onPlayerLeft — a player who disconnects mid-hold never sends the release.
  • Per-player things (camera, HUD, logs) use forPlayer / { playerId: id }; room-wide things deliberately do not.
  • Per-player state lives in data.set(player.id, ...); shared state in script fields.
  • Anything that shoots casts from players.getLookOrigin, not getEyePosition, and guards both reads against null.
  • A map with more than one spawn says how it wants them handed out (players.setSpawnRule).
  • onUpdate math uses dt.
  • Tested once alone in the play-test, once with two tabs in a room.