Build and test a multiplayer game
Configure private rooms for 1 to 8 players run by a room server, write scripts for its authority, presentation and prediction runtimes, build server-resolved combat, share state and events, and republish an older multiplayer game.
What multiplayer supports
Airogel provides private rooms for up to 8 signed-in players, or up to 4 when the account that published the game is on the Free plan. One player creates a room and shares an invite code or link. Everyone marks ready, the player who created the room starts it, and each browser enters the same immutable published release.
A room may be configured to start with a single player. Set Minimum players to 1 and turn on Allow late joining, and the room can begin with one player and have others arrive during play — which is how one game can be both playable solo and playable together. A room still has to have space for a second player, so Maximum players is always at least 2.
Every room is run by a room server, not by one of the players' browsers. The room server runs the scene script, simulates the physics, and moves every player's character and vehicle. Each browser sends its player's input — never a position — and draws what the room server reports back. A player's own character also responds on their screen straight away, before the room server has answered, and is quietly corrected if the two disagree.
Because players send input rather than results, a browser cannot put its character anywhere its input would not take it, and it cannot award itself a hit. The runtime is designed for casual cooperative, racing, objective, and combat games.
What every player sees the same
Characters and vehicles bound to player slots or handed to a player after being spawned (including their aim, crouch and ground state, and wheel and track motion), scripted objects declared "authority", objects copied from spawn templates with multiplayer.spawn, the game, participant, avatar and private state partitions, presentation events, combat projectiles, and the removal of any of those objects. Every browser also animates every character from its movement. Sounds, GUI, scene variables, objects declared "presentation", and objects created at runtime with scene.createObject are not shared: each browser has its own.
Configure the project
Open the project's Multiplayer section and configure:
- Enable multiplayer rooms: includes multiplayer in the next runtime bundle.
- Minimum players: the number required before the room can start, from 1 through 8. Set it to 1 to let one player start alone; pair that with late joining so a second player can still arrive.
- Maximum players: the room capacity, from the minimum through 8, and never below 2. A room holds the smaller of this and the publishing account's plan limit: 4 players on Free, 8 on Creator and Studio. If Minimum players is above the plan limit, rooms lower it to that limit so they can still start.
- Allow late joining: lets a new player enter an active room. It is off by default.
- Authority action schema: named, bounded requests a player may send to the room server. Fields may be booleans, bounded numbers, enumerated names, or object references. Free-form strings are deliberately unsupported.
- Weapons and projectiles: the project's shared combat library, frozen into each release. The Combat library box edits it as forms, with a preview of each shot's path and each projectile's collider; Edit as JSON shows the raw weapon and entity templates. A weapon has a kind (hitscan or projectile), cooldown, range, damage, team policy, and ammo policy. It names no action and no owner types, and it does not say where a shot leaves from: an object fires only the weapons its own Weapons setting lists, each from the mount that object gives it. Renaming a projectile updates the weapons that fire it; renaming or deleting a weapon leaves objects listing the old name, which publication refuses.
- Projectiles (entity templates in the JSON): projectile visuals, colliders, gravity, speed, lifetime, what an impact does (end, bounce, or pierce), and how many of each one player may have in flight at once. Each projectile's limit is per player, from 1 to 16, and a room holds at most 16 live projectiles across every player and template, refusing a launch past that.
- Presentation event schema: the events the room server may send players — for each one, whether it goes to one player or to everyone, whether it is delivered reliably, and its fields. See Send presentation events.
- Shared room settings: owner-selected choices for everyone, including the arena to load at Start. See “Let the lobby owner choose the map”.
- Lobby choices: what each player picks in the lobby before the room starts — a character, a team, a colour. See "Let players choose in the lobby" under Spawn objects during play.
These settings are copied into an immutable release. Changing them does not alter the live release or an existing room; republish to apply the changes to newly created rooms. A game published before 8 October 2026 must be republished once before it will load at all.
Through MCP, projects_configure_multiplayer sets the same project settings: enabled, minimum_players, maximum_players, late_join_enabled, action_schema, weapon_templates, entity_templates, event_schema, lobby_choices and room_options.
Let the lobby owner choose the map
Use Shared room settings in the project's Multiplayer section for one choice that applies to everyone: a map, a difficulty or a ruleset. Through MCP, pass room_options to projects_configure_multiplayer or projects_update. These settings are separate from lobby_choices, which each player picks for themselves.
This example offers two arenas. First create and test the scenes, then replace each placeholder with the scene UUID returned by scenes_list. Paste the array into Shared room settings, or send the whole object through MCP:
{
"room_options": [
{
"name": "terrain",
"label": "Terrain",
"type": "enum",
"required": true,
"default": "meadow",
"options": [
{
"value": "meadow",
"label": "Meadow",
"scene_id": "<meadow scene UUID>"
},
{
"value": "desert",
"label": "Desert Basin",
"description": "Broad dunes and sandstone cover",
"scene_id": "<desert scene UUID>"
}
]
}
]
}
- Every setting has a lowercase
name, alabel,type: "enum",required: true, a validdefaultand 1 to 32options. Declare up to eight settings. Names and option values use lowercase identifiers; labels allow up to 48 characters. There are no shared number or free-text settings. - An option has a
valueandlabel, and may have adescriptionof up to 240 characters and animageasset ID for its thumbnail (PNG, JPEG, WebP or GIF). Every player sees the selection, description and image. Shared options do not usemax,uniqueortemplate. - One setting may choose the starting scene. If any of its options includes
scene_id, every option in that setting must name a scene. Publication refuses a missing or deleted scene, a scene outside the project, or an invalid thumbnail. The allowlist and defaults are frozen into the release. A setting with no scene bindings leaves the normal gameplay entry scene in use. Pass[]to clear the declaration.
Changing a shared setting
A new room starts on the declared defaults. Only its connected lobby owner may change shared settings while it is waiting; guests see them without edit controls. Changing a value clears every human player's readiness, so everyone can check the new map before readying again. A delayed Ready request for the previous selection is refused. An identical selection does not clear readiness; bots remain ready.
In a game with shared settings, if the lobby owner leaves or times out, the earliest connected human by join time inherits lobby ownership (ID breaks a tie). Bots never inherit it. The selection stays intact, and a former owner reconnecting does not take control back. Lobby ownership is separate from simulation authority: the room server still runs the game.
Load the selected arena directly
Start locks the selection and its starting scene. The server and every browser load that arena as the room's first scene; they do not load the project's entry arena and then switch. Before Start, a room with shared settings waits in the lobby without loading a server gameplay scene. Late joiners and reconnecting players retain the same selection. If gameplay later changes scene, the normal room transition state decides the current scene.
Read the locked selection from any room script, including its init():
function init()
local options = multiplayer.getRoomOptions()
local terrain = options.terrain or "meadow"
print("Starting terrain: " .. terrain)
end
getRoomOptions() returns a detached table; editing it does not change the room. It returns an empty table outside a room. Use it to choose the arena's spawn and objective coordinates or initial rules. Settings cannot change after Start; use ordinary replicated game state for rules that change during a match.
Test each map. Use Test in a room, choose a shared setting in the invite lobby, ready the players and Start. A test lobby may report ready before its server is assigned, because its scene is chosen at Start. Check the selected arena, the rules' value from Lua, a guest's disabled controls, readiness after a change, lobby ownership transfer, late joining and reconnecting. Single-player Preview uses its own scene and does not reproduce the multiplayer lobby selection. Republish after changing the declaration so new rooms use it; running rooms keep their pinned release.
Assign player characters and vehicles
Multiplayer settings are not object variables. In a multiplayer project every object's inspector has a Multiplayer section; through MCP they are the object's multiplayer field. Set Player slot (multiplayer.playerSlot) on each player-controlled character or vehicle. Use stable slot numbers beginning at 1. A project that supports eight players normally provides one suitable object for each slot from 1 through 8. Only a character or a vehicle can hold a slot; publication refuses a slot on anything else, two objects in one scene with the same slot, and a slot above the project's Maximum players.
The slot decides which player controls the object when the room starts, and whose input its control script reads. It does not move anything by itself, so every slotted object also needs a control script. Attach the same control script to each slotted object. A vehicle that players will get into during the game needs a slot too, or must be spawned from a spawn template, because only a slotted or spawned character or vehicle can be handed from one player to another — see Hand an object to another player.
A camera that targets a slotted object is automatically retargeted to the local player's object when the room starts, and it keeps following whatever that player controls — into a vehicle they are handed, and back to their character when they get out. In multiplayer, point the scene camera at a slotted object or at a spawn template, or turn on the camera's Follow local player setting (multiplayer.followLocalPlayer); a camera aimed at any other object remains shared by every browser. A first-person camera hides the object it follows from its own view — its Hide followed object setting, on by default — so each player looks out from their own character while everyone else still sees it.
A new project's first scene is already set up this way, and plays the same alone and in a room. Its Player character holds slot 1 and the Main Camera follows it; the scene script, Game Manager, puts anyone who falls off the level back where they started; the HUD timer counts on each player's screen from its own GUI script; and the spinning cube is declared "presentation". Turn multiplayer on and it publishes with only a warning that slots 2 and up have no character: add a character with a slot and the same control script for each extra player you expect.
Three common setup failures
- A slotted object without a control script is inert.
- A camera targeting an object that is neither slotted nor a spawn template, and that does not have Follow local player on, never follows the local player.
- A scripted object that is not bound to a slot, and is not a camera or a GUI element, must say where its script runs with its Script runs on setting (
multiplayer.scriptRole), or publication refuses it. In a project created before 25 September 2026, the old starter objects — the Fall Zone, the Fall Detector and the Restart button — are the usual culprit: delete them.
Where each script runs
In a room every script runs in exactly one of three places, and what it may call depends on which.
| Runtime | Runs on | Scripts |
|---|---|---|
| Authority | The room server only | The scene script; the control script of every slotted character and vehicle, and of every character and vehicle spawned from a template; any other scripted object declared "authority". |
| Presentation | Every player's browser, never the room server | Camera and GUI scripts; particle emitters' scripts; scripted objects declared "presentation". |
| Prediction | The controlling player's browser | Nothing you attach. The player's browser also runs their character's or vehicle's control script on a throwaway copy, so the character responds at once instead of a round trip later. See Write control scripts that can be replayed. |
Keep rules, scoring and anything players must agree on in the scene script. Keep movement, driving and multiplayer.fire in the control script. Keep HUDs, sound, explicit animation such as emotes, and effects in GUI, camera, or presentation scripts.
multiplayer.isHost() is true only in the authority runtime — on the room server. It is false in every script running in a player's browser.
Declare scripted scenery and props
Give every scripted object that is not bound to a slot and is not a camera or GUI element a Script runs on setting (multiplayer.scriptRole):
"authority"runs the script on the room server and shares the object's movement with every player. Use it for anything gameplay reads or collides with — doors, platforms, hazards, pickups. A room shares at most 32 of these per scene."presentation"runs the script separately in each browser, as decoration only. In a room the object has no collider, and neither do the unscripted objects under it, such as the parts of a first-person weapon: players, raycasts, triggers, shots and navigation all pass through them. Publication refuses one that is dynamic, a trigger, a character, a vehicle, cloth, a ragdoll, a constraint, or water (presentation_object_with_physics): a presentation object is removed from the physics world on every machine, the room server's included, so nothing would float or swim in that water.
An unscripted physics object — a crate players push, a ball — needs no script to be shared: turn on its Replicate setting (multiplayer.replicate) and the room server simulates it for everyone, within the same 32 per scene. Without it, every browser simulates its own copy and players see it in different places. Publication refuses Replicate on a soft body, on water, on a player's character or vehicle (already shared), or together with the presentation role, and warns about a static, unscripted object marked to replicate, which never moves.
There is no scenery flag. Scenery that never moves needs no multiplayer settings at all, and scenery a script animates, such as a spinning sign, takes "presentation".
Moving water. To raise or lower water for every player, give the water object its own script with multiplayer.scriptRole: "authority". The room server runs it, and every player's water follows, along with the buoyancy and swimming it gives, including for players who join later. Replicate is refused on water (unreplicable_multiplayer_object), because nothing moves unscripted water, and "presentation" is refused as above. This water script follows a stage key that the scene script sets in game state:
-- The water object's script. Script runs on: authority.
local LEVELS = { 0, 2, 4 } -- the water object's height for each stage
local SPEED = 0.5 -- metres per second
function update(deltaTime)
local state = multiplayer.getGameState() or {}
local target = LEVELS[state.stage or 1] or LEVELS[1]
local p = gameObject.getPosition()
local step = SPEED * deltaTime
if math.abs(target - p.y) > 0.001 then
gameObject.setPosition(p.x, p.y + math.max(-step, math.min(step, target - p.y)), p.z)
end
end
Bodies resting in the water wake as it moves, so they float up as it rises and fall as it drops; see Physics, collisions, and triggers.
What each runtime may call
- Only the authority may change the game. A presentation script calling
setGameState,setParticipantState,setAvatarState,setPrivateState,setController,setPrimary,hold,release,fire,sendEvent,destroy,registerForCollisions,grab,setVar(on an object or on the scene),setParent, the navigation movers,scene.pauseGame,scene.resumeGame,scene.setGravity, or object creation (includingscene.cloneObject) and destruction raises an error that names the call. A presentation script may move its own object and other presentation objects, with the unscripted objects under them — so a HUD or camera script can animate a viewmodel's parts — but nothing the room server simulates. It reads its own player'sinput, as a GUI script does, so a camera script can turn the view from the mouse or the touch look stick. - Presentation reads where things are drawn. Another player's character or vehicle is drawn smoothly a little behind the room server, while its physics pose jumps to each new server frame. The local player's own character is drawn smoothly between its predicted steps, a frame or two behind them. So in a presentation script,
getPosition(),getRotation(),getWorldPosition(),getWorldRotation(),getForward()andgetRight()on another player, a replicated prop or the local player's own character return where this screen draws it. Aim a spectator or follow camera with them and it moves as smoothly as what it watches. Authority scripts and the predicted control script read the physics pose, as the room server does. - Only presentation has a screen and speakers. GUI setters (the text-style setters such as
setFontWeight,setTextOutlineandsetTextShadowincluded), sounds and music (including live volume and pitch changes,setSoundFilter,scene.setSfxFilter,scene.setMusicFilterandscene.setReverb), light changes, the active-camera calls, a camera's lens and follow setters andresetLook,setScale,setOpacity,setColor,getColorandclearColor,startTrail, the particle-emitter calls (emit,setEmitting,isEmitting,setEmissionRate,setParticleColor), the frame clocksscene.getUnscaledDeltaTime,scene.getTimeandscene.getUnscaledTime, andgameStorageraise the same kind of named error in every authority script — the scene script, control scripts, and objects declared"authority". - Animation is the exception. A character's control script may keep its animation calls; in a room they configure the character rather than raise. See Character animation in a room.
Effects are drawn by presentation. A particle emitter is presentation by construction, like a camera or GUI element: each browser draws its own particles, the room server builds none, and publication refuses Replicate or the "authority" role on one, so it never needs a Script runs on setting. Drive it from a script on the emitter itself that reads the car or character it belongs to, as lua/tyre_smoke.lua does, never from a control script, which runs on the room server. Trails work the same way: call startTrail from a camera, GUI or presentation script that reaches the car through an object_ref variable or by name. Nothing about a trail or a particle is replicated.
The device a player holds stays in their browser. input.device and input.gamepadStyle are never sent to the room, so they read nil in the scene script, in control scripts and their replay, and in objects declared "authority". Read them in a GUI or camera script to choose button prompts. Publication warns with input_device_outside_presentation when any other script reads either one; see Player input.
Publication reads your scripts and refuses, with the script, object and line, a presentation script's gameplay calls, a control script's clock reads and branched results, and a scene change from any script but the scene script. It does not catch a presentation call made from an authority script, which fails only when the room runs it — test in a real room. Outside a room, in single-player play, every script keeps the whole API.
An error raised by the scene script, or by an object declared "authority", happens on the room server: it never appears in any player's browser console, and the game simply behaves as if that line and everything after it in the callback did not run. A control script is the exception, because its player's browser runs a copy of it too, so that player's console shows the error as well.
You can read the room server's errors yourself. Once the game is published, open the project and choose Script errors on its publishing card. The page lists every distinct error the game's rooms caught in your authority scripts, newest first: whether the script failed to compile, failed in init, or raised in a callback such as update or onPlayerAction; the object and script; the message and the start of its stack; how many times it happened, in how many rooms, and when it was last seen; and a link to the scene in the editor. The same error in several rooms is listed once. A room reports its errors every few seconds, so reload the page after each attempt. Any member of the project's account can open it; players never see it, and a room that fails still ends for them with the usual message. Each room keeps up to 50 distinct errors, dropping the one seen least recently, and its errors are deleted with the room's record 30 days after it ends. Running the scene in the editor still reports every script's errors as they happen.
Never loop at a script's top level
A script that never returns blocks the room server, and the room is ended for everyone. The project's Script errors page records the ending as a stall, since the stuck script cannot report anything itself.
Character animation in a room
Every player's browser animates every character in the room itself, from how the character moves: idle, walk, run, the directional and turning clips, jump, fall, land, and crouch — the same states enableAutoAnimation drives in single-player play. Nothing has to be sent for it, and the room server animates nothing.
Existing control scripts keep working. Keep the single-player pattern — enableAutoAnimation in init(), updateAutoAnimation() at the end of update — so the same script works alone and in a room. In a room, a control script's animation calls behave like this:
enableAutoAnimation(config),disableAutoAnimation()andsetAnimationSpeed(speed, name)are recorded as the character's configuration, and apply on the controlling player's own screen, unless the character has a room animation (below), which wins.updateAutoAnimation(),playAnimation,stopAnimationand the other animation calls do nothing and never raise.getAnimationNames()returns an empty list andgetAnimationState()returnsnil, so do not steer gameplay by them.
Name a model's clips once, for every screen. A script's own configuration reaches only the controlling player's own screen; without anything else, everyone else animates that character with the conventional names (idle, walk, run, and the rest listed in Animation playback API), which the bundled default character uses. If your character's model names its clips differently, set Room animation on the character (or its spawn template) in the inspector's Multiplayer section, or multiplayer.animation through the API. Every player's screen then plays those clips, including spawned copies and players who join late or reconnect. When it is set, it wins over the control script's enableAutoAnimation, disableAutoAnimation and setAnimationSpeed on every screen, the controlling player's included. A control script's existing local animation calls still affect only its own player's screen. Use the explicit authority-owned runtime override below to switch clips on every screen.
A room animation takes the keys enableAutoAnimation takes — the seventeen clip names, walkThreshold, runThreshold, turnThreshold, blendTime, oneShotBlendTime and landBlendTime — plus speeds, a playback rate from 0 to 10 for each state. A key left out keeps its conventional name or default. Clip names are kept exactly as typed, up to 200 characters. A name the model does not have falls back to the nearest clip it does, as with enableAutoAnimation, and the browser console warns once per clip; the inspector suggests the model's clips and warns about a missing one as you type. Room animation only affects characters: on any other object, the publish check warns that it is ignored. In an update, null for one key clears it.
Switch locomotion clips during a room run
From the room authority, call multiplayer.setAnimationConfig(objectId, config) to replace a character's locomotion settings on every screen. It accepts live canonical characters: characters bound to player slots, or spawned characters handed to a player, including characters that are currently unowned after a control assignment. Characters replicated only as props are not accepted.
if multiplayer.isHost() then
multiplayer.setAnimationConfig(gameObject.id, {
idle = "CombatIdle",
run = "CombatRun",
speeds = {run = 1.2}
})
end
The configuration uses the same keys and bounds as authored room animation, with a limit of 2048 UTF-8 bytes when encoded as JSON. Each call replaces the whole override; it does not merge. Omitted clip names and tuning use defaults, omitted state speeds use 1, and an enabled override sets the shared animation mixer rate to 1 so an earlier owner-local speed cannot skew playback. Independently layered animations keep playing on that mixer.
On every screen, precedence is runtime override, authored room animation, then existing owner-recorded/default behavior. Pass {} to choose defaults explicitly, false to suspend engine locomotion updates without stopping the current clip, or nil to clear the override and restore the fallback. Invalid input raises without replacing the last valid configuration. Missing clips use the existing fallback chain.
Prediction and presentation scripts cannot write this authority-owned state. Guard the call in a control script with multiplayer.isHost(); a scene authority script can call it directly. The current override reaches late joiners and reconnecting players. It belongs to the running character and ends on despawn, scene transition or simulation Stop; source content is unchanged.
A presentation takeover remains local to its browser. releaseAnimation() resumes the latest runtime override, or the authored/legacy fallback if the override was cleared while the script held the character. Existing gameObject.enableAutoAnimation, disableAutoAnimation and setAnimationSpeed calls retain their local/recorded behavior; they are not automatically replicated.
"multiplayer": {
"playerSlot": 1,
"animation": {
"idle": "Idle_Loop",
"walk": "Walk_Fwd",
"run": "Sprint",
"speeds": { "run": 1.2 }
}
}
Explicit clips come from presentation, on a layer. An emote, a wave, a hit reaction, a throw, or a victory or defeat pose belongs in a camera, GUI or presentation script that plays it as an animation layer: a "fullBody" mask with a higher priority for a whole-body moment, "upperBody" or "arms" for one that leaves the legs walking. Leave locomotion to the engine. Calling playAnimation or stopAnimation on the character instead takes it over on that browser: the browser stops animating the character automatically and the script owns it until it hands the character back with releaseAnimation(). From the next frame the engine animates the character again, crossfading out of the script's clip. releaseAnimation() affects only that character on that browser: animation layers keep playing, and other characters are unaffected. It is safe to call twice, or on a character that was never taken over. A later playAnimation or stopAnimation takes the character over again. So does a script's own auto-animation on the character, but not straight away: updateAutoAnimation() takes it over the next time it plays a different clip, for example when the character starts or stops moving. Call disableAutoAnimation() before releasing, so the character is not taken back. A takeover also ends when the run stops or the character leaves the scene. In a character's own control script, which runs on the room server and in the owner's predicted copy, the call does nothing, like the other animation calls.
-- A camera or GUI script: a one-off emote, then back to locomotion
local hero = gameObject.getObjectByName("Player 1")
hero.playAnimation("wave", false, 0.2)
-- later, once the wave has finished:
hero.releaseAnimation()
Leave the legs to the engine. Each browser animates every character with its room animation, or the default clip names without one: idle, walk and run, the eight travel directions, turns, jump, fall, land and crouch. For your own character it reads the physics body's velocity. For other players' characters it measures where the character is drawn from frame to frame, smooths that over about 0.1 s, starts afresh after a teleport, and takes whether the character is on the ground or crouching from the room server. A script that instead picks the locomotion clip from the change in position each frame makes the legs stutter, because drawn positions advance unevenly in a room; see "Characters' legs stutter when they move in a room" in Troubleshooting for the cause and how to smooth it if you must drive locomotion yourself.
Match clip speed on every screen. setAnimationSpeed(speed, clip) in a control script is recorded and applies only on the controlling player's own screen; the room does not send it to anyone else. Called from a camera, GUI or presentation script, it sets that clip's rate on that browser and does not take the character over. So to make a 5.4 m/s run match a jog authored for about 3.8 m/s on every screen, call player.setAnimationSpeed(1.4, "run"), and the same for the other locomotion clips, from a presentation script that runs on every browser. On the controlling player's own screen, speeds the control script recorded are reapplied every frame and win. Simpler still, set the speeds in the character's room animation: they apply on every screen and win over setAnimationSpeed for those clips while the engine animates the character.
Animation layers are presentation-only, and never take the character over. A layer plays a clip on part of the skeleton, such as arms holding a ball, over the character's locomotion (see Animation layers). In a room, playAnimationLayer and the other layer calls work only in camera, GUI and presentation scripts; in an authority or prediction script they do nothing (playAnimationLayer returns false and getAnimationLayers() an empty list). Unlike playAnimation, a layer never stops the browser animating the character automatically, so idle, walk and run keep playing under it. The room server never plays a layer and nothing about one is sent to other players, so drive layers on every browser, the owner's included, from replicated state. The authority publishes what the character is doing, for example multiplayer.setAvatarState(character.id, { carrying = true, act = "throw", actSeq = n }), keeping the state's other keys; a presentation script on each browser reads getAvatarState, calls playAnimationLayer on the character while it is carrying and stopAnimationLayer when it is not, and plays a one-shot layer when actSeq rises. A player who joins late should adopt the first actSeq it sees without playing it. onAnimationLayerFinished goes to the character's own script, so a camera or GUI script driving the character's layers polls getAnimationLayer(layer) instead.
Bone sockets are presentation-only. attachToBone, detachFromBone, the bone reads, the playhead calls (seekAnimation, getAnimationTime, preloadAnimations and the rest) and getAnimationDiagnostics are animation calls too. The room server never poses a skeleton, so on the script's own object in an authority or prediction script they do nothing: attachToBone returns false, getBoneNames() returns an empty list and getBoneWorldPosition returns nil. Attach a sword or a hat from a camera, GUI or presentation script, and make what it holds a presentation object. What a bone holds is drawn separately on each player's screen and has no collider on the room server, so it can never decide a hit: do not build hit detection on a bone-held hitbox. Resolve a melee hit on the room server instead, for example from an action the control script sends, and use the bone only to draw the weapon.
Holding a replica is visual only. A presentation script may attach an object its browser only displays: a replicated prop or ball, or another player's character or vehicle. Its mesh is then drawn on the bone every frame, winning over where multiplayer.hold draws a held prop, but its body is never made kinematic or moved, because the room server owns where it really is. detachFromBone hands the drawing back to replication, so a ball still held goes back to its hold offset and a released one to its flight. This is automatic; there is no setting.
Timing gameplay by an animation. The room server never draws clips, so rendered playhead getters and onAnimationFinished cannot drive its rules. Use playAnimationTimeline and onAnimationMarker for gameplay timing. A control script's timeline also runs in the owner's predicted copy and replays with input corrections; only the authority applies shared outcomes. To show an upper-body action on every browser while preserving locomotion, publish the timeline's clip, time and speed, then use playAnimationLayer, seekAnimationLayer and setAnimationLayerSpeed from a camera, GUI or presentation script. See “Synchronize a throw with its animation” under Carry and throw props, and Animation playback API.
Write control scripts that can be replayed
A slotted character's or vehicle's control script runs on the room server and, at the same time, on its player's browser, which replays recent input through it to predict where the character will be. The two copies must reach the same answer from the same input, or the player's character is corrected on every replay and appears to stutter or snap back.
- Do not read the clock.
os.time,os.clockandos.dateraise in a control script. Count time by adding upupdate(deltaTime). math.randomis seeded per room, so both copies draw the same numbers.- Aim from the command, not the camera. The room server runs no camera, so a control script that turns its body toward the view reads
input.aimYawandinput.aimPitch: the aim of the command being simulated, in world-space radians (yaw measured from +Z toward +X, pitch positive up), the same on the room server and on the player's replay. They arenilwhen there is no room command, as in single-player play. Pair them with the camera mode First Person (Absolute Aim) (first_person_absolute), whose view does not turn again when the body turns; the older First Person mode adds look to the body's rotation, so a body turned from look turns the view twice. - Call outcomes for their effect; never branch on their result. On the player's copy, calls whose answer only the room server knows —
multiplayer.fire,multiplayer.setController,multiplayer.setPrimary,multiplayer.spawn,multiplayer.despawn,destroy,moveTo,followPath,scene.createObject,scene.cloneObject,scene.destroyObject,scene.destroyObjectByRef— do nothing and returnnil.if multiplayer.fire("rifle") thenis refused at publication. Read the outcome from replicated state instead.
Tweens and prediction
tween.to(object, options) and tween.value(from, to, duration, easing, callback) advance on the script's simulation deltaTime, not os.time or os.clock. A predicted control script's tween progress, property channels, handles, cancellation and callback upvalues rewind with the script during input replay. The same step sequence produces the same samples. Schedule once in init() or from a replay-safe input transition, rather than creating a new tween every update.
Callbacks keep the calling script's permissions. A generic value tween does not grant camera, GUI, storage or authority access: a callback can still be refused by the API it calls. Keep non-replayable presentation effects out of control scripts. A presentation script may move its own local camera or decoration, but cannot use a tween to move a replicated player or gameplay prop. setScale, GUI opacity and camera lens changes remain presentation-only. Transform tweens refuse dynamic bodies, characters and soft bodies in every runtime.
Drive a kinematic platform from its authority script, using tween.to(gameObject, ...). Its position and rotation use moveKinematic, so the room server supplies the movement velocity that carries riders. A platform declared presentation has no gameplay collider in the room. Each script owns its own tween queue; overlap cancellation does not coordinate separate scripts or browsers. Replicate gameplay state and let presentation scripts draw each browser's visual effects from that state.
Stop, destruction and scene replacement clear the script's tweens. Cancellation and overwritten channels do not call onComplete. For API options, easing names and examples, see Transform tweens and Simulation-time tweens.
Send authority actions
multiplayer.sendAction(name, payload) asks the room server for an outcome — from a GUI button, or from a player's character or vehicle control script. The scene script cannot send one — it is what receives them, and gets unavailable — and the player's own replayed copy of a control script sends nothing, because the room's run of the same script already has. It returns ok, reason. A request may be refused as unknown_action, invalid_payload, queue_full when 16 actions are already waiting, or unavailable. A player who controls no character or vehicle can still send one — to ask to be spawned back in, say.
Each action in the Authority action schema is a name with its fields. Every field gives a type — boolean, number (with minimum and maximum), enum (with values, up to 32 lowercase names) or object_ref — and required set to true or false. Names are lower_snake_case. A schema holds up to 32 actions of up to 16 fields each. Publication refuses a game that declares actions when no scene has a scene script to receive them.
{
"charge": {
"power": { "type": "number", "required": true, "minimum": 0, "maximum": 1 }
}
}
-- A GUI button's script.
function onClick()
local ok, reason = multiplayer.sendAction("charge", { power = 0.8 })
end
Only the scene script receives the request, once, on the room server:
function onPlayerAction(participant, name, payload)
if name == "charge" then
multiplayer.setParticipantState(participant.id, {
charge = payload.power
})
end
end
The server authenticates the participant ID and player slot. The scene script must still validate phase, ownership, distance, cooldown, and game-specific rules. An action is intent, never a completed outcome chosen by the player. The scene script may also define onPlayerActionRefused(participant, name, reason) for an action the room turned away before it ran — for example because the sender lost control of their object first.
The scene script is told who is in the room through onPlayerJoined(participant) and onPlayerLeft(participant), where participant carries id and playerSlot. Everyone already present is announced after init(). A leaver's participant and private state are cleared once onPlayerLeft returns, so read a final score there.
Build ranged combat
A slotted character's or vehicle's control script fires one of the weapons the object carries — those its Weapons setting (multiplayer.weapons) lists, each with its own mount: where the muzzle sits on this object, set per weapon in the inspector, so a turret and a soldier carry one shared rifle at different heights — with multiplayer.fire(templateId) when its player presses the button. The shot is resolved on the room server, on the input it is simulating:
local wasFiring = false
function update(deltaTime)
local firing = input.primaryFire == true
if firing and not wasFiring then
multiplayer.fire("rifle")
end
wasFiring = firing
end
Fire on the press, not while the button is held: a held trigger asks again on every step and draws a cooldown refusal for nearly every shot. Do not use the value fire returns — the player's own copy of the script gets nil — so read what happened from state and events.
A shot goes where the player is looking — the direction of their camera. A vehicle's gun usually should not: a chase camera looks down at the hull and lags its turns, so a tank's shell would go into the ground or off to one side. Set that weapon's Aim to Aims along this object in its Weapons setting (aim = "carrier" in multiplayer.weapons), and the shot leaves along the object's own forward (+Z) instead, following its hull up and down slopes, raised by the weapon's Elevation in degrees (up to 89 either way):
"weapons": [
{ "weapon": "cannon", "mount": { "x": 0, "y": 1.2, "z": 3 }, "aim": "carrier", "elevation": 10 }
]
Before anyone can fire, give each weapon owner's avatar state alive, team, health, ammo and cooldowns, typically in the scene script's init():
multiplayer.setAvatarState(player.id, {
alive = true, team = "red", health = 100,
ammo = { rifle = 30 }, cooldowns = {}
})
The room server checks the release-frozen weapon template against that state — owner, alive, team, ammo, and cooldown — and then:
- Hitscan: traces the shot against the world as the shooter was seeing it, up to half a second back, so ordinary network delay does not turn a shot that looked aligned into a miss.
- Projectile: spawns the configured projectile and simulates it on the room server until it hits something or expires. Its flight path is sent once, when it is fired, and every browser draws it moving smoothly along that path: other players' shots in step with the players who fired them, and your own without delay.
Both stop on terrain: a shot does not pass through a height field's hills, and a projectile that misses lands on the ground where it meets it.
An entity template's collisionPolicy decides what an impact does:
first_impact: the projectile ends on the first thing it touches, damaging it if it is a player.bounce: it rebounds off scenery, keepingrestitution(0–1) of its speed into the surface, up tomaxBouncestimes; the next surface after that ends it. A player it reaches is hit and ends it. Use it for grenades and ricochets.pierce: it passes through up tomaxTargetsplayers, damaging each once, and scenery ends it.
Give each policy exactly its own options — a restitution on a piercing projectile is refused when you save the template.
Ammo, cooldown and damage are written into avatar state in one step: the target's health drops by the weapon's damage, and alive becomes false at zero. Give an avatar a numeric shield and damage drains it first, with any overflow taken from health: a 25-damage hit on { health = 100, shield = 10 } leaves { health = 85, shield = 0 }. Only health decides alive. Nothing regenerates a shield for you; set and refill it from the scene script, reading the current avatar state and writing it back so health, ammo and cooldowns are kept. A shot does not call onPlayerAction. The scene script sees its result by reading avatar state — to respawn a player, for example — and hears every projectile that ends through onProjectileImpact(impact), once the step that traced it is over and with any damage already in avatar state. impact carries weapon, entityId, shooterId (the object that fired), targetId (the player it damaged) or hitId (anything else it ended on, such as the ground), and position. Score kills, practice targets, splash damage and craters there. Do not move objects of your own as projectiles and trace them by hand: every screen draws a hand-moved object a little in the past, so a moving shooter sees their own shot appear behind their muzzle. Publication refuses a weapon an object carries that the project does not define (unknown_multiplayer_weapon) and weapons on anything but a character or vehicle, and warns about a weapon nothing carries. A spawned copy carries its template's weapons.
The room also sends events of its own, which presentation scripts receive through onMultiplayerEvent: weapon_fired to every player (with source_object_id, weapon, and target_object_id on a hit), projectile_impact to every player when a projectile lands (with entity_id, source_object_id, weapon, target_object_id for a player it damaged or hit_object_id for anything else, and the point as x, y, z — place an explosion there), and action_refused to the shooter alone when a shot is refused, carrying action, weapon and reason. reason is one of unavailable, invalid_template, invalid_owner, not_equipped (the object's Weapons setting does not list it), owner_dead, no_ammo, cooldown, invalid_state, entity_unavailable, or wrong_scene. A player's own muzzle flash is therefore drawn from weapon_fired, a round trip after the press.
Carry and throw props
A ball, a flag or a crate that lasts the whole match is not a projectile. Make it a scripted object declared "authority", or an unscripted one with Replicate on — a real physics body on the room server, so it rolls and bounces with its own restitution — and let players pick it up:
multiplayer.hold(propId, holderId, x, y, z)has a character or vehicle carry the prop at an offset in the holder's frame, +Z forward. The offset defaults to0, 1, 0.5: chest height, in front.multiplayer.release(propId, vx, vy, vz)puts it back where it is, moving at that world-space velocity — a throw, or a drop with no velocity. It falls, rolls and bounces again.multiplayer.getHolder(propId)names the holder, or returnsnil. Any script can read it.
hold and release belong to authority scripts and return ok, reason: unavailable, unknown_prop (not an authority or replicated object), unknown_holder (not a character or vehicle), or not_held. Holding a prop someone else holds takes it from them. A held prop leaves the physics world, so it pushes nobody and stops no shot. It is dropped where it is if its holder is destroyed. Every screen draws it on the holder's hands, and it sends nothing over the network while it is carried.
Catching and hitting are your rules to write, in the ball's authority script. Call gameObject.registerForCollisions() in the ball's init(), and decide in its onCollisionEnter(other) whether other — a character is reported like any body, with other.id naming it — was catching (for example, from a flag in their avatar state that the catcher's control script sets from its input) or is out. The ball hears the floor and walls too, so check that other is a player's object, for example with multiplayer.getController(other.id), before applying the rule. Throw from the holder's control script on the press, along the way they face:
local wasThrowing = false
function update(deltaTime)
local throwing = input.actions and input.actions.throw == true
if throwing and not wasThrowing then
for _, ball in ipairs(scene.getObjectsByNamePrefix("Ball")) do
if multiplayer.getHolder(ball.id) == gameObject.id then
local forward = gameObject.getForward()
multiplayer.release(ball.id, forward.x * 12, 4, forward.z * 12)
end
end
end
wasThrowing = throwing
end
As with fire, call hold and release for their effect only: the player's own copy of a control script gets nil from them, and a script that branches on the result is refused at publication.
Synchronize a throw with its animation
Use two clocks with different jobs: an animation timeline decides when gameplay happens, and an animation layer draws the pose on each browser. This pattern also works for a kick, a reload or a melee wind-up.
- Validate before starting. Put a
releasemarker on the character'sthrowclip. CheckgetAnimationMarkers("throw")and the result ofplayAnimationTimelinebefore recording a pending throw. Use a non-looping timeline. A missing marker, unknown clip length or refused start cancels the request; it must not launch the ball immediately. - Release exactly once. In the character's
onAnimationMarker, accept only the expected clip and marker for the current pending throw. Clear the pending release before applying it. Recheck that the character is alive, the round is still active and it still holds that ball. If the scene script owns the rules, the character can increment a release counter in an object variable for the scene script to consume once. A timeline's callbacks belong to its object's own script, even when the scene script started it. - Cancel stale work. Clear the pending release and stop its timeline on elimination, loss of the ball, a round change, a stopped or replaced timeline, or a timeout. A timeout is a safety cancellation, never an alternative release trigger. Cancelling a wind-up retains an otherwise valid hold; cancelling recovery after release does not take the flying ball back.
- Publish the current action. The authority writes an action sequence, clip time and playback speed into avatar state while the timeline plays, including recovery after release. For example, use
act_seq,throw_timeandthrow_speed, withthrow_time = -1meaning inactive. These are game-defined keys, not built-in fields. Merge them with the existing avatar state because a setter replaces the whole partition. Scene variables do not replicate. - Draw the sampled pose. On every browser, the owner's included, a camera, GUI or presentation script starts an upper-body layer with
playAnimationLayerand seeks it withseekAnimationLayerto the authoritative clip time. Keep movement animation underneath. Track the action sequence so a new throw restarts once; extrapolate between samples at the published speed and correct drift against fresh samples. Preload external clips before relying on their playheads. - Respect the canonical hold. Attach the visible ball to the hand while
multiplayer.getHolder(ball.id) == char.id. While that remains true, cap the extrapolated throw pose at the release marker and set the layer speed to 0 there, so the hand does not finish a throw with the ball still attached. Resume at the published speed and detach once canonical ownership changes. UseonFinish = "hold"if the drawn layer should wait at its end until authority reports inactive, then fade it out once. - Handle arrival and cleanup. A late joiner starts an ongoing throw at its current sampled clip time, rather than at the wind-up. An inactive action with an old sequence must not replay a historical throw. On completion, cancellation, respawn or round reset, clear the visual action and stale sockets. Keep a valid carrying pose if the character still owns the ball.
Clip seconds are not simulation seconds. A marker at 0.8 clip seconds played at speed 2.2 is reached after about 0.8 / 2.2 = 0.364 simulation seconds from a zero start. The callback runs on the simulation step that crosses the marker, not at an exact wall-clock instant. Time scale and pause affect that clock. Use the actual recorded clip length and authored marker; do not duplicate the release delay in a separate timer.
The server has no animated hand. Choose the canonical hold/release offset relative to the character and test it against the pose. A browser's bone socket changes the drawing only; it cannot supply an authoritative launch origin or decide a hit.
Test the whole action. Check that the ball stays held before the marker, releases once across it and never releases on a missing marker or timeout. Interrupt the wind-up, eliminate the holder and reset the round. Check movement during the throw, an ongoing action on late join, delayed ownership updates, and removal of finished layers and sockets. Use single-player Preview for its own path, then a real room for authority and at least two signed-in browsers to inspect what another player sees.
For a physics hold rather than a carry — a gravity gun, or a crate dragged on a spring that a wall can stop — call gameObject.grab on the prop from the scene script's onPlayerAction or from the prop's own authority script, and give the prop Replicate so every browser sees it move. grab is the authority's alone, and publication refuses it in a presentation script. Collision callbacks, onCollisionStay included, run on the room server for authority scripts, never in a predicting browser. See Grabbing and carrying.
Share structured state
The multiplayer API carries detached JSON-like state partitions. Any script may read them; only an authority script may write them:
getGameState()andsetGameState(state)getParticipantState(id)andsetParticipantState(id, state)getAvatarState(objectId)andsetAvatarState(objectId, state)getPrivateState([participantId])andsetPrivateState(participantId, state)
Use game state for phase, timer, and team scores; participant state for inventory or persistent player progress; avatar state for health, alive state, team, and ammo; and private state for information delivered only to one participant. A player's browser can read only its own private state, and a GUI, camera or presentation script's getPrivateState() with no participant reads it. An authority script must name the participant. A participant partition must name someone in the room, and an avatar partition a character or vehicle that can hold a player slot. State snapshots are detached values, so mutating a returned table does not publish it; call the matching setter from an authority script. Every read returns a table of its own, so editing one changes neither the next read nor another script's. Reading the same partition several times in one step is cheap: until the partition changes, a read copies the table it last converted rather than converting the state again.
Scene variables do not replicate
No scene variable is shared between browsers, whatever it is named — including multiplayer_shared_* and p<slot>_*, which older samples sometimes use. A scene script's scene.setVar writes on the room server alone, and every browser keeps reading its own starting value. Nothing raises an error, and the pattern still works in single-player preview, so a game built on it looks correct right up until it is played in a room. Anything another browser has to see belongs in a state partition.
Three things to know when writing to a partition:
- Every key must be lower_snake_case. State keys have to match
^[a-z][a-z0-9_]{0,47}$, and one bad key rejects the whole partition rather than that key alone. A single camelCase name such asinteractTotalmakes the setter raise an error beginninggame state cannot be replicated: keys must be lowercase snake_case. - A setter replaces the whole partition.
setGameStatedoes not merge. Read the current snapshot, change the keys you own, and write the merged table back — otherwise every write erases the keys the previous one set. - Index a snapshot by name. Read
state.interact_totaldirectly rather than walking the returned value withpairs.
-- Scene script.
function onPlayerAction(participant, name, payload)
if name == "interact" then
local state = multiplayer.getGameState() or {}
state.interact_total = (state.interact_total or 0) + 1
state.last_actor = participant.id
multiplayer.setGameState(state)
end
end
Every browser then reads the same keys from a GUI, camera, or presentation script. Rather than reading every frame, such a script may define onMultiplayerStateChanged(change), which runs after replicated state changes. change.partition is "game", "participant", "avatar", or "private"; change.id names the participant or object for those partitions; and change.changed and change.removed list the keys. Read the values through the getters.
-- A GUI label's script.
function onMultiplayerStateChanged(change)
if change.partition == "game" then
local state = multiplayer.getGameState() or {}
gameObject.setText("Total: " .. tostring(state.interact_total or 0))
end
end
A rejected partition fails quietly
A setter that rejects its state raises on that line, the rest of the callback does not run, and the room keeps the last valid state. In a room the scene script runs on the room server, so the error does not appear in any player's browser console: every replicated value simply stays where it was on every screen. If a counter is stuck everywhere, check the key names before suspecting the network, and open the project's Script errors page, which lists the error the room server caught, or run the scene in the editor, where it is reported as it happens.
Send presentation events
An event is a one-off message from the room server to players' screens — a hit marker, a sound cue, a "round over" banner. Declare each one in the project's Presentation event schema, with:
audience:"owner"for one player, or"observers"for everyone;delivery:"reliable", resent until the browser acknowledges it and delivered once, or"unreliable", sent once and possibly lost;- an optional
coalesce, which lets a newer event replace an unsent one of the same name; fields, using the same field types as the action schema.
{
"round_over": {
"audience": "observers",
"delivery": "reliable",
"fields": { "winner_slot": { "type": "number", "required": true, "minimum": 1, "maximum": 8 } }
}
}
An authority script sends one with multiplayer.sendEvent(name, payload[, participantId]) — with a participantId for an owner event, without one for an observers event. It returns ok, reason, where reason is unknown_event, wrong_audience, invalid_payload, unknown_participant, queue_full, or unavailable.
multiplayer.sendEvent("round_over", { winner_slot = 2 })
Each receiving browser calls onMultiplayerEvent(name, payload) on its GUI, camera and presentation scripts. It holds events until those scripts have started, so an event sent from the scene script's init() still arrives.
function onMultiplayerEvent(name, payload)
if name == "round_over" then
gameObject.setText("Player " .. payload.winner_slot .. " wins")
end
end
Events are for things a player sees or hears, never for state anyone must agree on: a player who joins later receives the current state, not past events. The names action_refused, weapon_fired, and projectile_impact belong to the room itself and cannot be declared.
Let players talk with preset messages
Players in a room can talk to each other only through preset messages: a fixed list the platform writes — "Ready!", "Wait for me", "Let's go!", "Nice!", "Good game!", "Thanks!", "Oops!", "Sorry!", "Rematch?", and "Be right back". There is no free text, and a game cannot define its own list. Players send them from the room lobby and, during the game, from the message button below the room status in the top-right corner, where recent messages also appear for a few seconds.
- A player can send one message a second, and up to eight in 30 seconds.
- A player can report a message — as spam or harassment, cheating, or something else — and block its sender at the same time. Messages never reach a player from someone they have blocked, or who has blocked them.
- For a teen whose linked guardian has turned preset messages off, the message button and the lobby's message row are hidden, and no messages are delivered to them.
None of this needs a script. To build messages into your own game — a "GG" button in your HUD, or a speech bubble over the sender's character — use these from a GUI, camera or presentation script:
multiplayer.getPresetMessageOptions()returns the presets the local player may send, as{key, text}tables. It is empty outside a room and for a player who may not send.multiplayer.sendPresetMessage(key)sends one and returnsok, reason, wherereasonisunavailable,restricted,unknown_preset, ornot_connected. A message sent too quickly is refused by the room, and the player sees why in the game's message overlay.- Every GUI, camera and presentation script receives
onPresetMessage(message)— in the sender's browser too — withmessage.key,message.text,message.handle,message.playerSlotandmessage.isLocal.playerSlotmatchesmultiplayer.getPlayerSlot(objectId)on the sender's character or vehicle, so a script can find it.
-- A GUI button's script.
function onClick()
multiplayer.sendPresetMessage("good_game")
end
-- A GUI label's script: show the latest message from someone else.
function onPresetMessage(message)
if not message.isLocal then
gameObject.setText(message.handle .. ": " .. message.text)
end
end
The keys are ready, wait, go, nice, good_game, thanks, oops, sorry, rematch and brb. Both calls raise in an authority script: the room server has no player to speak for.
Hand an object to another player
multiplayer.getController(objectId) returns the id of the participant controlling a character or vehicle, or nil. An authority script moves control with multiplayer.setController(objectId, participantId), or frees an object by leaving out participantId. It returns ok, reason, where reason is unknown_object, unsupported_object (anything that is not a slotted or spawned character or vehicle), unknown_participant, control_limit, or unavailable.
A player may hold up to four objects at once; see Control several objects at once. To move them from one object to another, free the one they are in before handing them the next, as below. Getting into a vehicle is typically an action handled by the scene script:
function onPlayerAction(participant, name, payload)
if name == "enter_car" then
local car = scene.getObjectByName("Car")
local me = scene.getObjectByName("Player " .. participant.playerSlot)
if car and me and multiplayer.getController(car.id) == nil then
multiplayer.setController(me.id) -- free the character
local ok = multiplayer.setController(car.id, participant.id)
if not ok then multiplayer.setController(me.id, participant.id) end
end
end
end
The player's camera follows them into the vehicle and back out again, provided it targeted a slotted object or a spawn template, or has Follow local player on, to begin with.
Spawn objects during play
A room can add objects while it runs — pickups, enemies, crates, cars, or each player's own character — by copying a spawn template. A spawn template is an ordinary object in the scene with its Spawn template setting (multiplayer.spawnTemplate) on. A room never builds a template where it was placed; an authority script copies it, children included, with multiplayer.spawn, and every player sees the copy, including players who join later. Build one of each thing you spawn and mark it.
-- Scene script: three coins that come back ten seconds after they are taken.
function init()
multiplayer.spawn("Coin", -4, 0.6, 6, { respawnDelayMs = 10000 })
multiplayer.spawn("Coin", 0, 0.6, 6, { respawnDelayMs = 10000 })
multiplayer.spawn("Coin", 4, 0.6, 6, { respawnDelayMs = 10000 })
end
multiplayer.spawn(template, x, y, z, options) names the template by its name, places the copy at a world position, and returns id, reason. options may be left out, or may set:
yaw— turns the copy about +Y, in degrees.variables— overrides the copy's object variables: numbers, strings and booleans, at most 16 of them in 128 bytes.respawnDelayMs— from 0 to 60000. When the copy ends, a fresh one comes back that many milliseconds later, in the same place with the same options and its script started again, under a new id. The template's own Respawn delay setting (multiplayer.respawnDelayMs) is the default.killHeight— a copy that falls below this height is despawned as fallen. It defaults to the template's Kill height setting (multiplayer.killHeight), or -100, and applies whether or not the copy respawns.controller— a participant id. A character or vehicle copy is handed to that player as it spawns.
A refused spawn returns nil and a reason: unavailable (not an authority script, or the room is not running), unknown_template, spawn_limit (32 copies are already live, or the room is already sharing as many moving objects as it can), invalid_pose, invalid_option, and, for controller, not_controllable (the template is not a character or vehicle), unknown_participant or control_limit.
The copy runs the template's script, init() first, like any object. An unscripted copy is still shared with every player; a scripted one runs on the room server, so a template that is not a character or vehicle must be declared "authority". A copy's id is spawn_ followed by a number and is never reused: spawn again for a new copy. Spawning and despawning belong to authority scripts; a player asks for one with an action.
Despawn and respawn
multiplayer.despawn(id) removes a spawned object and its children, running its destroy(), and returns ok, reason: unavailable, unknown_object, not_spawned for an object the scene placed — remove those with scene.destroyObject — or invalid_option. A copy spawned with respawnDelayMs comes back after a despawn too; multiplayer.despawn(id, { respawn = false }) ends the chain. A spawned object removed any other way — it destroys itself, or another script destroys it — is gone for every player as well, and respawns if it was spawned to.
multiplayer.getTemplate(id) names the template a spawned object came from, or returns nil for anything else, and multiplayer.getSpawnedObjects(template) lists the live spawned ids, oldest first — every template's, with no argument.
The scene script hears about every copy through two callbacks. Both are called in order at the start of a later step, never inside the call that caused them:
onObjectSpawned(object, template)runs once a copy is built, respawns included, and is handed the copy itself. It is how a script learns a respawned copy's new id.onObjectDespawned(id, template, reason, respawning)runs for every copy that ends.reasonisdespawned,fellordestroyed, andrespawningsays whether it will come back.
-- The Coin template's script, declared "authority" and given a trigger volume.
function init()
gameObject.registerForCollisions()
end
function onTriggerEnter(other)
local player = multiplayer.getController(other.id)
if player == nil then return end
local state = multiplayer.getParticipantState(player) or {}
state.coins = (state.coins or 0) + 1
multiplayer.setParticipantState(player, state)
multiplayer.despawn(gameObject.id)
end
Give each player a spawned character
Instead of placing a slotted character for every player, a game can make its player character a spawn template and spawn one for each player as they arrive. Point the scene camera at the template: each browser then follows its own player's copy.
-- Scene script.
function onPlayerJoined(participant)
multiplayer.spawn("Avatar", (participant.playerSlot - 1) * 3, 0.1, 0, {
controller = participant.id,
respawnDelayMs = 3000
})
end
function onPlayerLeft(participant)
-- A leaver's character stays in the world, uncontrolled. Remove it for good.
for _, id in ipairs(multiplayer.getSpawnedObjects("Avatar")) do
if multiplayer.getController(id) == nil then
multiplayer.despawn(id, { respawn = false })
end
end
end
A test room's bots arrive the same way: onPlayerJoined fires for each, and this script gives it an avatar that its control script drives from the bot's input, exactly as for a person. participant.isBot in onPlayerJoined, onPlayerLeft and onPlayerAction is true for a bot and false for a person, and multiplayer.isBot(participantId) answers the same from any script (false outside a room). Use it to leave bots off a leaderboard or out of a "last player standing" count, never to give a bot different rules — the point of a bot is to test the rules your players get.
Script a bot
A bot added with the Script (onBotThink) brain asks your scene script what to do. Define onBotThink(bot, dt) there: the room's server calls it for each such bot 30 times a second, once onPlayerJoined has announced the bot. bot is the table onPlayerJoined received and dt is in seconds. Return nil for no input, or the input a person would give, which the bot's control script then reads as input:
-- Scene script: walk each scripted bot toward the ball.
function onBotThink(bot, dt)
local mine = multiplayer.getControlled(bot.id)[1]
local ball = scene.getObjectByName("Match Ball")
if not mine or not ball then return nil end
local from = scene.getObject(mine).getPosition()
local to = ball.getPosition()
return {
aimYaw = math.atan(to.x - from.x, to.z - from.z), -- face the ball
moveAxisY = 1, -- and walk forward
}
end
Steer with aimYaw (radians, from +Z toward +X, as a control script reads input.aimYaw) and moveAxisY, the way a player's stick and camera do. A bot can also ask for outcomes: authorityActions = { { name = "pass", payload = { power = 0.5 } } } raises each as if the bot had called multiplayer.sendAction, checked against your action schema, and it reaches onPlayerAction with the bot as the participant. See the Lua scripting overview for every field. An error is reported like any callback's and gives the bot no input that tick. Keep it cheap: it runs for every bot thirty times a second.
To kill a player, despawn their character: it comes back three seconds later where it first spawned, still theirs. A respawn goes back to whoever controlled the copy when it ended, and is dropped if that player has left the room.
Any spawned character or vehicle can be played, and any player can be given one later with multiplayer.setController. Until someone holds it, the room server runs its script with no input, so its script is a control script under the same rules as a player's — Write control scripts that can be replayed — even when it drives an enemy nobody plays.
Let players choose in the lobby
A game can let each player choose before the room starts: which character to play, which team to join, what colour to wear. Declare the choices in the project's Lobby choices setting, or with lobby_choices through MCP, and republish. The lobby shows them, in the order you list them, beside the ready button.
[
{ "name": "character", "label": "Character", "type": "enum", "required": true, "default": "knight",
"options": [
{ "value": "knight", "label": "Knight", "template": "Knight" },
{ "value": "mage", "label": "Mage", "template": "Mage" },
{ "value": "captain", "label": "Captain", "template": "Captain", "unique": true }
] },
{ "name": "hue", "label": "Colour", "type": "number", "required": false,
"minimum": 0, "maximum": 360, "integer": true, "default": 200 }
]
- Enum choices list options, each a
value(a lowercase name) and alabel. Any number of players may pick the same option, so two players can both be knights. Give an option amaxfrom 1 to 8 to cap how many players may hold it at once: a team of four is one option with"max": 4. The lobby shows how full a capped option is, such as 3/4, and closes it when full — except that in a test room a bot's place in it goes to a person who picks it (see "Test in a room").uniqueis the same as amaxof 1, and the lobby shows who holds it. - An option's
imagenames an image asset of the project (PNG, JPEG, WebP or GIF), by the idassets_listgives. The lobby shows options with images as picture cards. To show a character, take a screenshot of it in the editor, upload it as an asset, and use that asset's id. Publishing refuses an image that is missing or is not a picture. - Number choices take an optional
minimum,maximumandinteger. Without bounds a number may be anything from -1,000,000 to 1,000,000. With both bounds the lobby shows a slider. - An option's
templatenames the spawn template it stands for. Publishing refuses a name no published scene has as a spawn template. - Each player starts on a choice's
default. A player cannot ready up until everyrequiredchoice is made, and changing a choice un-readies them. There is no free text. - Choices are fixed once the room starts. A player joining a started room makes every required choice on the join form before they get in.
The scene script reads each player's choices as participant.choices, a table keyed by choice name. In a game with lobby choices, onPlayerJoined runs for everyone once the room starts rather than as each player enters the lobby, so the choices it sees are final:
-- Scene script.
local TEMPLATES = { knight = "Knight", mage = "Mage", captain = "Captain" }
function onPlayerJoined(participant)
local template = TEMPLATES[participant.choices.character] or "Knight"
multiplayer.spawn(template, (participant.playerSlot - 1) * 3, 0.1, 0, {
controller = participant.id,
variables = { hue = participant.choices.hue or 200 }
})
end
Any script can read anyone's choices with multiplayer.getLobbyChoices(participantId): a name tag or a scoreboard, say. It returns an empty table for a player who chose nothing, and nil for someone not in the room.
Control several objects at once
A player may control up to four objects at once — a tank's hull and its turret, a squad, or their character and the car they climbed into — and a room up to 16. One stream of that player's input drives every one, and each object's control script decides what to make of it: the hull reads movement, the turret aim.
- One object is primary. The camera follows it. It is the first object the player was given, until
multiplayer.setPrimary(participantId, objectId)names another (ok, reason:unavailable,unknown_participantornot_controlled); when the primary goes, the oldest left takes over.multiplayer.isPrimary(objectId)says whether an object is its controller's primary, andmultiplayer.getControlled(participantId)lists what a player controls, primary first. - Every object sees the same press. A control script that turns a press into
multiplayer.sendActionshould do it only whilemultiplayer.isPrimary(gameObject.id), or the action is sent once per object. - A handed-over object starts late. It receives its player's input a few steps after it is handed over, so a button still held from the press that handed it over reads as a new press. Ignore presses for a moment after an object becomes primary, counting the time from
deltaTime.
-- The Car template's control script, after its driving code.
local wasDown = false
local primaryFor = 0
local drive = update
function update(deltaTime)
drive(deltaTime)
if multiplayer.isPrimary(gameObject.id) then
primaryFor = primaryFor + deltaTime
else
primaryFor = 0
end
local down = input.actions.drive == true
if down and not wasDown and primaryFor > 0.5 then
multiplayer.sendAction("drive", {}) -- the scene script lets them out
end
wasDown = down
end
A player who controls nothing — between lives, say — can still send actions.
Spawned objects and scene changes
Spawned objects, and respawns still waiting, end with the scene they were spawned in. The destination's scripts spawn what it needs, usually each player's character from onPlayerJoined, and ids carry on numbering rather than starting again. Avatar keys kept with keep follow each player onto their primary object, waiting until their onPlayerJoined has run so that a character spawned there receives them; a player who still controls nothing then loses them. A player who leaves the room is released from everything they controlled, which stays in the world.
Republish, and keep templates simple
Every release can spawn; a game published before 8 October 2026 must be republished once first. Publication refuses more than 32 templates in a scene, two templates with the same name, a player slot on a template, a script on any part of a template but its root, and a template that is or contains something a room cannot copy: a camera, a GUI element, a constraint, a ragdoll, cloth or another soft body, a height field, or water. A character or vehicle template's script is checked as a control script. A scene may place at most 32 objects declared "authority"; the room shares up to 48, so a scene at its limit can still spawn.
Rounds, respawns, and scene changes
A round can end in place or with a scene change. To carry on in the same scene, the scene script moves players back to their spawn points and resets the state you track with setGameState. To start again from the scene as it was authored, or to move everyone to another scene, it calls scene.restart or scene.loadScene — see Change scene or restart the round.
The scene script can move a player's character directly with setPosition, and that player's screen is corrected to match.
-- Scene script: respawn anyone whose health has run out.
function update(deltaTime)
for slot = 1, 8 do
local player = scene.getObjectByName("Player " .. slot)
local state = player and multiplayer.getAvatarState(player.id)
if state and state.alive == false then
local at = spawns[slot] -- recorded from each player's position in init()
player.setPosition(at.x, at.y, at.z)
player.setCharacterVelocity(0, 0, 0)
state.alive, state.health = true, 100
multiplayer.setAvatarState(player.id, state)
end
end
end
Change scene or restart the round
The scene script may move the whole room to another scene of the game with scene.loadScene(idOrName, options), naming the scene by its id or its name, or rebuild the current scene as it was authored with scene.restart(options). Every player moves with the room, and the roster, player slots and connections stay.
- Only the scene script decides. Publication refuses either call in a control script or an object declared
"authority"(unsupported_multiplayer_scene_change), and a presentation script's call is a refused gameplay call. Players ask with an action — a "play again" button sendsmultiplayer.sendAction— and the scene script'sonPlayerActionchooses whether to honour it. The game menu offers no Restart in a room. - Nothing carries over unless you keep it.
options.keeplists, for any of the four partitions —game,participant,avatarandprivate— the state keys the next scene starts with. Every other key starts empty, and objects, projectiles, held props and who controls what are rebuilt from the destination scene. Avatar keys follow the player who controlled the object onto whatever they control in the destination — their primary object, if they control several — and wait for that player'sonPlayerJoinedto run, so a character spawned there receives them; they are dropped if the player still controls nothing then. Private keys stay with their owner.scene.restart()with nokeepresets every partition. - Both return
ok, reason. A refusal changes nothing.reasonisunknown_scene(no scene has that id or name),transition_pending(a change is already under way),rate_limited(more than 6 changes in a minute),invalid_keep(keepnames something other than the four partitions, or a list that is not up to 64 lower_snake_case keys),not_scene_script,not_authority(called in a player's browser), orunsupported(the room cannot change scene at all).
-- Scene script.
function onPlayerAction(participant, name, payload)
if name == "play_again" and multiplayer.getGameState().phase == "over" then
scene.restart({ keep = { participant = { "wins" } } })
end
end
The call returns straight away and the rest of that step runs; the state is kept as it stands at the end of the step. The old scene then stops — no physics, no update, no callbacks, and no player input or actions accepted — and every script's destroy() runs. Use destroy() only to tidy up locally: it cannot write state or change scene. The destination's scripts then start as they do when a room begins. The kept state is already in place when init() runs, so read it with the usual getters; then onPlayerJoined announces everyone present, and onPlayerLeft anyone who left during the change. A setter still replaces the whole partition, so add to kept state by reading it, changing it and writing it back.
Each player sees Loading the next scene, then Waiting for other players with a count. The room waits up to 20 seconds for every connected player to finish loading, then carries on. A player still loading stays in the room and catches up when they are ready, and a player who joins during or after the change arrives in the new scene. Actions sent from the old scene are dropped. On the room server, the scene script and objects declared "authority" get a math.random reseeded at every change, so a restarted round does not draw the same numbers as the last one. multiplayer.getSceneRevision() counts the changes — 0 for the scene the room started in, one more for each change, a restart included — so a HUD can show the round number and a scene script can tell a restart from a first start.
Republish, and write the calls plainly
Every release can change scene, however the scene script reaches scene.loadScene or scene.restart; a game published before 8 October 2026 must be republished once first. Publication reads only literal calls, though: it warns (unknown_multiplayer_scene_target) when a quoted scene name matches no scene in the game. If the room server cannot build the destination scene, or takes more than 30 seconds to, the room ends for everyone; it does not go back to the old scene.
Objects played by a bot
A character or vehicle whose Played by setting is a bot (Wander, Roam, Script or Idle, or set with setBotBrain) is an NPC. It is not a participant: it has no slot, no lobby seat, and never reaches onPlayerJoined. These are different from the test bots a test room seats as players; see Test in a room. In a room:
- The room server plays a bot object while no player controls it: its brain feeds its control script, there, and its movement replicates to every player. A player's browser never runs a brain.
- An unslotted NPC needs
multiplayer.scriptRole: "authority"like any other scripted object;"presentation"is refused on a character or vehicle. - A slotted bot object is a fill-in: its brain plays the seat until a player holds it, and from then on that player's commands do. The same goes for an object handed over with
multiplayer.setController. - Copies of a spawn template keep its Played by setting. A copy spawned with a
controlleris that player's; one spawned without is an NPC. - A Script bot's
onBotThinkruns only on the room server, with thebottable{id, objectId, isBot = true}. ItsauthorityActionsare ignored, because an NPC is no participant foronPlayerActionto hear them from.
NPCs in rooms need the multiplayer server release from 20 October 2026 or later. On an older one, a bot object nobody controls just stands still, and a slotted one still plays for its player; nothing breaks either way.
Know the remaining limits
- Animation configuration a script chooses at runtime does not replicate: other players see a character animated with its room animation, or the default clip names without one, and a clip a presentation script plays is seen only on that browser. Use an event if every player should see the same emote.
- Bone sockets are presentation-only: an object held at a bone with
attachToBoneexists only on each browser's screen, and the call does nothing on the room server. There is no networked melee primitive; a sword's hit must be resolved by the room server, not by the blade's trigger. - Ragdolls are not replicated: publication refuses a ragdoll as a replicated prop, a presentation object or a spawn template. Ragdoll control from Lua (
setRagdollMode, bone impulses, motor strength) changes only the copy on the room's server, so it is for single-player games. - A kinematic moving platform needs an authority script (
multiplayer.scriptRole: "authority"): the room server moves it and carries the players standing on it, and every browser draws it where the server puts it. The rider's own browser predicts them moving with the platform as well, so they ride it smoothly on their own screen too. That includes a turntable. To turn a rider's facing with it, callgameObject.rotate(0, gameObject.getGroundAngularVelocity().y * deltaTime, 0)in the control script rather than adding to a yaw kept in a Lua local: the engine rewinds the controlled object's pose together with the script's Lua locals and scheduled callbacks during replay, and rotating from the current pose keeps this step's turn aligned with that pose. Declared"presentation", it has no collider and players fall through it. scene.createObjectandscene.cloneObjectmay only be called by an authority script, and the objects they create are not shared with players. Usemultiplayer.spawnwith a spawn template for an object everyone sees. Configured combat projectiles use the replicated projectile system instead.- Only a spawn template's root may run a script, and nothing joins two spawned objects with a constraint.
- There is no slow motion in a room. The room's simulation is shared by every player and runs at a fixed rate, so
scene.setTimeScaleraises an error in any script there andscene.getTimeScale()is always 1. Publication warns withmultiplayer_time_scale, naming the lines, when a multiplayer game's scene script callssetTimeScale. Keep slow motion to single player, and show a moment to one player in a room with camera, GUI or sound effects instead. - Only the scene script may change scene, at most 6 times a minute, and only the state it lists in
keepsurvives the change. - Weapon pickup and equip systems use participant/avatar state and presentation logic; there is no dedicated equip primitive.
gameStorageis each browser's own device storage, available to presentation scripts only. There is no storage shared by the room.
Reconnecting, leaving, and the room server
A reconnecting participant normally keeps the same room membership and stable slot. The client renews short-lived credentials automatically and stores a rotating reconnect ticket in that browser session. A player who reloads rejoins, receives the current state, and controls their character again.
Republishing during play. Republishing does not disconnect rooms that are already running. Each room keeps the release it started on until it ends, and rooms created after the republish use the new one. A player who reloads into a room still on the old release cannot rejoin it; they are told This game was updated — reload to start a new room., with a Reload button. While a connection is coming back the room status reads Reconnecting…, and a room that is over reads This room has ended.
Because the room server runs the game, any player may leave — including the one who created the room — and the room carries on for everyone else. To leave, choose Leave multiplayer in the game's menu: it takes you out of the room, and onPlayerLeft fires for you, before you are back at the lobby. Closing the tab only disconnects you, and your seat is kept so that reloading rejoins you. Once you have left a room you cannot join that same room again. The room ends when its last player leaves, or when every player has been disconnected for about a minute. A room's invite code is valid for 15 minutes before the room starts, and a started room lasts one hour on the Free plan and up to four hours on Creator and Studio. Room size and length are read from the publishing account's plan when each room is created, so an upgrade applies from the next room without republishing. A plan also limits how many rooms can be open at once across all of its games — one on Free, so a second group gets a room once the first one ends. A player who cannot open a room, or who finds one full, is told which limit stopped them, and the game's creator is offered the upgrade. When a room reaches its time limit the game says so, and the lobby it returns to says whether the creator's plan set that limit.
While a browser waits for the room server it shows Reconnecting to room server…. If the room server itself is lost during play, or stops responding — for example because a script never returns — the room ends: within about 30 seconds for a script that never returns, and within about two minutes if the server process does not come back. It does not resume with a reset world, and no player's browser takes over.
Runtime limits
- 1 to 8 participants per room (up to 4 on the Free plan), with capacity for at least 2.
- A started room lasts up to 1 hour on the Free plan and up to 4 hours on Creator and Studio.
- Rooms open at once, waiting or started, across all of a creator's games: 1 on the Free plan, 3 on Creator and 10 on Studio.
- Up to 16 player-controlled characters and vehicles in a room. A player controls up to 4 at once.
- Up to 32 scripted objects declared
"authority"placed in a scene, each updated up to 10 times per second. The room shares up to 48 such moving objects, placed and spawned together. - Up to 32 spawn templates per scene, and up to 32 spawned objects live at once.
- Vehicles with at most 10 wheels.
- Up to 16 live projectiles per room. A bouncing projectile rebounds at most 16 times, and a piercing one passes through at most 8 players.
- The room server simulates at 60 steps per second and sends state 20 times per second; browsers send input 30 times per second.
- Input frames: 4 KiB. Output frames: 8 KiB.
- Each state partition is bounded to 768 encoded bytes, and game, participant and avatar state together to 4 KiB. Tables hold at most 64 entries, nest at most three levels, and strings are at most 256 characters.
- Up to 32 declared authority actions of up to 16 fields each; up to 16 pending per player, and at most 32 handled per player per second.
- Up to 32 declared presentation events; each player can have 64 owner and 64 observer events waiting.
- Hitscan shots are traced up to half a second into the past.
- Up to 6 scene changes a minute. The room waits up to 20 seconds for players to load the next scene, and up to 30 seconds for the room server to build it.
Use an authored main menu
The project page's Game startup section can select a Menu scene in place of the built-in solo/multiplayer start screen. Keep it separate from the gameplay entry scene and give it a camera and GUI buttons. A solo button calls player.startSolo(); a multiplayer button calls player.openMultiplayer(). See Menu scenes and player actions for the full button script and return values.
The menu runs locally before play. Its multiplayer button opens the app-host picker for the published release, where the existing sign-in, profile, lobby choices and room flow still apply. It does not embed a room browser. Once a player joins a room, the room bypasses the menu and starts from the gameplay entry scene. Republish to update the live menu and loading appearance.
Local Preview cannot open a published picker: player.openMultiplayer() returns false, "multiplayer_unavailable". Solo entry uses slot 1. To test the draft in a real room while your authored menu is running, open Preview's top-left menu and choose Test in a room. The same command is available during solo preview. Your menu selection stays in place. The default start screen still has its inline test panel and player-slot chooser. MCP clients can also use multiplayer_test_start.
Publish and test
- Configure multiplayer, actions, weapons and projectiles, presentation events, each object's Multiplayer settings (player slot, script role, replication, weapons), scripts, and the camera.
- Publish or republish, and resolve anything the Publishing section refuses. Existing releases do not acquire changed multiplayer configuration.
- Use separate signed-in browser profiles to create and join a room.
- Test every supported slot, room size, authority action, weapon template, and presentation event.
- Confirm aim, hitscan, projectiles, damage, alive state, private state, events, refused-shot feedback, and remote presentation on every browser.
- Hand a vehicle to a player and back, and confirm their camera follows.
- If the game changes scene, change scene and restart with every player connected, then join late, and confirm everyone arrives in the same scene with only the state you kept.
- If the game spawns objects, join late and confirm you see every live copy and none that has gone; let a respawning copy be despawned or fall off the level and confirm it comes back for every player; and hand a spawned vehicle to a player and back.
- Send a preset message from the lobby and from inside the game, and confirm every player sees it — along with anything your own scripts do in
onPresetMessage. - Reload a player and confirm they rejoin the same slot; then have the player who created the room leave and confirm the room carries on for the rest.
- Review desktop/mobile behavior and browser-console errors.
Editor Preview of a multiplayer game is single-player. Its default start screen, Single-player preview — playing as slot N, only chooses which player slot your controls drive, so it is useful for checking a slot's control script, camera and HUD layout. There is no room: no room server, no other players and no networking. So in Preview onPlayerJoined never fires; getGameState, getParticipantState, getAvatarState, getPrivateState and the set…State calls raise "multiplayer state requires a multiplayer room"; sendAction returns false, "unavailable"; and multiplayer.spawn returns nil, "unavailable" and copies nothing, with or without a controller, so a game that spawns each player's character in onPlayerJoined shows no player character in Preview. Preview also does not prove credentials, prediction over a real network, or reconnect behavior. Test all of these with Test in a room, below, or in a room on the published URL. See Publish and test a game.
Test in a room
Test in a room is available on Preview's default start screen and in the top-left Preview menu while an authored menu or solo preview is running. It runs your current draft in a real room on a real multiplayer server without publishing it. It is available on the Creator and Studio plans. Press Test in a room (or Test again after an earlier run): the editor builds a test build of the draft — checked and built exactly as a publish is, but never made live, so players of the published game see no change — and opens a room for it. Your player window opens on the room's lobby. Make your lobby choices there and press I'm ready; the game starts when everyone in the room is ready, or when you press Start game in the panel once they are. From then on the room runs as any published room does: onPlayerJoined fires, the state calls and sendAction work, and multiplayer.spawn with a controller spawns each player's character.
Opening the test panel from Preview's menu pauses the local simulation. You can return to Preview while the test builds or runs, then reopen the panel to manage the same room. Closing the panel does not stop the room or cancel the player window. An existing manual pause stays in place when you close it. Use Stop in the panel to end the test room.
- Shared settings. The lobby owner chooses the map or other shared rules before Start. A test lobby with shared settings can be ready before its server is assigned; Start locks the choice and then loads its selected scene.
- Other players. Members of your team join with the panel's Invite a member link or its QR code, signed in on another browser or device. Nobody else can join, and a test room cannot be joined with a join code. Once the game has started, the link joins them late if the game allows late join. If the game has required lobby choices, the link first shows those choices as the lobby does: they pick, press Join the game, and go straight into the running game.
- Your game's rules still apply. The room needs the game's minimum players and every required lobby choice before it starts; there is no solo start below the minimum, but bots can fill the seats. When it cannot start yet, the panel says what is missing, such as "The room needs 2 players to start; 1 is here." or "PoodleDoodle hasn't picked a team."
- Bots. Add bot seats a bot that the room's own server plays, up to three a room, so you can test a game whose minimum players or teams need others on your own. Plays picks its brain:
- Wander walks about at random with the odd jump and
primaryFire. - Idle sends no input.
- Roam walks its character between random reachable points on the scene's navigation mesh, along the routes
scene.findPathgives, and wanders where the scene has no mesh. It picks each point within 4 to 14 m of its home, where your game last put its character, so it stays near there; the mesh is everywhere walkable, not your play area. Home moves whenever the game places the character somewhere new, such as at kickoff or a respawn. When it gets stuck, or finds no route, it walks straight back toward home for a few seconds, which brings it down off a ledge or out of a corner. It works with either kind of control script: one that turns the body towardinput.aimYawand moves forward, or one that moves along fixed world directions and ignores the aim, as a game under a fixed camera does. It watches which way its input actually moves the character and adjusts within a couple of metres. - Script (onBotThink) lets your scene script play the bot: see "Script a bot" below.
onPlayerLeftfires and its character is released as for a player who left. Bots test your game's rules and scripts, not the network path a player's browser takes, so play it with people before you publish.- Bots give way to people. A person who picks an option that is full only because a bot holds a place in it gets the place: the most recently joined such bot moves to another option with room, or leaves if there is none. A person joining a room whose seats bots fill takes a bot's seat. Nobody is refused because of a bot. So the lobby, and the invite link's choices for a late joiner, keep an option open while only bots fill it, even when it shows as full; only places people hold close it. A bot that moves after the game has started changes its choices mid-game, so read
participant.choicesormultiplayer.getLobbyChoicesagain rather than assuming a bot's team is fixed. - Suggested test bots. In the project's multiplayer settings, Suggested test bots sets how many bots this game suggests, the brain they play and the lobby choices they take. The panel then offers Add N suggested bots, and Add bot uses the suggested brain and choices. You still decide whether to add them, and the setting is not part of releases.
- A bot that stands still can hold your game up. If your game waits for every player to move or to send an action before it starts a round — Futbol Fan waits for each player to move once — an Idle bot, or a Script bot in a game with no
onBotThink, never does, and the round never starts. Use Wander or Roam, or haveonBotThinksend what your game waits for.
- Wander walks about at random with the odd jump and
- The panel. It shows the build's progress, each player with their choices and whether they are ready and connected, and the Lua errors the room's server catches. Those errors are kept out of the published game's script errors. Stop ends the room for everyone; Test again starts a new one, reusing the last test build if nothing has been edited since.
- Limits. A team can have one test room open at a time. If another project's test room is open, Start test is refused with a message naming that project, such as "Your team already has a test room open for Kart Rally. Stop it first.", and the panel offers Open Kart Rally, which opens that project in a new tab, and Stop that room, which asks you to confirm and then ends that room for everyone in it so you can test this project. A test room runs for at most 15 minutes, a team gets 6 starts an hour, and test rooms do not count toward your plan's rooms at once. Each plan includes test-room minutes every day (UTC): 60 on Creator and 120 on Studio, counted as server time from when a server takes the room until the room ends. Published games are served first: a test room may wait for a server, the panel says so, and it shows No servers available when none can be had.
- Extra minutes from credit. When the included minutes are used up, a new test is refused unless an account admin has turned on Pay for extra minutes from credit under Test-room minutes on the Subscriptions page. With it on, testing continues at the price shown there (currently $0.02 a minute of server time), paid from the credit balance. Each test room is charged by the second when it ends, and never for more than the extra time it was given when it started. An optional Daily limit for extra minutes caps what they may cost each UTC day; with no limit, only the credit balance does. Extra minutes buy time only: the 15-minute room, one test room at a time and published games being served first all still apply. The Test in a room panel shows the included minutes left today and, with credit turned on, the price, the daily limit and what has been spent today.
- From an AI assistant. An MCP client can run the same test with
multiplayer_test_start, follow it withmultiplayer_test_status— the stage, the players and their choices, what is stopping the start, and the room's Lua errors — and end it withmultiplayer_test_stop. It can seat bots when the room opens (botsorfill_to_minimum) and add or remove them later withmultiplayer_test_add_botandmultiplayer_test_remove_bot. Withbot_timelinesit can seat bots that replay a script it writes: timed input, such as hold forward or turn right; authority actions checked against your action schema up front; and a join and leave time. Each bot then reports what it sent and what the room refused. A timeline bot drives your control script the way a person's keys would, so whether "forward" follows the aim or a fixed world direction is up to your script. Its aim starts the way its character faces. People still join through the invite link. See the MCP tool guide.