Multiplayer
Every session, including single player, has a server and one client per player. Your mod's server script runs once on the host. Its client script runs on every player's game, including the host's. They share no variables. They talk through the net module.
See the Host Tools example for all of this in one mod.
Messages
if not net then return end -- client scripts also run in the main menu, where there is no net
net.send("vote", { choice = "desert" }) -- to the server copy of this mod
net.on("result", function(data) -- from the server
notify.toast("The winner is " .. data.winner)
end)local votes = {}
-- net.handle checks the data's shape and how often each player sends before your handler runs.
net.handle("vote", { choice = "string" }, function(player, data) -- server handlers get the sending Player first
if data.choice == "" then return false, "Pick a map." end -- answered on "denied" with { reason }
votes[player.id] = data.choice
end, { rate = 2 })
timer.after(60, function()
net.broadcast("result", { winner = count(votes) }) -- to every player
end)| Function | Where | Sends to |
|---|---|---|
net.send(channel, data, reliable?) | client | the server copy of your mod |
net.sendTo(player, channel, data, reliable?) | server | one player's client copy |
net.broadcast(channel, data, reliable?, except?) | server | every player, optionally skipping one |
net.on(channel, handler) | both | server: handler(player, data); client: handler(data) |
net.handle(channel, schema, handler, { rate, burst, deny }) | both | net.on with checks: see below |
Checking what clients send
A client can be modified, so a server handler must check everything it receives. net.handle does the routine part:
- schema maps field names to types:
"string","number","integer","boolean","table","vec3","quat","color","Player"or"any".?makes a field optional ("string?"),[]makes it a list ("string[]", at most 4096 items).nilaccepts any data. Extra fields are allowed. - rate is how many messages per second each player may send on this channel (
burstextra at once). - A message that does not match, or comes too fast, never reaches your handler. On the server the sender gets a message on
deny(default"denied") with{ reason, channel }; returningfalse, reasonfrom the handler sends the same. On a client, bad messages from the server are logged once and dropped.
net.handle("move", { blocks = "string[]", offset = "vec3" }, function(player, data)
if #data.blocks > 64 then return false, "Too many blocks." end
-- …
end, { rate = 20 })net.on("denied", function(data) notify.toast(data.reason) end)- Channels are names you choose, up to 64 bytes. They are private to your mod.
- Data may be tables, strings, numbers, booleans,
vec3,quat,colorandPlayerhandles, nested up to 16 levels. Functions and other handles are not sent. reliable = falsesends quickly without resending lost messages. Use it for frequent position updates, where the next message replaces a lost one.net.isConnected(),net.isHost()andnet.isServer()tell you where you are.
Limits
Messages from each player are rate-limited per mod, so a buggy or malicious client cannot flood the server:
| Limit | Value |
|---|---|
| Messages | 60 per second, bursts up to 240 |
| Data | 256 KB per second, bursts up to 1 MB |
| One message | about 64 KB |
| Replicated state | 1 MB and 4096 keys per mod |
Messages over a limit are dropped. A client that keeps sending malformed data is disconnected.
Replicated state
-- server
net.state.set("round", { number = 2, endsAt = time.unix() + 120 })
-- client
net.state.watch("round", function(round) roundLabel:setText("Round " .. round.number) end)
local current = net.state.get("round")State is a set of keys the server writes and every client receives, including players who join halfway through, who get a snapshot. net.state.onChanged(fn) watches every key. Setting a key to nil removes it. State lasts for the session.
Players
for _, p in ipairs(players.all()) do log.info(p.id, p.name, p:isHost()) end
local me = players.me() -- nil on a dedicated server
local host = players.host() -- nil on a dedicated server
events.on("player.joined", function(player) net.sendTo(player, "welcome", {}) end)
events.on("player.left", function(player) --[[ the Player is no longer valid here ]] end)player:position() and player:rotation() read where a player is (needs players). On the server, player:teleport(position, rotation?) moves them (needs players.teleport). On a client, player:highlight(colorOrMaterial) outlines them for this player only (needs world).
Keeping a mod cheat-proof
A client script runs on the player's own computer, and a player can change it. Write every multiplayer mod assuming the client lies:
- The client asks, the server decides. Put rules, permissions and changes to the world in
server.lua. - Check everything a client sends: types, sizes, ranges and ids. A
net.handleschema checks the types; your handler checks the rest. With plainnet.on,type(data) ~= "table"comes first. - Trust
player, not data. Theplayerargument comes from the connection. A client cannot fake it. Adata.playerIdfield can be anything. - Don't take numbers that matter from the client. The Thruster Tuner receives which block, not the new limit, and computes the limit on the server.
- Rate-limit expensive requests beyond the game's limits, with
net.handle'srate. See Block Mover. - Check who is asking:
player:isHost()for host-only actions.
API functions that change the world are server-only for this reason, and the game performs its own ownership checks. building.moveInto, for example, refuses to move blocks on a creation the player does not own.
Testing multiplayer
The game runs once per computer, so test on two computers, or with a friend: host on one and join from the other. Joining offers to download your mod if the other copy doesn't have the identical version. Use /mods.log in each copy's developer console, or the script editor's Log tab, to watch both sides. On the host, the script editor's Client / Server switch runs code in either copy of your mod.
Mods are not reloaded while another player is connected, because the two games would run different code: the script editor saves your file without reloading the mod, and /mods.reload is refused. Change your scripts while you play alone, then test together.
Downloads when joining
When a player joins, the server lists the mods its session runs (everything except "network": "local" mods). For each one the player either has the identical package (same content hash), has it cached from an earlier download, or needs to download it. Before anything downloads, the player sees the list, the sizes and the permissions, and chooses Download and join or Leave. Downloads are checked file by file against the hash, stored separately from the player's own mods, and only used for sessions that list that exact hash. See Publishing, and Joining a modded server for the player's side.