Talking between client and server
Lesson 15.1
The client draws and listens to keys; the server owns the world. When a key press should change the world, or the server wants to update a HUD, the two halves have to talk. They do it with payloads: small messages with your own name and content.
Register a channel
A payload travels on a channel with an id in your mod’s namespace. Register it on both sides, in lifecycle.register, with the function that receives it:
cl.on("lifecycle.register", function()
cl.net.payload_register{
id = "my_mod:ask_ruby",
dir = "to_server",
handler = function(sender, data)
-- runs on the server when a client sends my_mod:ask_ruby
sender:give("my_mod:ruby")
end,
}
end)
| Field | Means |
|---|---|
id |
The channel id. It must start with your mod id. |
dir |
"to_server", "to_client" or "both_ways" (the default). |
handler(sender, data) |
Receives the payload. sender is the player on the server and nil on the client. |
max_size |
The largest payload in bytes. The default is 32768. |
The handler runs in the game’s tick, like an event handler, so it can give items and place blocks on the server, or update HUD values on the client.
Send from the client
Pack the content into a buffer and send it:
local buf <close> = cl.buf.create(16)
cl.net.send_to_server("my_mod:ask_ruby", buf)
Even an empty buffer is a useful message: “the player pressed the ruby key”. The <close> frees the buffer right after the line.
Content: write and read in the same order
A buffer is a row of values. The sender writes them, the receiver reads them in exactly the same order:
-- client
local buf <close> = cl.buf.create(64)
buf:write_str("my_mod:ruby")
buf:write_i32(3)
cl.net.send_to_server("my_mod:ask_item", buf)
-- server handler
handler = function(sender, data)
local item = data:read_str()
local count = data:read_i32()
...
end
| Write | Read | For |
|---|---|---|
write_i32(n) / read_i32() |
Whole numbers | |
write_varint(n) / read_varint() |
Whole numbers, fewer bytes when small | |
write_i64(n) / read_i64() |
Very large whole numbers | |
write_f32(x) / read_f32() |
Decimal numbers | |
write_f64(x) / read_f64() |
Precise decimal numbers | |
write_str(s) / read_str() |
Strings |
data:remaining() tells you how many bytes are left to read. Reading past the end raises an error.
Send from the server
On the server, send to one player:
local buf <close> = cl.buf.create(8)
buf:write_i32(mana)
player:send_to_player("my_mod:mana", buf)
cl.net.send_to_player(player, id, buf) is the same call written as a function. To reach everybody, loop over cl.entity.server_players().
Never trust the client
A payload from the client is a request, not an order. Anybody can change their client and send anything. So the server checks every request:
handler = function(sender, data)
local count = data:read_i32()
if count < 1 or count > 3 then return end -- refuse silly amounts
if not sender:is_valid() then return end
sender:give("my_mod:ruby", count)
end
Ask yourself for every payload: what if somebody sends this a thousand times, or with a huge number? Limit amounts, add cooldowns, and check that the player is allowed to do the thing.
Both sides need the mod
Payloads only arrive where your mod is installed. A vanilla server ignores them, and players without your mod don’t get them. A mod that relies on payloads should say "sync": "required" in its manifest.
A complete round trip
A key on the client asks the server for a ruby, and the server answers with the new count for the HUD:
local hud_count = 0
cl.on("lifecycle.register", function()
cl.net.payload_register{
id = "my_mod:ask_ruby",
dir = "to_server",
handler = function(sender, data)
sender:give("my_mod:ruby")
local reply <close> = cl.buf.create(8)
reply:write_i32(1)
sender:send_to_player("my_mod:got_ruby", reply)
end,
}
cl.net.payload_register{
id = "my_mod:got_ruby",
dir = "to_client",
handler = function(sender, data)
hud_count = hud_count + data:read_i32()
end,
}
end)
cl.on("lifecycle.client_setup", function()
local key = cl.keybind.register{
id = "my_mod:ask_ruby",
default_key = "key.keyboard.r",
category = "key.categories.my_mod",
}
cl.on("tick.client_post", function()
while key:consume_click() do
local buf <close> = cl.buf.create(1)
cl.net.send_to_server("my_mod:ask_ruby", buf)
end
end)
cl.hud.register_layer{
id = "got",
render = function(g) g:text("Rubies asked for: " .. hud_count, 4, 4, 0xE0115F, true) end,
}
end)
In singleplayer both halves run in one game, but the messages still take the same path, so what works there works on a server too.
Try it
Add a cooldown to the round trip: the server ignores my_mod:ask_ruby from a player who asked less than 5 seconds ago. Use the player’s UUID as the key.
Show a solution
local last_ask = {} -- uuid -> game time of the last accepted request
handler = function(sender, data)
local now = sender.world:game_time()
local before = last_ask[sender.uuid]
if before and now - before < 100 then return end -- 100 ticks = 5 seconds
last_ask[sender.uuid] = now
sender:give("my_mod:ruby")
end
The game time counts ticks and never goes backwards, so it is a reliable clock for cooldowns.
Recap: register a channel with cl.net.payload_register on both sides, write values into a cl.buf and read them back in the same order, send with send_to_server or send_to_player, and check every client request on the server.