Talking across the boundary#
The server owns the tree and each client holds a mirror of it. Property changes travel one way on their own, server to client, and nothing a client writes to its own mirror goes anywhere. This page is the other direction: how a client tells the server something, how one side asks the other a question and waits for the answer, how scripts on one side talk to each other, and how you share a value that is not a property of anything.
A channel is an instance#
A Remote is a channel you send messages down. You create one the way you create anything else:
-- server
local hit = world:add("Remote", { name = "hit" })It has a name, a parent and a lifetime. "Does this channel exist" is the same question as "does this
part exist", and destroying whatever it hangs off closes it. You find it the way you find anything:
game.world:find("hit").
What a message may carry#
A remote refuses any argument it was not told about. accepts lists them in order, and ? marks one
that may be left out:
local pressed = world:add("Remote", { name = "pressed", accepts = "string" })
local aim = world:add("Remote", { name = "aim", accepts = "vec3, number?" })pressedtakes one string and nothing else. A client that sends a number down it is refused.aimtakes avec3and then a number the sender may leave out.
Kinds: bool, number, string, vec3, quat, cframe, color, udim2, instance, list,
table. An empty accepts means the message carries nothing.
Sending a message and hearing one#
Each side has its own verb for saying something and its own signal for hearing it, and the two directions are not the same shape:
| says | hears | |
|---|---|---|
| client | remote:fireServer(...) |
remote.onClient:connect(function(...) end) |
| server | remote:fireClient(body, ...)remote:fireAllClients(...) |
remote.onServer:connect(function(body, ...) end) |
Each verb is refused on the side it doesn't belong to, by name. Calling fireServer on the server is
a mistake, and being told beats a message that goes nowhere.
A message leaves the server with the tree changes of the same tick, and arrives after them. An instance you create and then send, or a part you move and then announce, is already there when the client hears about it.
A message that reaches a client before any script there has connected to onClient waits for the
first connection, up to 64 per remote; past that the rest are dropped. That covers the usual race of
a server firing in joined while the client's scripts are still starting. Once something has
connected, a message nobody is listening to is gone.
The sender is handed over, never passed. onServer receives the body of whoever sent it as its
first argument, supplied by the engine. A player is addressed by its body here, the same way
game.players:me() answers with one. A client cannot claim to be somebody else, because it never
says who it is.
A player can have no body: before player:spawn() with game.players.autoSpawn = false, or while
dead. onServer then hands over nil. onServerPlayer fires for the same message with the
Player instead, which always exists:
play.onServerPlayer:connect(function(player, choice)
if choice == "start" then player:spawn() end
end)Use onServerPlayer whenever the message is about a player rather than about a body, such as a menu
choice, a settings change, or anything sent before the player has spawned.
game.players:list() gives every Player, game.players:byId(player.id) finds one again later, and
game.players:playerOf(body) turns a body into its player.
Everything after the sender is a claim by the client. Check it before you act on it:
hit.onServer:connect(function(from, what, howHard)
if typeof(what) ~= "string" or typeof(howHard) ~= "number" then return end
if howHard <= 0 or howHard > 10 then return end
from.humanoid.health -= howHard
end)When you don't care if it arrives#
An UnreliableRemote is a channel that is allowed to drop messages and deliver them out of order:
local looking = world:add("UnreliableRemote", { name = "looking" })Use it for something that is continuously replaced, where the next one is along shortly: a cursor, a look direction, a held key. A message that is lost is a frame of staleness and no more.
Today every message goes over Minecraft's own connection, which is ordered and never loses
anything, so an UnreliableRemote arrives exactly like a Remote, costs the same, and is dropped
only by the same per tick limit. Write your code as if any message can go missing anyway: that is
the promise, and a later version may stop keeping them.
Unreliable delivery is its own class rather than a flag on Remote, so a purchase cannot go down an
unreliable channel because somebody passed the wrong boolean.
Never send a score, a payment or anything else that must arrive exactly once down an
UnreliableRemote. The engine will not tell you when one goes missing.
Asking and waiting for the answer#
A Remote says something and moves on. A RemoteFunction asks, waits, and hands back what the
other side returned:
-- server
local buy = world:add("RemoteFunction", { name = "buy", accepts = "string" })
local prices = { sword = 50, shield = 30 }
local coins = {}
buy.onServerInvoke = function(player, item)
local price = prices[item]
if price == nil then return false, "that is not for sale" end
local have = coins[player.id] or 0
if have < price then return false, "you need " .. price - have .. " more coins" end
coins[player.id] = have - price
return true, "you bought a " .. item
end-- client
task.spawn(function()
local bought, message = game.world.buy:invokeServer("sword")
print(bought, message)
end)onServerInvokeis assigned, not connected. It is one function.- The handler is handed the
Playerfirst, then the arguments the client sent. Both of its returns arrive on the client as the two valuesinvokeServergives back. - The client's call sits inside
task.spawnbecause it waits for the answer. - The prices and the coins live on the server only. The client learns that a sword costs 50 by being told, which is the point of asking rather than deciding locally.
| asks | answers | |
|---|---|---|
| client | remote:invokeServer(...) |
remote.onClientInvoke = function(...) end |
| server | remote:invokeClient(player, ...) |
remote.onServerInvoke = function(player, ...) end |
accepts works the way it does on a Remote, and is checked on both sides. What the handler returns
is not checked against anything, but it has to be something that may cross.
The server's handler is handed the Player, the same way onServer is handed the body. A call that
did not come from a player is refused before your handler runs.
invokeClient takes a Player or a body, and asks that one client. An answer only counts when it
comes from the player that was asked.
Invoking waits, so it runs inside a function started with task.spawn. A handler can wait too, with
task.wait or a signal's wait, and the caller keeps waiting until it returns.
A handler is one function, not a signal: assigning another replaces it, and nil removes it.
When there is no answer#
Each of these raises an error in the caller, where pcall catches it:
- the handler errors, and the caller gets its message
- nothing is assigned to
onServerInvokeoronClientInvokeon the other side - the arguments don't match
accepts, or the answer can't cross - no answer arrives within
timeoutseconds, 30 by default;0waits forever - the remote function is destroyed while the caller waits
- the script that was answering stops first
invokeClientis asked about a player who has left, or one who leaves before answering. That holds withtimeout = 0too: waiting forever means waiting for an answer, not for somebody who is gone.- the call is over the rate limit, which fails at once with "too many calls" rather than waiting out the timeout
A client is told what went wrong, never where. A server handler that errors sends its message across without the file and line it failed on; the server log keeps those.
A client can close the game or never answer, so the server gives it a timeout and a pcall. Ask each
player from their own task.spawn, so one slow answer doesn't hold up the rest:
-- server
local ready = world:add("RemoteFunction", { name = "ready", accepts = "string", timeout = 10 })
for _, player in game.players:list() do
task.spawn(function()
local ok, said = pcall(function()
return ready:invokeClient(player, "Start the round?")
end)
if not ok then
print(player.name, "did not answer:", said)
elseif said then
player:spawn()
end
end)
end-- client: label is a TextLabel and yes a TextButton on a screen gui
game.world.ready.onClientInvoke = function(question)
label.text = question
return yes.activated:wait(8) ~= nil
endThe client's handler waits up to eight seconds for the button. wait(8) returns nil when nobody
presses it, so the handler answers false and the server spawns nobody. The server's own
timeout = 10 covers the case where the client never answers at all.
Calls count against the same limit as messages: 6 per remote per tick from one client. A call past it fails straight away with "too many calls", rather than leaving the caller to wait out its timeout. Answering a call the server asked for does not count, so a client that is asked ten times in a tick can reply to all ten.
What may cross#
A number, text, a flag, nothing, a Vector3, a Quat, a CFrame, a Color, an instance, and
lists or text-keyed tables of those.
An instance crosses as the id it is and is resolved on the far side; one that was destroyed on the way
arrives as nil, which is the ordinary race and not an error. An instance in accepts that arrives
as nil refuses the whole message, so write instance? when it should still arrive.
Anything else is an error that names what was allowed: a function, a table with number keys mixed with text keys, or an instance one side made locally and the other has never heard of. None of these is dropped quietly.
Limits: 16 arguments, 8 levels of nesting, 256 values per delivery, and 6 deliveries per channel per tick from any one client. Past the rate the rest of the tick is dropped and counted. A message to the server holds at most 32 KB and one to a client at most 1 MB; a bigger one is an error where it is sent.
Messages between scripts on one side#
messaging is a topic board. A script publishes to a name, and every script that subscribed to that
name hears it. Use it when one part of your code wants to say that something happened without
knowing who is listening.
local subscription = messaging:subscribe("rounds", function(message)
print("round", message.name, "in", message.seconds)
end)
messaging:publish("rounds", { name = "final", seconds = 90 })
subscription:unsubscribe()- Handlers run in order of subscription, straight away, inside the
publishcall. There is no queue and no tick of delay. - A message is any Luau value: it never leaves the machine, so none of the rules about what may cross apply.
- A topic is one to 1024 letters. An empty one is an error.
- Subscribing twice with the same handler subscribes twice, and each
unsubscribedrops one. Unsubscribing something already gone does nothing. - A subscription made by a
Scriptends when it is destroyed or disabled, like its signal connections and itstaskthreads.
messaging does not cross the boundary. The server has one board and each client has its own, so
a publish on the server is heard by server scripts only. Use a channel for the other side.
On the server, a handler gets a second argument, from: the game.serverId of the server that
published. A client's handler gets nil there. On one server from is always this server, so you
can ignore it.
It matters under a fleet, where a publish on the server also reaches every other server
of the game, a moment later. The message then has to be something a store could save. See
Messages between servers.
Shared state#
A value instance holds one value in the tree, which is how you share a number or a piece of text that is not a property of anything:
local score = world:add("NumberValue", { name = "score" })
local phase = world:add("StringValue", { name = "phase", value = "waiting" })
score.value += 1
score.changed:connect(function(property) print(score.value) end)There are six of them: NumberValue, StringValue, BoolValue, Vector3Value, ColorValue and
ObjectValue.
They replicate because every property does, so a client reads one straight off the tree without being
told anything, and hears it change without a channel.
changed gives you the property name, like every other instance, so you read .value off the
thing that changed. It does not hand over the new value.
