BTYN

The generated API

← Docs index

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