The generated API
Compiling a schema writes two modules. Each one carries only the half of the protocol its side takes part in: the client file has no reader for a packet the server never sends it, and no writer for one the client never sends.
-- server
local Net = require(ServerScriptService.Net)
-- client
local Net = require(ReplicatedStorage.Net)
Every payload type is exported, so Net.Attack.on hands you a fully typed
table with no annotation on your side.
Events
Given:
event Attack from client rate 10 { target: entity, combo: u8 }
event Damaged from server { victim: entity, amount: u16 }
Sending side
-- client, because Attack is `from client`
Net.Attack.fire({ target = id, combo = 2 })
-- server, because Damaged is `from server`
Net.Damaged.to(player, data) -- one player
Net.Damaged.all(data) -- everyone
Net.Damaged.list(players, data) -- a chosen set
Net.Damaged.except(player, data) -- everyone but one
all, list and except serialise once and copy the bytes into each
recipient’s batch. list is the one to reach for when you know who cares —
unlike FireAllClients, it lets you filter.
Receiving side
-- server
Net.Attack.on(function(player, data)
-- data.target: number, data.combo: number
end)
-- client
Net.Damaged.on(function(data) end)
One handler per packet; registering again replaces it.
How handlers run
Every handler — events, channels, requests — is resumed on its own thread. Two consequences worth knowing:
You may yield. A datastore call or a task.wait in a handler holds up
nothing else. The packets behind it in the same batch keep being delivered.
An error is contained. A handler that throws does not discard the rest of
the batch, and is not misreported through onAbuse as the sender’s fault. It
surfaces as an ordinary script error.
The trade is that two handlers from the same batch have no completion order if the first one yields. If a pair of packets is only safe in sequence — a charge and its release — validate before you yield, or carry the ordering in the payload rather than relying on arrival order.
What the schema does not check
The shape is validated before your handler runs: ranges, lengths, finiteness,
enum variants, and that an Instance field really holds one. What it cannot
check is meaning.
entity in particular is a bare u32 naming something your game owns. Any
value fits, so every id from a client is a claim to verify:
Net.Attack.on(function(player, data)
local target = entities[data.target] -- exists?
if not target or not canReach(player, target) then
return -- theirs to touch?
end
...
end)
Requests
request Buy from client { item: u16 } -> { ok: bool, balance: u32 }
-- client
local reply = Net.Buy.call({ item = 12 }) -- yields; throws on failure
local ok, reply = Net.Buy.try({ item = 12 }) -- yields; never throws
-- server
Net.Buy.on(function(player, request)
if not canAfford(player, request.item) then
return nil -- nil rejects; the caller's `call` throws
end
return { ok = true, balance = balanceOf(player) }
end)
Returning nil rejects, and so does throwing — the handler runs inside a
pcall, so an error reaches the caller as a failure rather than a timeout.
For a request X from server, the direction flips and the server’s call takes
a target: Net.Ping.call(player, data). A reply is only accepted from the
player it was sent to, so one client cannot answer a question the server asked
somebody else.
Every request carries a deadline (request_timeout, default 10s). A reply that
never arrives fails the call rather than stranding the thread, and one that
arrives after the deadline is dropped rather than delivered late.
Channels
channel Health priority high { hp: u8(0..100), downed: bool }
Server
local health = Net.Health.of(entityId)
health.set({ hp = 80 }) -- only changed fields are marked dirty
health.audience(playersInRange) -- who receives it
health.destroy() -- tells holders to drop it
of returns the same handle for the same id, so calling it in a loop does not
allocate. After destroy the id is free again: calling of with it — even in
the same frame — gives you a fresh entity, and the client sees the removal
before the new keyframe.
set compares before marking. Re-setting a field to the value it already holds
does nothing, which means you can hand it your whole state every frame without
defeating delta encoding.
audience replaces the set. Players who joined get a keyframe — a full
update — so they never try to apply a delta against state they never had.
Players who left are told to drop the entity.
That call is the interest management. BTYN does not decide who is relevant, because only your game knows; it makes acting on the answer cheap and correct.
-- a typical shape
Net.Health.of(id).audience(playersWithin(120, position))
Client
Net.Health.on(function(id, state, changed)
-- state: the merged view, always complete
-- changed: only the fields that arrived in this update
updateBar(id, state.hp)
end)
Net.Health.onRemove(function(id) removeBar(id) end)
local current = Net.Health.get(id) -- nil if never received
Use state for anything that needs the whole picture and changed when you
only want to react to what moved — retriggering a hit flash on every unrelated
armour tick is the usual bug here.
Module-level
Net.flush()
Sends every pending batch now. Only needed with manual = true; otherwise this
happens once per frame on Heartbeat.
Net.onAbuse(function(player, reason)
warn(`{player}: {reason}`)
end)
Fires when a received batch fails validation, or a rate limit is exceeded. On the server this means a client sent something the schema does not permit, which no honest client can do — the same compiler wrote both sides. Log it, and consider it a signal.
Rate limits are declared in the schema (rate 10) and enforced on the receiving
side, per player, with a token bucket. An over-budget packet is dropped and
reported, never queued. The bucket holds one second of budget, so rate 10
allows a burst of ten and refills at ten per second.
Next: troubleshooting · performance · security · schema reference