Skip to content

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.