BTYN

Performance

← Docs index

The limits you are actually working against

     
Bandwidth per client ~50 KB/s before throttling Going over delays all replication — characters, physics, properties — not just your packets
Overhead per remote call ~9 bytes 100 small messages a frame is ~900 bytes of pure header
Client request rate ~500/s, shared across remotes of a kind Remote spam is throttled before you notice
Unreliable payload dropped above the cap No error. The packet simply never arrives
Safe send rate degrades above ~60 Hz Batching per frame is not optional

The 50 KB/s is shared with the engine’s own character and physics replication. Only part of it is yours to spend.

What BTYN does about it

Two remotes, batched per frame

Every packet in a frame goes into one send. Header overhead is paid twice a frame instead of once per message, and ordinary gameplay traffic stops being able to reach the request-rate limit.

Constant offsets

A fixed-size packet compiles to a straight line with no cursor and no type dispatch:

local function writeAttack(b: buffer, o: number, v: Attack, refs: { Instance }): number
    local w1 = 0
    if v.crit then w1 += 1 end
    if v.stun then w1 += 2 end
    buffer.writeu8(b, o, w1)
    buffer.writeu32(b, o + 1, v.target)
    buffer.writeu8(b, o + 5, v.combo)
    return 6
end

This is what a compiler buys. A runtime schema has to walk a table and branch on each field’s type every single call; there is nothing left here to walk.

Packed booleans

Every boolean in a packet shares a bitfield at the front. 32 flags cost 4 bytes and one writeu32. This is the case where the gap between a compiled and an interpreted schema is widest.

One bounds check per packet

Sizes are known, so a fixed packet is validated once on entry rather than before every field. Faster and safer than checking nothing.

Serialise-once broadcast

all, list and except encode the packet a single time into a scratch buffer and buffer.copy it into each recipient’s batch. Forty players cost one serialisation and forty memcpys.

One allocation per flush

Streams are reused across frames and grow by doubling. The only allocation on the send path is the exactly-sized buffer handed to the remote, once per flush.

Delta encoding and interest management

Covered in the API guide. Two effects, and the second is larger: send only what changed, and only to players it matters to.

Techniques that matter more than any library

In rough order of impact. A library optimises the generic case; these are about your specific one, and they win by more.

1. Do not send. The cheapest packet is the one that does not exist. Replicate only what is in a player’s radius of relevance, send on change rather than on a timer, and let the client derive whatever it can derive.

2. Lower the rate. Plenty of things running at 60 Hz look identical at 10 Hz with client interpolation. NPC movement is the classic: six updates a second, interpolated, is indistinguishable from sixty in play.

3. Quantise. Position rarely needs f64. Angles fit in one or two bytes. Health fits in a u8 if the maximum is 100. Use fixed(min, max, bytes) and angle rather than reaching for f32 by reflex.

4. Bitpack. 32 booleans fit in a u32 — BTYN does this for you. Enums become a byte.

5. Delta encode. Send what changed, behind a mask. That is what channels are.

6. Opcodes, never strings. Never send "FireballCast". BTYN assigns every packet a numeric opcode at compile time; you never see a name on the wire.

7. Be careful with Instance. References are expensive and arrive nil when the receiver has not streamed the object in. Prefer entity.

Measuring

Trust your own game over anyone’s benchmark, including this page. Use the MicroProfiler’s network profiler and the network stats panel, at a realistic player count, and watch three things:

--check tells you what the compiler knows statically:

lune run cli/main -- net.btyn --check
  [9    ] Muzzle         unreliable     server -> client  18 B

  largest unreliable packet: 19 B of the 800 B cap

There is a headless codec benchmark in the repo:

lune run bench/run
  fixed, 3 fields             7 B   encode     8.0M/s   decode    10.9M/s
  32 booleans                 4 B   encode     1.3M/s   decode     1.6M/s
  quantised vec3 + unit      18 B   encode     3.2M/s   decode     3.4M/s
  dynamic string              6 B   encode     4.0M/s   decode     7.1M/s

It measures CPU and wire size for the generated codecs, with payloads that vary every iteration. It deliberately does not claim to be a library comparison — that needs the other libraries and a real place to run them in. Note the 4 bytes for 32 booleans; that is the number worth looking at on that row.

On benchmarks

Networking benchmarks in this ecosystem are unusually easy to get wrong. A widely circulated result once showed a library sending almost nothing, because it XORed against a previous frame that was identical — the payload was constant, so the deltas were all zero. It was measuring a bug.

If you benchmark:


Next: security · compiler and editor · schema reference · API