Buffer networking for Roblox.
Releases · Install · Example · Codecs · Benchmarks
Lync batches all sends into a single buffer per player per frame, applies XOR compression across frames, validates and rate-limits every incoming payload, and does it all without code generation.
Wally
[dependencies]
Lync = "axp3cter/[email protected]"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)
local Net = {}
Net.State = Lync.packet("State", Lync.deltaStruct({
position = Lync.vec3,
health = Lync.float(0, 100, 0.5),
shield = Lync.float(0, 100, 0.5),
status = Lync.enum("idle", "moving", "attacking", "dead"),
alive = Lync.bool,
}))
Net.Hit = Lync.packet("Hit", Lync.struct({
targetId = Lync.int(0, 65535),
damage = Lync.float(0, 200, 0.1),
headshot = Lync.bool,
}), {
rateLimit = { maxPerSecond = 30, burst = 5 },
validate = function(data, player)
if data.damage > 200 then return false, "damage" end
return true
end,
})
Net.Chat = Lync.packet("Chat", Lync.struct({
msg = Lync.string(200),
channel = Lync.int(0, 255),
}))
Net.Ping = Lync.query("Ping", Lync.nothing, Lync.f64, { timeout = 3 })
return table.freeze(Net)Server
local Lync = require(game.ReplicatedStorage.Lync)
local Net = require(game.ReplicatedStorage.Net)
local Players = game:GetService("Players")
local alive = Lync.group("alive")
Lync.onDrop(function(player, reason, name)
warn(player.Name, "dropped", name, reason)
end)
Lync.start()
Players.PlayerAdded:Connect(function(player) alive:add(player) end)
game:GetService("RunService").Heartbeat:Connect(function()
Net.State:send({
position = Vector3.new(0, 5, 0),
health = 100,
shield = 50,
status = "idle",
alive = true,
}, alive)
end)
Net.Hit:on(function(data, player)
local target = Players:GetPlayerByUserId(data.targetId)
if not target then return end
alive:remove(target)
Net.Chat:send({ msg = player.Name .. " eliminated " .. target.Name, channel = 0 }, Lync.all)
end)
Net.Ping:handle(function(_, player) return os.clock() 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)
local character = game.Players.LocalPlayer.Character
if not character then return end
character:PivotTo(CFrame.new(state.position))
end)
scope:on(Net.Chat, function(data) print("[chat]", data.msg) end)
Net.Hit:send({ targetId = 123, damage = 45.5, headshot = true })
local serverTime = Net.Ping:request(nil)
if serverTime then print("server clock:", serverTime) endLync.packet(name, codec, options?)
| Field | Type | Default | Description |
|---|---|---|---|
unreliable |
boolean |
false |
Send over UnreliableRemoteEvent. Cannot use with delta codecs. |
rateLimit |
RateLimitConfig |
none | Server-side rate limiting. |
validate |
(data, player) → (bool, string?) |
none | Server-side validation. Return false, "reason" to drop. |
maxPayloadBytes |
number |
none | Max bytes per payload. |
timestamp |
"frame", "offset", or "full" |
none | Appends a timestamp. "frame" = 1B counter. "offset" = 2B ms. "full" = 8B clock. Received as third argument. |
-- 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)| Method | Description |
|---|---|
packet:on(fn) |
fn(data, sender, timestamp?). Returns a Connection. |
packet:once(fn) |
Fires once, then disconnects. |
packet:wait() |
Yields until next fire. Returns data, sender, timestamp?. |
packet:name() |
Returns the packet name. |
packet:stats() |
Returns { bytesSent, bytesReceived, fires, recvFires, drops }. Requires stats enabled. |
Lync.query(name, requestCodec, responseCodec, options?)
Request-response built on packets. Returns nil on timeout.
| Field | Type | Default | Description |
|---|---|---|---|
timeout |
number |
5 | Seconds before yielding nil. |
rateLimit |
RateLimitConfig |
{ maxPerSecond = 30 } |
Server-side rate limiting. |
validate |
(data, player) → (bool, string?) |
none | Server-side validation. |
| Method | Context | Description |
|---|---|---|
query:handle(fn) |
Both | Register handler. Server: fn(request, player) → response. Client: fn(request) → response. |
query:request(data) |
Client | Send to server, yield for response. |
query:request(data, player) |
Server | Send to one client. |
query:request(data, target) |
Server | Send to multiple. Returns { [Player]: response? }. |
query:name() |
Both | Returns the query name. |
query:stats() |
Both | Combined stats for request and response channels. |
Each query consumes two packet IDs internally.
Lync.group(name)
Named player sets. Members auto-removed on PlayerRemoving. Iterable with for player in group do.
| Method | Returns | Description |
|---|---|---|
group:add(player) |
boolean |
true if added. |
group:remove(player) |
boolean |
true if removed. |
group:has(player) |
boolean |
Membership check. |
group:count() |
number |
Member count. |
group:destroy() |
— | Clears members, frees name. |
Lync.scope()
Batches connections for cleanup.
local scope = Lync.scope()
scope:on(packetA, fnA)
scope:on(packetB, fnB)
scope:add(someRBXScriptConnection)
scope:destroy() -- disconnects everything| Method | Description |
|---|---|
scope:on(source, fn) |
Connect and track. |
scope:once(source, fn) |
Connect once and track. |
scope:add(connection) |
Track an existing connection. |
scope:destroy() |
Disconnect all. Safe to call multiple times. |
Returned by packet:on(), packet:once(), query:handle(), and middleware functions.
| Field / Method | Description |
|---|---|
connection.connected |
boolean |
connection:disconnect() |
Stops the listener. Safe mid-fire, safe to call multiple times. |
Lync.onSend(function(data, name, player)
return data -- or return Lync.DROP to discard
end)
Lync.onReceive(function(data, name, player)
return data
end)
Lync.onDrop(function(player, reason, name, data)
warn(player.Name, "dropped", name, reason)
end)All three return a Connection.
Server-side second argument to packet:send().
| Target | Description |
|---|---|
player |
Single player. |
Lync.all |
All connected players. |
Lync.except(...) |
Everyone except specified players or groups. |
{ p1, p2, ... } |
Array of players. |
group |
All members of a group. |
Lync.int(min, max) picks the smallest wire type for your range.
| Codec | Bytes | Description |
|---|---|---|
Lync.int(0, 255) |
1 | u8 |
Lync.int(0, 65535) |
2 | u16 |
Lync.int(0, 4294967295) |
4 | u32 |
Lync.int(-128, 127) |
1 | i8 |
Lync.int(-32768, 32767) |
2 | i16 |
Lync.int(-2147483648, 2147483647) |
4 | i32 |
Lync.f16 |
2 | Half-precision float. ~3 digits. ±65504. |
Lync.f32 |
4 | Single-precision float. |
Lync.f64 |
8 | Double-precision float. |
Lync.bool |
1 | Bitpacked inside structs and arrays (8 per byte). |
Lync.float(min, max, precision) |
1–4 | Quantized float. Clamped to range. |
| Codec | Description |
|---|---|
Lync.string |
Variable length. Binary-safe. |
Lync.string(maxLength) |
Same, but rejects on read if length exceeds maxLength. |
Lync.buff |
Variable-length buffer. |
| Codec | Bytes |
|---|---|
Lync.vec2 |
8 |
Lync.vec3 |
12 |
Lync.cframe |
24 |
Lync.color3 |
3 |
Lync.inst |
2 |
Lync.udim |
8 |
Lync.udim2 |
16 |
Lync.numberRange |
8 |
Lync.rect |
16 |
Lync.ray |
24 |
Lync.vec2int16 |
4 |
Lync.vec3int16 |
6 |
Lync.region3 |
24 |
Lync.region3int16 |
12 |
Lync.numberSequence |
variable |
Lync.colorSequence |
variable |
Call the codec to get a quantized version.
| Codec | Bytes | Description |
|---|---|---|
Lync.vec2(min, max, precision) |
2–8 | Per-component quantization. |
Lync.vec3(min, max, precision) |
3–12 | Per-component quantization. |
Lync.cframe() |
16 | Compressed rotation. ≤0.16° angular error. Saves 8B vs lossless. |
| Codec | Description |
|---|---|
Lync.struct({ key = codec }) |
Named fields. Bools are automatically bitpacked. |
Lync.array(codec, maxCount?) |
Variable-length list. Bool arrays are bitpacked. |
Lync.map(keyCodec, valueCodec, maxCount?) |
Key-value pairs. |
Lync.optional(codec) |
1-byte nil flag + value if present. |
Lync.tuple(...) |
Ordered positional values. |
Lync.tagged(tagField, { name = codec }) |
Discriminated union with 1-byte tag. |
Only works with reliable transport. Sends 1 byte when data hasn't changed.
| Codec | Description |
|---|---|
Lync.deltaStruct(schema) |
Delta-compressed struct. |
Lync.deltaArray(codec, maxCount?) |
Delta-compressed array. |
Lync.deltaMap(keyCodec, valueCodec, maxCount?) |
Delta-compressed map. |
| Codec | Description |
|---|---|
Lync.enum(...) |
String enum. Up to 256 variants. 1 byte. |
Lync.bitfield(schema) |
Sub-byte packing. 1–32 bits. |
Lync.custom(size, write, read) |
User-defined fixed-size codec. |
Lync.nothing |
Zero bytes. Reads nil. |
Lync.unknown |
Bypasses serialization entirely. Use with validate. |
Lync.auto |
Self-describing. Supports nil, bool, numbers, strings, buffers, and Roblox types. |
Two modes (pick one per packet):
Token bucket: { maxPerSecond = N, burst = M }
Cooldown: { cooldown = seconds }
Global limit across all packets: Lync.configure({ globalRateLimit = { maxPerSecond = N } })
Lync.configure(options) — call before Lync.start().
| Option | Default | Description |
|---|---|---|
channelMaxSize |
262,144 | Max buffer bytes per frame (4,096–1,048,576). |
validationDepth |
16 | Max recursion depth for input validation (4–32). |
poolSize |
16 | Buffer pool size (2–128). |
bandwidthLimit |
none | { softLimit, maxStrikes }. Per-player bandwidth throttle. |
globalRateLimit |
none | { maxPerSecond }. Global per-player rate limit. |
stats |
false |
Enables packet:stats() and Lync.stats.player(). |
| Function | Description |
|---|---|
Lync.configure(options) |
Set options before start. |
Lync.start() |
Initialize transport. Call once after all definitions. |
Lync.started |
Read-only boolean. true after start(). |
Lync.flush() |
Force an immediate send. |
Lync.flushRate(hz) |
Set flush rate. 1–60. Default 60. |
Enable with Lync.configure({ stats = true }).
| Function | Description |
|---|---|
packet:stats() |
{ bytesSent, bytesReceived, fires, recvFires, drops } |
Lync.stats.player(player) |
{ bytesSent, bytesReceived } — server only. |
Lync.stats.reset() |
Zeros all counters. |
| Function | Description |
|---|---|
Lync.debug.pending() |
Number of in-flight query requests. Useful for detecting leaks. |
Lync.debug.registrations() |
Frozen array of { name, id, kind, isUnreliable } for all registered packets and queries. |
| Constraint | Limit |
|---|---|
| Packet + query registrations | 127 |
| Buffer per frame | 256 KB default, 1 MB max |
| Concurrent query requests | 65,536 |
| Enum variants | 256 |
| Bitfield bits | 32 |
| Tagged variants | 256 |
Run rojo serve bench.project.json with one server + one client.
| Codec | Bytes |
|---|---|
bool |
1 |
int(0, 255) |
1 |
int(0, 65535) |
2 |
f16 |
2 |
f32 |
4 |
f64 |
8 |
string (5 chars) |
6 |
string (1000 chars) |
1002 |
vec3 |
12 |
vec3(0, 100, 1) |
3 |
cframe |
24 |
cframe() |
16 |
color3 |
3 |
| entity struct (6 fields) | 34 |
| entity compact (quantized) | 13 |
| bitfield | 2 |
| 100× entities | 601 |
| 1000× bools (bitpacked) | 127 |
100k iterations, isolated CPU. No networking.
| Codec | Encode | Decode | Round-trips/sec |
|---|---|---|---|
bool |
44ns | 29ns | 13.9M |
int(0, 255) |
42ns | 28ns | 14.4M |
f32 |
41ns | 25ns | 15.0M |
f64 |
41ns | 26ns | 14.8M |
string (10 chars) |
46ns | 60ns | 9.4M |
string (1000 chars) |
76ns | 238ns | 3.2M |
vec3 |
53ns | 27ns | 12.4M |
cframe |
92ns | 144ns | 4.2M |
cframe() |
123ns | 170ns | 3.4M |
| entity struct | 239ns | 395ns | 1.6M |
| 100× entities | 15.2µs | 34.1µs | 20K |
| 1000× bools | 4.3µs | 5.1µs | 107K |
| Codec | Full | Unchanged | Savings |
|---|---|---|---|
deltaStruct (entity) |
35B | 1B | 97% |
deltaStruct (compact) |
14B | 1B | 93% |
deltaArray (100× entity) |
602B | 1B | 100% |
deltaArray (1000× bool) |
128B | 1B | 99% |
deltaMap (string → u8) |
19B | 1B | 95% |
1000 fires/frame, 8 seconds, one player.
| Packet | FPS | Kbps |
|---|---|---|
| booleans | 60 | 2.5 |
| entity struct | 60 | 2.3 |
| entity compact | 60 | 2.4 |
| bitfield flags | 60 | 2.4 |
| cframe lossless | 60 | 2.5 |
| cframe compressed | 60 | 2.3 |
Same methodology as Blink's benchmarks: 1,000 fires/frame, same data every frame, 10 seconds.
Other tool numbers from Blink v0.17.1 (2025-04-30).
Note
Lync batches all sends into one buffer per frame. Other tools fire one RemoteEvent per send. Lync also includes server-side validation and bool bitpacking (1000 bools = 127B vs ~1002B). Delta compression is not exercised here — see Delta Savings.
| Tool | FPS | Kbps |
|---|---|---|
| roblox | 16 | 559,364 |
| lync | 60 | 3.68 |
| blink | 42 | 41.81 |
| zap | 39 | 41.71 |
| bytenet | 32 | 41.64 |
| Tool | FPS | Kbps |
|---|---|---|
| roblox | 21 | 353,107 |
| lync | 60 | 2.49 |
| blink | 97 | 7.91 |
| zap | 52 | 8.10 |
| bytenet | 35 | 8.11 |
MIT