Buffer networking for Roblox.
Releases · Install · Example · API · Codecs · Benchmarks
Packets, queries, groups, validation, and rate limiting — all batched into one buffer per player per frame. No code generation.
Wally
[dependencies]
Lync = "axp3cter/lync@2.2.1"npm (roblox-ts)
npm install @axpecter/lyncimport Lync from "@axpecter/lync";Or grab the .rbxm from Releases.
Important
Define all packets, queries, and groups before calling Lync.start().
Shared — ReplicatedStorage.Net
local Lync = require(game.ReplicatedStorage.Lync)
return table.freeze({
State = Lync.packet("State", Lync.deltaStruct({
position = Lync.vec3,
health = Lync.float(0, 100, 0.5),
status = Lync.enum("idle", "moving", "attacking", "dead"),
alive = Lync.bool,
})),
Hit = Lync.packet("Hit", Lync.struct({
targetId = Lync.int(0, 65535),
damage = Lync.float(0, 200, 0.1),
}), {
rateLimit = { maxPerSecond = 30, burst = 5 },
validate = function(data) return data.damage <= 200, "damage" end,
}),
Ping = Lync.query("Ping", Lync.nothing, Lync.f64, { timeout = 3 }),
})Server
local Lync = require(game.ReplicatedStorage.Lync)
local Net = require(game.ReplicatedStorage.Net)
local Players = game:GetService("Players")
local alive = Lync.group("alive")
Players.PlayerAdded:Connect(function(p) alive:add(p) end)
Net.Hit:on(function(data, sender) -- ... end)
Net.Ping:handle(function() return os.clock() end)
Lync.start()
game:GetService("RunService").Heartbeat:Connect(function()
Net.State:send(getState(), alive)
end)Client
local Lync = require(game.ReplicatedStorage.Lync)
local Net = require(game.ReplicatedStorage.Net)
Lync.start()
local scope = Lync.scope()
scope:on(Net.State, function(state) -- ... end)
Net.Hit:send({ targetId = 123, damage = 45 })
local serverTime = Net.Ping:request(nil)| Function | Description |
|---|---|
Lync.configure(opts) |
Set options. Must precede start(). |
Lync.start() |
Initialize transport. Call once. |
Lync.isStarted() |
true after start(). |
Lync.flush() |
Force an immediate send. |
Lync.flushRate(hz) |
1–60. Default 60. |
| Option | Default | Range | Description |
|---|---|---|---|
channelMaxSize |
262144 | 4 KB – 1 MB | Max buffer bytes per frame. |
validationDepth |
16 | 4–32 | Max recursion depth for input validation. |
poolSize |
16 | 2–128 | Reusable channel-state pool. |
bandwidthLimit |
none | — | { softLimit, maxStrikes } per-player throttle. |
globalRateLimit |
none | — | { maxPerSecond } across all packets per player. |
stats |
false |
— | Enables :stats() and Lync.stats.player(). |
Lync.packet(name, codec, options?)
-- Server
packet:send(data, player)
packet:send(data, Lync.all)
packet:send(data, Lync.except(p1, p2))
packet:send(data, { p1, p2, p3 })
packet:send(data, group)
-- Client
packet:send(data)
-- Both
local conn = packet:on(function(data, sender, timestamp) end)
packet:once(fn)
local data, sender, timestamp = packet:wait()
packet:name()
packet:stats() -- requires stats=true| Option | Type | Description |
|---|---|---|
unreliable |
boolean | Use UnreliableRemoteEvent. Disallowed with delta codecs. |
rateLimit |
RateLimitConfig |
Server-side per-player limit. |
validate |
(data, player) → (bool, string?) |
Drop on false. |
maxPayloadBytes |
number | Reject oversize incoming payloads. |
timestamp |
"frame" / "offset" / "full" |
Append 1B / 2B / 8B timestamp. Read as third arg. |
Lync.query(name, requestCodec, responseCodec, options?)
Request-response on top of two packet IDs.
-- Server
query:handle(function(data, player) return response end)
local resp = query:request(data, player) -- response?
local map = query:request(data, target) -- { [Player]: response? }
-- Client
query:handle(function(data) return response end)
local resp = query:request(data) -- yields; nil on timeout| Option | Default | Description |
|---|---|---|
timeout |
5 | Seconds before yielding nil. |
rateLimit |
{ maxPerSecond = 30 } |
Server-side. |
validate |
none | (data, player) → (bool, string?) |
Lync.group(name) — named player set. Members auto-removed on PlayerRemoving. Iterable: for player in group do.
| Method | Returns | Description |
|---|---|---|
group:add(p) / :remove(p) |
boolean | true if changed. |
group:has(p) |
boolean | Membership. |
group:count() |
number | |
group:destroy() |
— | Clear and free name. |
Lync.scope() — batches connections for cleanup.
local scope = Lync.scope()
scope:on(packet, fn)
scope:once(packet, fn)
scope:add(rbxConnection)
scope:destroy()Server-side :send second arg.
| Target | Description |
|---|---|
Player |
One player. |
Lync.all |
All connected. |
Lync.except(...) |
Everyone except given players or groups. |
{ p1, p2 } |
Array of players. |
group |
All members. |
-- Return Lync.DROP from onSend to discard a packet.
Lync.onSend(function(data, name, player) return data end)
Lync.onReceive(function(data, name, player) return data end)
Lync.onDrop(function(player, reason, name, data) end)All return a Connection.
c.connected |
boolean |
c:disconnect() |
Idempotent. |
Lync.configure({ stats = true }).
| Function | Description |
|---|---|
Lync.stats.player(p) |
{ bytesSent, bytesReceived }. Server only. |
Lync.stats.reset() |
Zero all counters. |
packet:stats() |
{ bytesSent, bytesReceived, fires, recvFires, drops } |
| Function | Description |
|---|---|
Lync.debug.pending() |
In-flight query requests. |
Lync.debug.registrations() |
Frozen array of { name, id, kind, isUnreliable }. |
| Codec | Bytes | Notes |
|---|---|---|
int(min, max) |
1 / 2 / 4 | Picks smallest u8/u16/u32/i8/i16/i32. |
f16 / f32 / f64 |
2 / 4 / 8 | f16: ±65504, ~3 digits. |
float(min, max, precision) |
1–4 | Quantized. Clamped. |
bool |
1 | Auto-bitpacked inside struct and array. |
| Codec | Notes |
|---|---|
string |
Variable length. Binary-safe. |
string(maxLength) |
Bounded. Rejects on read if exceeded. |
buff |
Variable-length buffer. |
| Codec | Bytes |
|---|---|
vec2 / vec3 |
8 / 12 |
cframe |
24 |
color3 |
3 |
inst |
2 |
udim / udim2 |
8 / 16 |
numberRange |
8 |
rect |
16 |
ray |
24 |
vec2int16 / vec3int16 |
4 / 6 |
region3 / region3int16 |
24 / 12 |
numberSequence / colorSequence |
variable |
Call as a function for compression.
| Codec | Bytes | Notes |
|---|---|---|
vec2(min, max, precision) |
2–8 | Per-component. |
vec3(min, max, precision) |
3–12 | Per-component. |
cframe() |
16 | Smallest-three quaternion. ≤0.16° rotation error. |
| Codec | Notes |
|---|---|
struct({k = c}) |
Named fields. Bools auto-bitpacked. |
array(c, max?) |
List. Bool arrays bitpacked. |
map(k, v, max?) |
Key-value pairs. |
optional(c) |
1B nil flag + value. |
tuple(...) |
Positional. |
tagged(field, {name = c}) |
Discriminated union. 1B tag. ≤256 variants. |
Sends 1 byte when unchanged.
| Codec |
|---|
deltaStruct(schema) |
deltaArray(c, max?) |
deltaMap(k, v, max?) |
| Codec | Notes |
|---|---|
enum(...) |
String enum. ≤256 variants. 1B. |
bitfield(schema) |
1–32 bits. Sub-byte packing. |
custom(size, write, read, typeCheck?) |
User-defined fixed-size. |
nothing |
0 bytes. Reads nil. |
unknown |
Bypass serialization. Use with validate. |
auto |
Self-describing. nil/bool/numbers/strings/buffers/Roblox types. |
Per-packet, pick one mode:
- Token bucket:
{ maxPerSecond = N, burst = M } - Cooldown:
{ cooldown = seconds }
Global per-player: Lync.configure({ globalRateLimit = { maxPerSecond = N } }).
| Packet + query IDs | 127 |
| Buffer per frame | 1 MB max |
| In-flight queries | 65,536 |
| Enum / tagged variants | 256 |
| Bitfield total bits | 32 |
rojo serve bench.project.json with one server + one client.
CPU benches run a fixed 1000 iterations per case.
| Codec | Encode | Decode | RT/s |
|---|---|---|---|
bool |
43 ns | 28 ns | 14.1 M |
int(0, 255) |
41 ns | 25 ns | 15.2 M |
int(0, 65535) |
40 ns | 25 ns | 15.3 M |
f16 |
60 ns | 42 ns | 9.7 M |
f32 |
41 ns | 26 ns | 14.9 M |
f64 |
41 ns | 25 ns | 15.3 M |
string (10 chars) |
45 ns | 73 ns | 8.4 M |
string (1000 chars) |
74 ns | 250 ns | 3.1 M |
vec3 |
56 ns | 27 ns | 12.1 M |
vec3 quantized |
121 ns | 85 ns | 4.9 M |
cframe |
88 ns | 186 ns | 3.6 M |
cframe() |
118 ns | 214 ns | 3.0 M |
| entity struct (6 fields) | 234 ns | 476 ns | 1.4 M |
| 100× entities | 15.3 µs | 34.6 µs | 20 K |
| 1000× bools (bitpacked) | 4.3 µs | 5.3 µs | 104 K |
| Codec | Bytes |
|---|---|
| entity struct (6 fields, lossless) | 34 |
| entity compact (quantized) | 13 |
| 100× entities | 601 |
| 1000× bools (bitpacked) | 127 |
| bitfield flags | 2 |
tuple(u8, vec3, bool) |
14 |
| Codec | Full | Unchanged |
|---|---|---|
deltaStruct (entity) |
35 B | 1 B |
deltaStruct (compact) |
14 B | 1 B |
deltaArray (100× entity) |
602 B | 1 B |
deltaArray (1000× bool) |
128 B | 1 B |
deltaMap (string → u8) |
19 B | 1 B |
Same methodology as Blink: 1000 fires/frame, identical data, 10 s. Other-tool numbers from Blink v0.17.1.
Note
Lync batches all sends into one buffer per frame and bitpacks bools (1000 = 127 B vs ~1002 B). Delta compression isn't exercised here.
100× struct(6× u8) entities
| Tool | FPS | Kbps |
|---|---|---|
| roblox | 16 | 559,364 |
| lync | 60 | 3.47 |
| blink | 42 | 41.81 |
| zap | 39 | 41.71 |
| bytenet | 32 | 41.64 |
1000× bool
| Tool | FPS | Kbps |
|---|---|---|
| roblox | 21 | 353,107 |
| lync | 60 | 2.33 |
| blink | 97 | 7.91 |
| zap | 52 | 8.10 |
| bytenet | 35 | 8.11 |
MIT