BTYN

The .btyn schema language

← Docs index

A schema is a plain text file. Comments follow Luau: -- to end of line, and --[[ ]] for a block. There are no reserved words — from, rate and event are matched by position, so a field may be called any of them.

Declarations

event

Reliable, ordered, batched once per frame.

event Attack from client {
    target: entity,
    combo:  u8,
}

from is required and says who sends. It decides which half of the API each side receives: a from client event gives the client .fire and the server .on. Calling the wrong one is a type error, not a silent no-op.

unreliable

Same shape, sent over UnreliableRemoteEvent. May arrive out of order or not at all.

unreliable Muzzle from server {
    at:  vec3,
    dir: unit,
}

Use it when a lost packet is invisible: muzzle flashes, footstep dust, aim updates that the next one supersedes anyway. Never for anything with consequences.

The compiler computes the worst-case size of every unreliable packet and fails the build if it cannot fit the payload cap. This matters because the engine’s behaviour on an oversized unreliable payload is to discard it without an error.

error: unreliable event 'Snapshot' can reach 1003 bytes, over the 800 byte cap
  --> net.btyn:12:12
   |
12 | unreliable Snapshot from server {
   |            ^^^^^^^^ worst case is 1003 bytes
   |
help: the engine drops an oversized unreliable payload without telling you.
      Shrink the packet (quantise floats, cap arrays tighter), split it, or
      make it a reliable `event`

request

Request and reply over the same two remotes. No RemoteFunction is involved — a RemoteFunction blocks its caller, and on the server a yielding invocation blocks replication behind it.

request Buy from client rate 4 {
    item:     u16,
    quantity: u8(1..99),
} -> {
    ok:      bool,
    balance: u32,
    reason:  string(64),
}

The requester gets .call (yields, throws on failure) and .try (returns ok, value). The responder gets .on, and its handler may yield freely — it runs on its own thread, so a datastore call does not stall the batch.

Every request carries a deadline (request_timeout, default 10s). A reply that never comes fails the call rather than stranding the thread forever.

channel

Replicated state, delta-encoded, with an explicit audience. Always server to client, so it takes no from.

channel Health priority high {
    hp:     u8(0..100),
    armor:  u8,
    downed: bool,
}

Only changed fields go out, behind a dirty bitmask. A health tick costs one mask byte plus one value byte, not the whole record.

priority is low, normal (default) or high. When a client is over its per-frame byte budget, low defers first and high never defers. Deferred deltas coalesce with the next update, so nothing is lost — only delayed.

Capped at 32 fields, which keeps the mask a single load. Splitting is better design anyway: fields that change at different rates want different priorities.

A channel takes no rate and no from. It already sends at most once a frame per player, and it always replicates server to client; both are rejected at compile time rather than accepted and ignored.

struct and enum

struct Vec2i { x: i16, y: i16 }

enum Team { Red, Blue, Spectator }

A struct is laid out recursively and packs its own booleans internally. An enum travels as one byte (two past 256 variants) and appears in Luau as a string union — "Red" | "Blue" | "Spectator" — so call sites stay readable.

config

config {
    server = "src/Server/Net.luau"
    client = "src/Client/Net.luau"
}
Key Default Meaning
server required Where to write the server module
client required Where to write the client module
runtime game:GetService("ReplicatedStorage"):WaitForChild("BTYN") Luau expression the generated files use to reach the runtime
budget 40000 Soft per-client outbound budget, bytes/second
unreliable_cap 800 Largest unreliable payload, bytes
max_packets_per_batch 256 Most packets one received batch may carry, server-side
request_timeout 10 Seconds before an unanswered request fails
write_checks true Validate on the way out as well as in
manual false Flush by hand instead of on Heartbeat

On unreliable_cap: Roblox does not publish this number, and community measurement puts it near 1000 bytes. The default sits well below that on purpose — undershooting costs a few bytes per packet, overshooting costs silent data loss. Measure on your own place before raising it.

On write_checks: the receiving side always validates, because that is the trust boundary. This flag only controls the sending side, where the cost buys you catching hp = 300 at the call site instead of watching it silently truncate to 44 and arrive as a mystery. Leave it on until a profile says otherwise.

On max_packets_per_batch: the smallest packet on the wire is a single opcode byte, so without a ceiling one payload from a hostile client becomes tens of thousands of handler calls in a frame — an amplification the per-packet rate limits cannot see, because they are only reached once the work has been done. The default is far above ordinary play, where a busy frame of input is a handful of packets. It applies to what the server receives and never to the server’s own batches, which legitimately run long: one channel keyframe per replicated entity is one packet each.

On manual: with it set, nothing flushes on its own and Net.flush() is yours to call. The per-frame channel budget follows real elapsed time rather than assuming sixty flushes a second, so an unusual cadence still spends budget bytes per second rather than sixty times too many.

Types

Numbers

u8 u16 u32 · i8 i16 i32 · f32 f64

Any of them takes an optional inclusive range, validated on both sides:

hp:   u8(0..100),
temp: i16(-50..50),

A range that cannot fit its type is a compile error.

bool

Costs one bit, not one byte. Every boolean in a packet is collected into a bitfield at the front, written with one store per 32 flags.

entity

A u32 you own the meaning of. It exists as its own name to steer you away from Instance, which cannot live in a buffer at all, costs real bandwidth, and arrives nil when the receiver has not streamed the object in yet.

string(n)

The cap is required. An uncapped string is an uncapped packet, which is both a bandwidth leak and a free amplification primitive. The length prefix is one byte up to 255, two beyond.

[T; n]

An array with a required cap, same reasoning. [u16; 100] is at most 201 bytes.

T?

Optional. When T is fixed-size this costs one bit and keeps the packet on the constant-offset fast path — the payload bytes are always written, zeroed when absent. When T is dynamic it costs a presence byte instead.

That presence bit lives in the packet’s (or struct’s) bitfield, which array elements and channel fields do not have — so an optional of a fixed type is rejected in those two places. Wrap it in a struct, which carries its own bitfield: struct Slot { value: u16? }. Optionals of dynamic types spend a presence byte and work anywhere.

Roblox values

Type Bytes Notes
vec3 12 3 × f32
cframe 18 position as 3 × f32, rotation as quantised euler angles
color3 3 one byte per channel
unit 6 unit vector, each component quantised to i16; normalised on read
angle 2 radians quantised across [-pi, pi]
Instance 2 index into a sidecar array — see below

fixed(min, max, bytes)

A float quantised into a fixed range. bytes is 1, 2 or 4.

blend: fixed(-1, 1, 2),

Two bytes gives 65536 steps across the range, finer than anything a player perceives. This is usually the right answer when you were about to reach for f32.

Instance

Buffers hold bytes, so an Instance cannot go in one. BTYN writes an index and carries the reference alongside the payload.

It works, and you should still prefer entity. Instance references cost bandwidth, and arrive nil when the receiver has not loaded the object yet — a bug that only shows up on a slow connection. A broadcast carrying an Instance also gives up the serialise-once fast path, because indices are relative to the batch that holds them.


Next: the generated API · compiler and editor · performance · security