@axpecter/lync 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +475 -426
  2. package/package.json +1 -1
  3. package/src/Types.luau +63 -31
  4. package/src/api/Group.luau +101 -106
  5. package/src/api/Packet.luau +187 -186
  6. package/src/api/Query.luau +223 -284
  7. package/src/api/Scope.luau +46 -57
  8. package/src/api/Signal.luau +104 -137
  9. package/src/codec/Base.luau +69 -20
  10. package/src/codec/composite/Array.luau +179 -270
  11. package/src/codec/composite/Map.luau +97 -347
  12. package/src/codec/composite/Optional.luau +27 -24
  13. package/src/codec/composite/Shared.luau +96 -65
  14. package/src/codec/composite/Struct.luau +200 -360
  15. package/src/codec/composite/Tagged.luau +62 -187
  16. package/src/codec/composite/Tuple.luau +79 -103
  17. package/src/codec/datatype/Buffer.luau +49 -21
  18. package/src/codec/datatype/CFrame.luau +207 -30
  19. package/src/codec/datatype/Color.luau +21 -11
  20. package/src/codec/datatype/Instance.luau +28 -21
  21. package/src/codec/datatype/IntVector.luau +33 -21
  22. package/src/codec/datatype/NumberRange.luau +19 -10
  23. package/src/codec/datatype/Ray.luau +26 -22
  24. package/src/codec/datatype/Rect.luau +20 -16
  25. package/src/codec/datatype/Region.luau +51 -45
  26. package/src/codec/datatype/Sequence.luau +64 -56
  27. package/src/codec/datatype/String.luau +89 -56
  28. package/src/codec/datatype/UDim.luau +40 -22
  29. package/src/codec/datatype/Vector.luau +181 -17
  30. package/src/codec/meta/Auto.luau +295 -253
  31. package/src/codec/meta/Bitfield.luau +104 -148
  32. package/src/codec/meta/Custom.luau +8 -4
  33. package/src/codec/meta/Enum.luau +29 -57
  34. package/src/codec/meta/Float.luau +64 -0
  35. package/src/codec/meta/Nothing.luau +9 -5
  36. package/src/codec/meta/Unknown.luau +44 -36
  37. package/src/codec/primitive/Bool.luau +16 -26
  38. package/src/codec/primitive/Float16.luau +58 -64
  39. package/src/codec/primitive/Int.luau +68 -0
  40. package/src/codec/primitive/Number.luau +21 -43
  41. package/src/codec/primitive/Varint.luau +67 -46
  42. package/src/index.d.ts +249 -335
  43. package/src/init.luau +319 -207
  44. package/src/internal/Baseline.luau +29 -24
  45. package/src/internal/Channel.luau +333 -278
  46. package/src/internal/Middleware.luau +60 -85
  47. package/src/internal/Pool.luau +19 -30
  48. package/src/internal/Registry.luau +56 -113
  49. package/src/transport/Bridge.luau +64 -43
  50. package/src/transport/Client.luau +59 -170
  51. package/src/transport/Gate.luau +282 -142
  52. package/src/transport/Reader.luau +148 -140
  53. package/src/transport/Server.luau +138 -488
  54. package/src/api/Namespace.luau +0 -256
  55. package/src/codec/meta/Quantized.luau +0 -174
package/README.md CHANGED
@@ -1,19 +1,25 @@
1
1
  <h1 align="center">Lync</h1>
2
- <p align="center">Buffer networking for Roblox. Delta compression, XOR framing, built-in security.</p>
2
+ <p align="center">Buffer networking for Roblox.</p>
3
3
  <p align="center">
4
4
  <a href="https://github.com/Axp3cter/Lync/releases/latest">Releases</a> ·
5
+ <a href="#install">Install</a> ·
5
6
  <a href="#example">Example</a> ·
6
- <a href="#benchmarks">Benchmarks</a> ·
7
- <a href="#limits--configuration">Limits</a>
7
+ <a href="#codecs">Codecs</a> ·
8
+ <a href="#wire-protocol">Wire Protocol</a> ·
9
+ <a href="#benchmarks">Benchmarks</a>
8
10
  </p>
9
11
 
12
+ Lync serializes structured data into flat buffers, batches all sends into a single `RemoteEvent:FireClient` per player per frame, and applies XOR framing so Roblox's internal deflate compressor can eliminate redundancy across frames. On the server, every incoming payload is schema-validated and rate-limited before any listener fires.
13
+
14
+ All codecs are defined at runtime. No code generation, no build step, no external CLI. Packets, queries, groups, and middleware are configured in shared modules and resolved at `Lync.start()`.
15
+
10
16
  ## Install
11
17
 
12
- **Wally (Luau)**
18
+ **Wally**
13
19
 
14
20
  ```toml
15
21
  [dependencies]
16
- Lync = "axp3cter/lync@2.0.0"
22
+ Lync = "axp3cter/lync@2.1.0"
17
23
  ```
18
24
 
19
25
  **npm (roblox-ts)**
@@ -26,52 +32,46 @@ npm install @axpecter/lync
26
32
  import Lync from "@axpecter/lync";
27
33
  ```
28
34
 
29
- Or grab the `.rbxm` from [releases](https://github.com/Axp3cter/Lync/releases/latest) and drop it in `ReplicatedStorage`.
35
+ Or grab the `.rbxm` from [Releases](https://github.com/Axp3cter/Lync/releases/latest) and drop it into `ReplicatedStorage`.
30
36
 
31
37
  > [!IMPORTANT]
32
- > Define everything before calling `Lync.start()`. Packets, queries, namespaces, all of it.
38
+ > All packets, queries, and groups must be defined before calling `Lync.start()`. The registry assigns sequential IDs at define time. Defining packets after `start()` will cause ID mismatches between server and client.
33
39
 
34
40
  ## Example
35
41
 
36
- **Shared**
42
+ **Shared** (`ReplicatedStorage.Net`)
37
43
 
38
44
  ```luau
39
45
  local Lync = require(game.ReplicatedStorage.Lync)
40
46
 
41
47
  local Net = {}
42
48
 
43
- Net.State = Lync.definePacket("State", {
44
- value = Lync.deltaStruct({
45
- position = Lync.vec3,
46
- health = Lync.quantizedFloat(0, 100, 0.5),
47
- shield = Lync.quantizedFloat(0, 100, 0.5),
48
- status = Lync.enum("idle", "moving", "attacking", "dead"),
49
- alive = Lync.bool,
50
- }),
51
- })
52
-
53
- Net.Hit = Lync.definePacket("Hit", {
54
- value = Lync.struct({
55
- targetId = Lync.u16,
56
- damage = Lync.quantizedFloat(0, 200, 0.1),
57
- headshot = Lync.bool,
58
- }),
59
- rateLimit = { maxPerSecond = 30, burstAllowance = 5 },
49
+ Net.State = Lync.packet("State", Lync.deltaStruct({
50
+ position = Lync.vec3,
51
+ health = Lync.float(0, 100, 0.5),
52
+ shield = Lync.float(0, 100, 0.5),
53
+ status = Lync.enum("idle", "moving", "attacking", "dead"),
54
+ alive = Lync.bool,
55
+ }))
56
+
57
+ Net.Hit = Lync.packet("Hit", Lync.struct({
58
+ targetId = Lync.int(0, 65535),
59
+ damage = Lync.float(0, 200, 0.1),
60
+ headshot = Lync.bool,
61
+ }), {
62
+ rateLimit = { maxPerSecond = 30, burst = 5 },
60
63
  validate = function(data, player)
61
64
  if data.damage > 200 then return false, "damage" end
62
65
  return true
63
66
  end,
64
67
  })
65
68
 
66
- Net.Chat = Lync.definePacket("Chat", {
67
- value = Lync.struct({ msg = Lync.boundedString(200), channel = Lync.u8 }),
68
- })
69
+ Net.Chat = Lync.packet("Chat", Lync.struct({
70
+ msg = Lync.string(200),
71
+ channel = Lync.int(0, 255),
72
+ }))
69
73
 
70
- Net.Ping = Lync.defineQuery("Ping", {
71
- request = Lync.nothing,
72
- response = Lync.f64,
73
- timeout = 3,
74
- })
74
+ Net.Ping = Lync.query("Ping", Lync.nothing, Lync.f64, { timeout = 3 })
75
75
 
76
76
  return table.freeze(Net)
77
77
  ```
@@ -83,22 +83,15 @@ local Lync = require(game.ReplicatedStorage.Lync)
83
83
  local Net = require(game.ReplicatedStorage.Net)
84
84
  local Players = game:GetService("Players")
85
85
 
86
- local alive = Lync.createGroup("alive")
87
-
88
- Lync.onSend(function(data, name, player)
89
- print("[out]", name)
90
- return data
91
- end)
86
+ local alive = Lync.group("alive")
92
87
 
93
- Lync.onDrop(function(player, reason, name, data)
88
+ Lync.onDrop(function(player, reason, name)
94
89
  warn(player.Name, "dropped", name, reason)
95
90
  end)
96
91
 
97
92
  Lync.start()
98
93
 
99
- Players.PlayerAdded:Connect(function(player)
100
- alive:add(player)
101
- end)
94
+ Players.PlayerAdded:Connect(function(player) alive:add(player) end)
102
95
 
103
96
  game:GetService("RunService").Heartbeat:Connect(function()
104
97
  Net.State:send({
@@ -110,24 +103,14 @@ game:GetService("RunService").Heartbeat:Connect(function()
110
103
  }, alive)
111
104
  end)
112
105
 
113
- Net.Hit:listen(function(data, player)
106
+ Net.Hit:on(function(data, player)
114
107
  local target = Players:GetPlayerByUserId(data.targetId)
115
108
  if not target then return end
116
-
117
109
  alive:remove(target)
118
110
  Net.Chat:send({ msg = player.Name .. " eliminated " .. target.Name, channel = 0 }, Lync.all)
119
- Net.State:send({
120
- position = Vector3.zero,
121
- health = 0,
122
- shield = 0,
123
- status = "dead",
124
- alive = false,
125
- }, Lync.except(target))
126
111
  end)
127
112
 
128
- Net.Ping:listen(function(request, player)
129
- return os.clock()
130
- end)
113
+ Net.Ping:handle(function(_, player) return os.clock() end)
131
114
  ```
132
115
 
133
116
  **Client**
@@ -140,474 +123,540 @@ Lync.start()
140
123
 
141
124
  local scope = Lync.scope()
142
125
 
143
- scope:listen(Net.State, function(state)
126
+ scope:on(Net.State, function(state)
144
127
  local character = game.Players.LocalPlayer.Character
145
128
  if not character then return end
146
129
  character:PivotTo(CFrame.new(state.position))
147
130
  end)
148
131
 
149
- scope:listen(Net.Chat, function(data)
150
- print("[chat]", data.msg)
151
- end)
132
+ scope:on(Net.Chat, function(data) print("[chat]", data.msg) end)
152
133
 
153
134
  Net.Hit:send({ targetId = 123, damage = 45.5, headshot = true })
154
135
 
155
136
  local serverTime = Net.Ping:request(nil)
156
- if serverTime then
157
- print("server clock:", serverTime)
158
- end
137
+ if serverTime then print("server clock:", serverTime) end
159
138
  ```
160
139
 
161
140
  ## Lifecycle
162
141
 
163
- | | What it does |
164
- |:---------|:------------|
165
- | `Lync.start()` | Sets up transport. Server creates remotes, client connects. Call once after all definitions. |
166
- | `Lync.VERSION` | `"2.0.0"` |
142
+ | Function | Behavior |
143
+ |:---------|:---------|
144
+ | `Lync.configure(options)` | Sets limits and enables stats. Must be called before `start()`. See [Configuration](#configuration). |
145
+ | `Lync.start()` | Server creates remotes under `ReplicatedStorage.LyncRemotes`. Client waits for them. Connects the Heartbeat flush loop. Errors if called twice. |
146
+ | `Lync.started` | Read-only boolean. `true` after `start()` returns. |
147
+ | `Lync.flush()` | Forces an immediate buffer flush. Resets the accumulator to prevent double-sending on the next Heartbeat. Errors if not started. |
148
+ | `Lync.flushRate(hz)` | 1–60. Default 60. At 60, flushes every Heartbeat directly. Below 60, uses an elapsed-time accumulator with drift correction. Callable at runtime. |
167
149
 
168
150
  ## Packets
169
151
 
170
- `Lync.definePacket(name, config)` returns a Packet.
152
+ `Lync.packet(name, codec, options?)` returns a Packet handle. The second argument is any codec. Options go in the optional third argument.
153
+
154
+ ### Packet Options
155
+
156
+ | Field | Type | Default | Behavior |
157
+ |:------|:-----|:--------|:---------|
158
+ | `unreliable` | `boolean` | `false` | Routes through `UnreliableRemoteEvent`. Incompatible with delta codecs (errors at define time). |
159
+ | `rateLimit` | `RateLimitConfig` | none | Server-side rate limiting on incoming fires. See [Rate Limiting](#rate-limiting). |
160
+ | `validate` | `(data, player) → (bool, string?)` | none | Server-side callback after schema validation. Return `false, "reason"` to drop. Fires `onDrop`. |
161
+ | `maxPayloadBytes` | `number` | none | Maximum bytes a single payload can consume. |
162
+ | `timestamp` | `"frame"`, `"offset"`, or `"full"` | none | Prepends a timestamp to each item. `"frame"` = u8 wrapping counter (1B). `"offset"` = u16 milliseconds into the current second (2B). `"full"` = f64 `os.clock()` (8B). Listeners receive it as a third argument after `sender`. |
171
163
 
172
- | Config | Type | Required | What it does |
173
- |:-------|:-----|:--------:|:-------------|
174
- | `value` | Codec | Yes | How to serialize the payload. |
175
- | `unreliable` | boolean | No | Sends over UnreliableRemoteEvent. Default `false`. Cant use with delta codecs. |
176
- | `rateLimit` | `{ maxPerSecond, burstAllowance? }` | No | Server-side token bucket. Burst defaults to maxPerSecond if you dont set it. |
177
- | `validate` | `(data, player) → (bool, string?)` | No | Server-side. Return `false, "reason"` to drop. Runs after NaN scan. |
178
- | `maxPayloadBytes` | number | No | Server-side. Max bytes a single batch of this packet can consume. Fires `onDrop` with reason `"size"` if exceeded. |
179
- | `timestamp` | `"frame" \| "offset" \| "full"` | No | Prepends a timestamp to each item. `"frame"` = u8 wrapping counter (1B), `"offset"` = u16 ms (2B), `"full"` = f64 clock (8B). Listeners receive it as a third argument. |
164
+ ### Packet Methods
180
165
 
181
- **Server, single `send` with targets:**
166
+ **Sending (server):**
182
167
 
183
168
  ```luau
184
- packet:send(data, player) -- one player
185
- packet:send(data, Lync.all) -- everyone
186
- packet:send(data, Lync.except(player)) -- everyone except one
187
- packet:send(data, Lync.except(p1, p2)) -- everyone except multiple
188
- packet:send(data, { p1, p2 }) -- list of players
189
- packet:send(data, group) -- group object
169
+ packet:send(data, player) -- single player
170
+ packet:send(data, Lync.all) -- all connected players
171
+ packet:send(data, Lync.except(p1, p2)) -- all except specified
172
+ packet:send(data, { p1, p2, p3 }) -- array of players
173
+ packet:send(data, group) -- group members
190
174
  ```
191
175
 
192
- **Client:**
176
+ **Sending (client):**
193
177
 
194
178
  ```luau
195
- packet:send(data) -- send to server
179
+ packet:send(data) -- to server
196
180
  ```
197
181
 
198
- **Shared (both contexts):**
182
+ **Receiving (both):**
199
183
 
200
- | Method | What it does |
201
- |:-------|:------------|
202
- | `packet:listen(fn(data, sender, timestamp?))` | Sender is `Player` on server, `nil` on client. `timestamp` is present when the packet has a `timestamp` config. Returns a Connection. |
203
- | `packet:once(fn(data, sender, timestamp?))` | Auto-disconnects after one fire. |
204
- | `packet:wait()` | Returns `(data, sender)`. |
205
- | `packet:disconnectAll()` | Kills all listeners on this packet. |
184
+ | Method | Behavior |
185
+ |:-------|:---------|
186
+ | `packet:on(fn)` | Connects a listener. `fn(data, sender, timestamp?)`. Server `sender` is `Player`. Client `sender` is `nil`. Returns a [Connection](#connection). |
187
+ | `packet:once(fn)` | Same as `on` but auto-disconnects after one fire. |
188
+ | `packet:wait()` | Yields until the next fire. Returns `(data, sender, timestamp?)`. |
189
+ | `packet:name()` | Returns the registration name string. |
190
+ | `packet:stats()` | Returns `{ bytesSent, bytesReceived, fires, recvFires, drops }`. Populated only when stats are enabled. |
206
191
 
207
192
  ## Queries
208
193
 
209
- `Lync.defineQuery(name, config)` returns a Query. Basically RemoteFunctions but built on RemoteEvents. Returns `nil` if the other side times out or errors.
194
+ `Lync.query(name, requestCodec, responseCodec, options?)` returns a Query handle. Built on RemoteEvents with varint correlation IDs. Returns `nil` on timeout or handler error.
210
195
 
211
- | Config | Type | Required | What it does |
212
- |:-------|:-----|:--------:|:-------------|
213
- | `request` | Codec | Yes | How to serialize the request. |
214
- | `response` | Codec | Yes | How to serialize the response. |
215
- | `timeout` | number | No | Seconds before giving up. Default `5`. |
216
- | `rateLimit` | `{ maxPerSecond, burstAllowance? }` | No | Server-side token bucket on incoming requests. |
217
- | `validate` | `(data, player) → (bool, string?)` | No | Server-side validation on incoming requests. |
196
+ ### Query Options
218
197
 
219
- | Method | Where | What it does |
220
- |:-------|:------|:-------------|
221
- | `query:listen(fn)` | Both | Register a handler. Server gets `fn(request, player) → response`. Client gets `fn(request) → response`. |
222
- | `query:request(data)` | Client | Send request to server, yield until response or timeout. |
223
- | `query:requestFrom(player, data)` | Server | Send request to a specific client, yield until response or timeout. |
224
- | `query:requestAll(data)` | Server | Send request to all players. Returns `{ [Player]: response? }`. |
225
- | `query:requestList(players, data)` | Server | Send request to a list of players. Returns `{ [Player]: response? }`. |
226
- | `query:requestGroup(group, data)` | Server | Send request to all players in a group. Returns `{ [Player]: response? }`. |
198
+ | Field | Type | Default | Behavior |
199
+ |:------|:-----|:--------|:---------|
200
+ | `timeout` | `number` | 5 | Seconds before the request yields `nil`. |
201
+ | `rateLimit` | `RateLimitConfig` | `{ maxPerSecond = 30 }` | Server-side rate limiting on incoming requests. |
202
+ | `validate` | `(data, player) → (bool, string?)` | none | Server-side validation on incoming requests. |
227
203
 
228
- ## Namespaces
204
+ ### Query Methods
229
205
 
230
- `Lync.defineNamespace(name, config)` returns a Namespace. Takes a `packets` table and/or a `queries` table. All names get auto-prefixed with `"YourNamespace."` so nothing collides.
206
+ | Method | Context | Behavior |
207
+ |:-------|:--------|:---------|
208
+ | `query:handle(fn)` | Both | Registers a handler. Server: `fn(request, player) → response`. Client: `fn(request) → response`. Returns a Connection that clears the handler on disconnect. |
209
+ | `query:request(data)` | Client | Sends request to server, yields until response or timeout. Returns the response or `nil`. |
210
+ | `query:request(data, player)` | Server | Sends request to one client, yields until response or timeout. |
211
+ | `query:request(data, target)` | Server | Sends request to multiple targets. Returns `{ [Player]: response? }`. Accepts `Lync.all`, arrays, and groups. |
212
+ | `query:name()` | Both | Returns the registration name. |
213
+ | `query:stats()` | Both | Returns combined stats for the request and response channels. |
231
214
 
232
- The config takes `PacketConfig` and `QueryConfig` objects (same shape you'd pass to `definePacket` / `defineQuery`). The namespace creates and owns the packets/queries internally.
215
+ Each query consumes two packet IDs internally (one for requests, one for responses).
233
216
 
234
- ```luau
235
- local Combat = Lync.defineNamespace("Combat", {
236
- packets = {
237
- Hit = {
238
- value = Lync.struct({ targetId = Lync.u16, damage = Lync.f32, headshot = Lync.bool }),
239
- rateLimit = { maxPerSecond = 30, burstAllowance = 5 },
240
- validate = function(data, player)
241
- if data.damage > 200 then return false, "damage" end
242
- return true
243
- end,
244
- },
245
- Death = { value = Lync.u16 },
246
- },
247
- queries = {
248
- Stats = {
249
- request = Lync.nothing,
250
- response = Lync.struct({ kills = Lync.u16, deaths = Lync.u16 }),
251
- timeout = 3,
252
- },
253
- },
254
- })
217
+ ## Groups
218
+
219
+ `Lync.group(name)` returns a Group. Members are removed automatically on `PlayerRemoving`. Names must be unique (duplicate errors). Destroyed groups free their name for reuse.
255
220
 
256
- -- Access by short name directly on the namespace
257
- Combat.Hit:send(data, player)
258
- Combat.Death:listen(function(targetId, sender) end)
259
- Combat.Stats:listen(function(request, player) return { kills = 10, deaths = 2 } end)
221
+ Groups implement `__iter`, so `for player in group do` works directly.
260
222
 
261
- -- Or via the typed sub-tables
262
- Combat.packets.Hit:send(data, player)
263
- Combat.queries.Stats:request(nil)
223
+ | Method | Returns | Behavior |
224
+ |:-------|:--------|:---------|
225
+ | `group:add(player)` | `boolean` | `true` if added, `false` if already a member. |
226
+ | `group:remove(player)` | `boolean` | `true` if removed, `false` if not a member. |
227
+ | `group:has(player)` | `boolean` | Membership check. |
228
+ | `group:count()` | `number` | Current member count. |
229
+ | `group:destroy()` | — | Clears all members and frees the name. Safe to call multiple times. |
230
+
231
+ ## Scope
232
+
233
+ `Lync.scope()` batches connections for lifecycle-aligned cleanup.
234
+
235
+ ```luau
236
+ local scope = Lync.scope()
237
+ scope:on(packetA, fnA)
238
+ scope:on(packetB, fnB)
239
+ scope:add(someRBXScriptConnection)
240
+ scope:destroy()
264
241
  ```
265
242
 
266
- | Config field | Type | What it does |
267
- |:-------------|:-----|:-------------|
268
- | `packets` | `{ [string]: PacketConfig }?` | Map of short name → packet config. Each entry becomes a Packet on the namespace. |
269
- | `queries` | `{ [string]: QueryConfig }?` | Map of short name → query config. Each entry becomes a Query on the namespace. |
270
-
271
- Access packets and queries by their short name on the returned object: `ns.PacketName`, `ns.QueryName`. Or use the typed sub-tables: `ns.packets.PacketName`, `ns.queries.QueryName`.
272
-
273
- | Method | What it does |
274
- |:-------|:------------|
275
- | `ns:listenAll(fn(name, data, sender, timestamp?))` | Listens to every packet in the namespace. `name` is the short name without prefix. Returns a Connection. |
276
- | `ns:onSend(fn(data, name, player) → data?)` | Send middleware that only runs for this namespace. Returns a remover. |
277
- | `ns:onReceive(fn(data, name, player) → data?)` | Receive middleware that only runs for this namespace. Returns a remover. |
278
- | `ns:disconnectAll()` | Kills all listeners made through `listenAll`. |
279
- | `ns:destroy()` | Kills listeners and removes scoped middleware. Full cleanup. |
280
- | `ns:packetNames()` | Sorted list of packet short names. |
281
- | `ns:queryNames()` | Sorted list of query short names. |
282
- | `ns.packets` | Frozen table mapping short name → Packet object. |
283
- | `ns.queries` | Frozen table mapping short name → Query object. |
243
+ | Method | Behavior |
244
+ |:-------|:---------|
245
+ | `scope:on(source, fn)` | Calls `source:on(fn)` and tracks the returned connection. |
246
+ | `scope:once(source, fn)` | Calls `source:once(fn)` and tracks the returned connection. |
247
+ | `scope:add(connection)` | Accepts both Lync connections and `RBXScriptConnection`. |
248
+ | `scope:destroy()` | Disconnects all tracked connections. Safe to call multiple times. |
284
249
 
285
250
  ## Connection
286
251
 
287
- Returned by `packet:listen()`, `packet:once()`, `query:listen()`, and `ns:listenAll()`.
252
+ Returned by `packet:on()`, `packet:once()`, `query:handle()`, `scope:on()`, and middleware functions.
288
253
 
289
- | | What it does |
290
- |:-------|:------------|
291
- | `connection.connected` | `boolean` |
292
- | `connection:disconnect()` | Stops the listener. |
254
+ | Field/Method | Behavior |
255
+ |:-------------|:---------|
256
+ | `connection.connected` | `boolean`. `true` until disconnected. |
257
+ | `connection:disconnect()` | Stops the listener. O(1) via swap-remove. Safe to call multiple times. Safe to call during a fire (snapshot iteration prevents skipped listeners). |
293
258
 
294
- ## Scope
259
+ ## Middleware
295
260
 
296
- Batches connections for lifecycle-aligned cleanup.
261
+ Global intercept chains on all packets. Handlers run in registration order. Return a transformed value to pass it downstream. Return `nil` to pass through unchanged. Return `Lync.DROP` from `onSend` to silently drop the packet.
297
262
 
298
- ```luau
299
- local scope = Lync.scope()
263
+ All three functions return a [Connection](#connection).
300
264
 
301
- scope:listen(packetA, fnA)
302
- scope:listen(packetB, fnB)
303
- scope:listenAll(namespace, fnC)
265
+ | Function | Behavior |
266
+ |:---------|:---------|
267
+ | `Lync.onSend(fn)` | `fn(data, name, player?) → data?`. Runs before serialization. |
268
+ | `Lync.onReceive(fn)` | `fn(data, name, player?) → data?`. Runs after deserialization and validation. |
269
+ | `Lync.onDrop(fn)` | `fn(player, reason, name, data?)`. Fires when a packet is rejected. Reason is `"rate"`, `"validation"`, or the string returned by the `validate` callback. |
270
+ | `Lync.DROP` | Frozen sentinel. Return from `onSend` to silently drop the packet. |
304
271
 
305
- scope:destroy() -- disconnects everything
306
- ```
272
+ ## Targets
307
273
 
308
- | Method | What it does |
309
- |:-------|:------------|
310
- | `scope:listen(source, fn)` | Calls `source:listen(fn)` and tracks the connection. |
311
- | `scope:once(source, fn)` | Calls `source:once(fn)` and tracks the connection. |
312
- | `scope:listenAll(namespace, fn)` | Calls `namespace:listenAll(fn)` and tracks the connection. |
313
- | `scope:add(connection)` | Also accepts RBXScriptConnection. |
314
- | `scope:destroy()` | Safe to call multiple times. |
274
+ Server-side second argument to `packet:send()` and `query:request()`.
315
275
 
316
- ## Groups
276
+ | Target | Behavior |
277
+ |:-------|:---------|
278
+ | `player` | Single `Player` instance. |
279
+ | `Lync.all` | All connected players via `Players:GetPlayers()`. |
280
+ | `Lync.except(...)` | All players except specified. Accepts any mix of `Player` and Group arguments. |
281
+ | `{ p1, p2, ... }` | Lua array of players. Non-player entries are silently skipped. |
282
+ | `group` | All current members of a [Group](#groups). |
317
283
 
318
- Named player sets. Members get removed automatically on `PlayerRemoving`. `Lync.createGroup(name)` returns a Group object.
284
+ ## Codecs
319
285
 
320
- ```luau
321
- local vips = Lync.createGroup("vips")
286
+ ### Numbers
322
287
 
323
- vips:add(player)
324
- vips:remove(player)
325
- vips:has(player)
288
+ `Lync.int(min, max)` selects the smallest wire type that fits the range:
326
289
 
327
- packet:send(data, vips)
328
- ```
290
+ | Range | Wire | Bytes |
291
+ |:------|:-----|------:|
292
+ | `[0, 255]` | u8 | 1 |
293
+ | `[0, 65535]` | u16 | 2 |
294
+ | `[0, 4294967295]` | u32 | 4 |
295
+ | `[-128, 127]` | i8 | 1 |
296
+ | `[-32768, 32767]` | i16 | 2 |
297
+ | `[-2147483648, 2147483647]` | i32 | 4 |
329
298
 
330
- | Method | Returns | What it does |
331
- |:-------|:--------|:-------------|
332
- | `group:add(player)` | `boolean` | `true` if added, `false` if already in. |
333
- | `group:remove(player)` | `boolean` | `true` if removed, `false` if wasnt in there. |
334
- | `group:has(player)` | `boolean` | Whether the player is in the group. |
335
- | `group:count()` | `number` | Number of members. |
336
- | `group:getSet()` | `{ [Player]: true }` | Snapshot of the internal set. |
337
- | `group:forEach(fn)` | `()` | Calls `fn(player)` for each member. |
338
- | `group:destroy()` | `()` | Removes the group and all memberships. |
299
+ Signed integers use unsigned buffer writes (`writeu8`/`writeu16`/`writeu32`) with two's complement conversion because `writei8`/`writei16`/`writei32` are not FASTCALL-optimized in Luau.
339
300
 
340
- ## Middleware
301
+ | Codec | Bytes | Behavior |
302
+ |:------|------:|:---------|
303
+ | `Lync.f16` | 2 | Half-precision IEEE 754. ~3 decimal digits. ±65504 normal range. Overflow clamps to ±inf. NaN preserved. |
304
+ | `Lync.f32` | 4 | IEEE 754 single-precision. |
305
+ | `Lync.f64` | 8 | IEEE 754 double-precision. |
306
+ | `Lync.bool` | 1 | `true`/`false`. Inside structs, bools are separated and bitpacked (8 per byte). Inside arrays, bools are bitpacked. Standalone uses 1 byte. |
341
307
 
342
- Global intercept on all packets. Handlers run in the order you registered them. Return `Lync.DROP` from a handler to drop the packet. Return the data to pass it through.
308
+ `Lync.float(min, max, precision)` quantizes a float range to an integer range. Wire type is selected by `ceil((max - min) / precision)`: u8 if 255, u16 if 65535, u32 otherwise. Values outside `[min, max]` are clamped.
343
309
 
344
- ```luau
345
- Lync.onSend(function(data, name, player)
346
- if shouldDrop(data) then
347
- return Lync.DROP
348
- end
349
- data.timestamp = os.clock()
350
- return data
351
- end)
352
- ```
310
+ ### Strings and Buffers
311
+
312
+ | Codec | Wire format | Behavior |
313
+ |:------|:------------|:---------|
314
+ | `Lync.string` | varint length + raw bytes | Lengths 0–191 use a 1-byte prefix (dense prefix-varint). 192+ use multi-byte. Binary-safe. |
315
+ | `Lync.string(maxLength)` | same | Callable via `__call`. Same write path. Read rejects if decoded length exceeds `maxLength`. |
316
+ | `Lync.buff` | varint length + raw bytes | Same wire format as string. Read returns an isolated buffer copy. |
353
317
 
354
- | Function | What it does |
355
- |:---------|:------------|
356
- | `Lync.onSend(fn(data, name, player) data \| Lync.DROP)` | Runs before a packet goes out. Returns a remover function. |
357
- | `Lync.onReceive(fn(data, name, player) → data \| Lync.DROP)` | Runs when a packet comes in. Returns a remover function. |
358
- | `Lync.onDrop(fn(player, reason, name, data))` | Fires when a packet gets rejected. Returns a remover function. Supports multiple handlers. Reason is `"nan"`, `"rate"`, `"validate"`, `"size"`, or whatever string your validate function returned. |
359
- | `Lync.DROP` | Frozen sentinel. Return from middleware to drop the packet. |
360
-
361
- Packets that fail validation are dropped individually. Other packets in the same frame from the same player are unaffected.
362
-
363
- ## Target Descriptors
364
-
365
- Used as the second argument to `packet:send()` on the server.
366
-
367
- | Target | What it does |
368
- |:-------|:------------|
369
- | `player` | Send to one player. |
370
- | `Lync.all` | Send to all connected players. |
371
- | `Lync.except(player, ...)` | Send to everyone except the specified players. |
372
- | `{ p1, p2, ... }` | Send to a list of players. |
373
- | `group` | Send to all members of a Group object. |
374
-
375
- ## Types
376
-
377
- ### Primitives
378
-
379
- | Type | Bytes | Range |
380
- |:-----|------:|:------|
381
- | `Lync.u8` | 1 | 0 to 255 |
382
- | `Lync.u16` | 2 | 0 to 65,535 |
383
- | `Lync.u32` | 4 | 0 to 4,294,967,295 |
384
- | `Lync.i8` | 1 | -128 to 127 |
385
- | `Lync.i16` | 2 | -32,768 to 32,767 |
386
- | `Lync.i32` | 4 | -2,147,483,648 to 2,147,483,647 |
387
- | `Lync.f16` | 2 | ±65,504, roughly 3 digits of precision |
388
- | `Lync.f32` | 4 | IEEE 754 single |
389
- | `Lync.f64` | 8 | IEEE 754 double |
390
- | `Lync.bool` | 1 | true/false. Gets packed into bitfields when inside structs, and 8-per-byte when inside arrays. |
391
-
392
- ### Datatypes
393
-
394
- | Type | Bytes | What it is |
395
- |:-----|------:|:-----------|
396
- | `Lync.string` | varint + N | Varint length prefix then raw bytes. |
397
- | `Lync.vec2` | 8 | 2x f32. |
398
- | `Lync.vec3` | 12 | 3x f32. |
399
- | `Lync.cframe` | 24 | Position as 3x f32, rotation as axis-angle 3x f32. |
400
- | `Lync.color3` | 3 | RGB 0-255 per channel, clamped. |
401
- | `Lync.inst` | 2 | Instance ref through sidecar array. Requires refs on read, throws without them. |
402
- | `Lync.buff` | varint + N | Varint length prefix then raw bytes. |
403
- | `Lync.udim` | 8 | Scale f32 + Offset i32. |
404
- | `Lync.udim2` | 16 | 2x UDim (X then Y). |
405
- | `Lync.numberRange` | 8 | Min f32 + Max f32. |
406
- | `Lync.rect` | 16 | Min.X f32 + Min.Y f32 + Max.X f32 + Max.Y f32. |
407
- | `Lync.vec2int16` | 4 | 2x i16. |
408
- | `Lync.vec3int16` | 6 | 3x i16. |
409
- | `Lync.region3` | 24 | Min Vec3 + Max Vec3 as 6x f32. |
410
- | `Lync.region3int16` | 12 | Min Vec3int16 + Max Vec3int16 as 6x i16. |
411
- | `Lync.ray` | 24 | Origin Vec3 + Direction Vec3 as 6x f32. |
412
- | `Lync.numberSequence` | varint + N×12 | Varint count then (time f32 + value f32 + envelope f32) per keypoint. |
413
- | `Lync.colorSequence` | varint + N×7 | Varint count then (time f32 + R u8 + G u8 + B u8) per keypoint. |
414
- | `Lync.boundedString(maxLength)` | varint + N | Same wire format as `Lync.string` but rejects on read if length exceeds `maxLength`. |
318
+ ### Roblox Types
319
+
320
+ All fixed-size types expose `_directWrite` and `_directRead` for struct fast-path optimization, except `Lync.inst` (requires the channel's ref array).
321
+
322
+ | Codec | Bytes | Wire layout |
323
+ |:------|------:|:------------|
324
+ | `Lync.vec2` | 8 | 2× f32 |
325
+ | `Lync.vec3` | 12 | f32 |
326
+ | `Lync.cframe` | 24 | 3× f32 position + 3× f32 axis-angle rotation |
327
+ | `Lync.color3` | 3 | 3× u8 RGB, clamped to [0, 1] then scaled to [0, 255] |
328
+ | `Lync.inst` | 2 | u16 index into sidecar `{ Instance }` array |
329
+ | `Lync.udim` | 8 | f32 Scale + i32 Offset |
330
+ | `Lync.udim2` | 16 | 2× UDim |
331
+ | `Lync.numberRange` | 8 | f32 Min + f32 Max |
332
+ | `Lync.rect` | 16 | 4× f32 |
333
+ | `Lync.ray` | 24 | f32 (Origin + Direction) |
334
+ | `Lync.vec2int16` | 4 | i16 |
335
+ | `Lync.vec3int16` | 6 | i16 |
336
+ | `Lync.region3` | 24 | f32 (Min + Max) |
337
+ | `Lync.region3int16` | 12 | i16 (Min + Max) |
338
+ | `Lync.numberSequence` | varint + N×12 | f32 time + f32 value + f32 envelope per keypoint |
339
+ | `Lync.colorSequence` | varint + N×7 | f32 time + u8 R + u8 G + u8 B per keypoint |
340
+
341
+ ### Quantized Variants
342
+
343
+ These codecs are callable. The bare name gives the lossless version; calling with arguments gives the quantized version.
344
+
345
+ | Codec | Bytes | Behavior |
346
+ |:------|------:|:---------|
347
+ | `Lync.vec2(min, max, precision)` | 2–8 | Per-component quantization. 2B at u8, 4B at u16, 8B at u32. |
348
+ | `Lync.vec3(min, max, precision)` | 3–12 | Per-component quantization. 3B at u8, 6B at u16, 12B at u32. |
349
+ | `Lync.cframe()` | 16 | Smallest-three quaternion compression. 3× f32 position (12B) + 2-bit largest-component index + 3× 10-bit signed quaternion components (4B). Angular precision ≤ 0.16° (~0.003 radians). Saves 8 bytes vs lossless. |
415
350
 
416
351
  ### Composites
417
352
 
418
- | Constructor | What it does |
419
- |:------------|:------------|
420
- | `Lync.struct({ key = codec })` | Named fields. Bools get packed into bitfields automatically. |
421
- | `Lync.array(codec, maxCount?)` | Variable length list with varint count. Optional `maxCount` rejects on read if exceeded. Bool arrays are bitpacked (8 per byte). |
422
- | `Lync.map(keyCodec, valueCodec, maxCount?)` | Key-value pairs with varint count. Optional `maxCount` rejects on read if exceeded. |
423
- | `Lync.optional(codec)` | 1 byte flag, value only if present. |
424
- | `Lync.tuple(codec, codec, ...)` | Ordered positional values, no keys. |
425
- | `Lync.tagged(tagField, { name = codec })` | Discriminated union with a u8 variant tag. Puts `tagField` into the decoded table so you know which variant it is. |
426
-
427
- ### Delta
428
-
429
- Reliable only. Lync will error if you try to use these with `unreliable = true`.
430
-
431
- | Constructor | What it does |
432
- |:------------|:------------|
433
- | `Lync.deltaStruct({ key = codec })` | First frame sends everything. After that only dirty fields get sent via bitmask. If nothing changed it costs 1 byte. |
434
- | `Lync.deltaArray(codec, maxCount?)` | Same idea but for arrays. Dirty elements get sent with varint indices. Optional `maxCount` rejects on read if exceeded. |
435
- | `Lync.deltaMap(keyCodec, valueCodec, maxCount?)` | Delta compression for key-value maps. Sends only upserted and removed entries after the first frame. Optional `maxCount` rejects on read if exceeded. |
436
-
437
- ### Meta
438
-
439
- | Constructor | What it does |
440
- |:------------|:------------|
441
- | `Lync.enum(value, value, ...)` | u8 index, up to 256 variants. |
442
- | `Lync.quantizedFloat(min, max, precision)` | Fixed-point compression. Picks u8/u16/u32 based on your range and precision. |
443
- | `Lync.quantizedVec3(min, max, precision)` | Same thing but for all 3 components. |
444
- | `Lync.bitfield({ key = spec })` | Sub-byte packing, 1 to 32 bits total. Spec is `{ type = "bool" }` or `{ type = "uint", width = N }` or `{ type = "int", width = N }`. |
445
- | `Lync.custom(size, write, read)` | User-defined fixed-size codec. `write` is `(b, offset, value) → ()`, `read` is `(b, offset) → value`. Plugs into struct/array/delta specialization automatically. |
446
- | `Lync.nothing` | Zero bytes. Reads nil. Good for fire-and-forget signals. |
447
- | `Lync.unknown` | Skips serialization entirely, goes through Roblox's sidecar. Requires refs array on read (same as `Lync.inst`). Use when you dont have a codec for the value. |
448
- | `Lync.auto` | Self-describing. Writes a u8 type tag then the value. Handles nil, bool, all number types, string, vec2, vec3, color3, cframe, buffer, udim, udim2, numberRange, rect, vec2int16, vec3int16, region3, region3int16, ray, numberSequence, colorSequence. |
353
+ | Constructor | Behavior |
354
+ |:------------|:---------|
355
+ | `Lync.struct(schema)` | `{ [string]: Codec }`. Fields serialized in sorted key order. Bools separated and bitpacked after all non-bool fields. All-fixed-size structs expose `_size`, `_directWrite`, `_directRead`. |
356
+ | `Lync.array(element, maxCount?)` | Varint count + elements. Fixed-size elements use a stride loop. Bool elements are bitpacked. Optional `maxCount` rejects on read. |
357
+ | `Lync.map(keyCodec, valueCodec, maxCount?)` | Varint count + key-value pairs. |
358
+ | `Lync.optional(codec)` | 1-byte flag. `0` = nil. `1` = value follows. |
359
+ | `Lync.tuple(...)` | Positional values without keys. All-fixed-size tuples expose `_size`. |
360
+ | `Lync.tagged(tagField, variants)` | Discriminated union. u8 variant tag. `variants` is `{ [string]: Codec }`, sorted alphabetically for deterministic tag assignment. Tag field is injected on read. |
449
361
 
450
- ## Benchmarks
362
+ ### Delta Codecs
451
363
 
452
- ### Lync Tests
364
+ Reliable transport only. Errors at define time if combined with `unreliable = true`.
453
365
 
454
- 1,000 packets/frame, 10 seconds, one player.
366
+ Delta codecs serialize into a scratch buffer and compare byte-for-byte against a cached baseline. Identical bytes produce a 1-byte `UNCHANGED` flag. Any difference triggers a full re-send prefixed with a `FULL` flag byte.
455
367
 
456
- | Scenario | Without Lync | With Lync | FPS |
457
- |:---------|------------:|---------:|----:|
458
- | Static booleans (1B) | 480 Kbps | **2.34 Kbps** | 60.00 |
459
- | Static entities (34B) | 16,320 Kbps | **2.62 Kbps** | 60.00 |
460
- | Moving entities | 16,320 Kbps | **3.14 Kbps** | 60.00 |
461
- | Chaotic entities | 16,320 Kbps | **4.76 Kbps** | 59.99 |
368
+ | Constructor | Behavior |
369
+ |:------------|:---------|
370
+ | `Lync.deltaStruct(schema)` | Same schema as `struct`. First frame is always full. |
371
+ | `Lync.deltaArray(element, maxCount?)` | Delta-framed array. |
372
+ | `Lync.deltaMap(keyCodec, valueCodec, maxCount?)` | Delta-framed map. |
462
373
 
463
- ### Cross-Library Comparison
374
+ ### Meta Codecs
464
375
 
465
- Same data shapes and methodology as [Blink's benchmark suite](https://github.com/1Axen/blink/blob/main/benchmark/Benchmarks.md). 1,000 fires/frame, 10 seconds, same data every frame. Kbps scaled by 60/FPS.
466
-
467
- **Entities** (100x struct of 6x u8, fired 1000 times/frame)
468
-
469
- | Tool (FPS) | Median | P0 | P80 | P90 | P95 | P100 |
470
- |:-----------|-------:|---:|----:|----:|----:|-----:|
471
- | roblox | 16.00 | 16.00 | 15.00 | 15.00 | 15.00 | 15.00 |
472
- | **lync** | **60.00** | 61.00 | 60.00 | 60.00 | 60.00 | 59.00 |
473
- | blink | 42.00 | 45.00 | 42.00 | 42.00 | 42.00 | 42.00 |
474
- | zap | 39.00 | 40.00 | 38.00 | 38.00 | 38.00 | 38.00 |
475
- | bytenet | 32.00 | 34.00 | 32.00 | 32.00 | 32.00 | 31.00 |
476
-
477
- | Tool (Kbps) | Median | P0 | P80 | P90 | P95 | P100 |
478
- |:------------|-------:|---:|----:|----:|----:|-----:|
479
- | roblox | 559,364 | 559,364 | 676,715 | 676,715 | 676,715 | 784,081 |
480
- | **lync** | **3.61** | 3.53 | 3.63 | 3.64 | 3.64 | 4.64 |
481
- | blink | 41.81 | 26.30 | 42.40 | 42.48 | 42.48 | 42.62 |
482
- | zap | 41.71 | 25.46 | 42.19 | 42.32 | 42.32 | 42.93 |
483
- | bytenet | 41.64 | 22.84 | 42.36 | 42.82 | 42.82 | 43.24 |
484
-
485
- **Booleans** (1000x bool, fired 1000 times/frame)
486
-
487
- | Tool (FPS) | Median | P0 | P80 | P90 | P95 | P100 |
488
- |:-----------|-------:|---:|----:|----:|----:|-----:|
489
- | roblox | 21.00 | 22.00 | 20.00 | 19.00 | 19.00 | 19.00 |
490
- | **lync** | **60.00** | 61.00 | 60.00 | 60.00 | 60.00 | 59.00 |
491
- | blink | 97.00 | 98.00 | 97.00 | 96.00 | 96.00 | 96.00 |
492
- | zap | 52.00 | 53.00 | 51.00 | 51.00 | 51.00 | 49.00 |
493
- | bytenet | 35.00 | 37.00 | 35.00 | 35.00 | 35.00 | 34.00 |
494
-
495
- | Tool (Kbps) | Median | P0 | P80 | P90 | P95 | P100 |
496
- |:------------|-------:|---:|----:|----:|----:|-----:|
497
- | roblox | 353,107 | 196,826 | 690,747 | 842,240 | 842,240 | 1,124,176 |
498
- | **lync** | **4.31** | 3.85 | 4.36 | 4.38 | 4.38 | 4.44 |
499
- | blink | 7.91 | 7.41 | 7.93 | 7.99 | 7.99 | 8.00 |
500
- | zap | 8.10 | 5.75 | 8.17 | 8.22 | 8.22 | 8.27 |
501
- | bytenet | 8.11 | 5.07 | 8.35 | 8.46 | 8.46 | 8.47 |
376
+ | Constructor | Behavior |
377
+ |:------------|:---------|
378
+ | `Lync.enum(...)` | String enum. u8 index, up to 256 variants. Errors on unknown values at write time and duplicate values at define time. |
379
+ | `Lync.bitfield(schema)` | Sub-byte packing, 1–32 bits. Spec: `{ type = "bool" }`, `{ type = "uint", width = N }`, or `{ type = "int", width = N }`. Wire: 1B ≤8 bits, 2B ≤16 bits, 4B ≤32 bits. Signed ints use sign extension. Fields sorted alphabetically. |
380
+ | `Lync.custom(size, write, read)` | User-defined fixed-size codec. `write(buffer, offset, value)`, `read(buffer, offset) value`. |
381
+ | `Lync.nothing` | Zero bytes. Reads `nil`. |
382
+ | `Lync.unknown` | Bypasses buffer serialization. Values go through the remote's sidecar array. Warns at define time if used without `validate`. |
383
+ | `Lync.auto` | Self-describing. u8 type tag + value. Integers auto-sized. Floats try f32 then f64. Supports nil, bool, number, string, buffer, and 15 Roblox types. Tables error. |
502
384
 
503
- > [!NOTE]
504
- > Lync benchmarks run on Ryzen 7 7800X3D, 32GB DDR5-4800. Other tool numbers are from [Blink's published benchmarks](https://github.com/1Axen/blink/blob/main/benchmark/Benchmarks.md) (v0.17.1, Ryzen 9 7900X, 34GB DDR5-4800). Different CPUs so FPS numbers arent directly comparable but bandwidth numbers are since Kbps is scaled by 60/FPS. Lync hits the 60 FPS frame cap in both tests.
385
+ ## Wire Protocol
505
386
 
506
- ## Stats
387
+ ### Dense Prefix-Varint
507
388
 
508
- Off by default. Call `Lync.enableStats()` before `Lync.start()` to activate. When disabled, zero overhead on send and receive paths.
389
+ Variable-length unsigned integer encoding. 1-byte range covers 0–191 (LEB128 only covers 0–127).
509
390
 
510
- Per-packet counters are available directly on the Packet object. Per-player counters are available via `Lync.getPlayerStats()`.
391
+ | Range | Bytes | Encoding |
392
+ |:------|------:|:---------|
393
+ | 0–191 | 1 | Direct value |
394
+ | 192–8,383 | 2 | `0xC0 + high5`, `low8` |
395
+ | 8,384–1,056,959 | 3 | `0xE0 + high4`, `low16 LE` |
396
+ | 1,056,960–4,294,967,295 | 5 | `0xF0`, `u32 LE` |
511
397
 
512
- ```luau
513
- Lync.enableStats()
514
- Lync.start()
398
+ ### MSB Batch Framing
515
399
 
516
- -- Per-packet (both sides)
517
- print(Net.State:getBytesSent(), Net.State:getFires(), Net.State:getDrops())
400
+ All sends within one Heartbeat are batched into a single buffer per player per reliability channel.
518
401
 
519
- -- Per-player (server only)
520
- local stats = Lync.getPlayerStats(player)
521
- if stats then
522
- print(stats.bytesSent, stats.bytesReceived)
523
- end
402
+ **Single-item:** `[1IIIIIII] [payload]` — MSB set, 7-bit packet ID, no count byte. 1-byte header.
524
403
 
525
- Lync.resetStats() -- zeros everything in-place
526
- ```
404
+ **Multi-item:** `[0IIIIIII] [u16 count] [payload₁] ...` — MSB clear, u16 item count follows. Used when ≥2 sends to the same packet occur in one frame.
527
405
 
528
- | Function / Method | What it does |
529
- |:-------|:----------------|
530
- | `Lync.enableStats()` | Activates stat counters. Call before `start()`. |
531
- | `packet:getBytesSent()` | Wire bytes produced (includes batch header overhead). |
532
- | `packet:getBytesReceived()` | Wire bytes consumed on receive. |
533
- | `packet:getFires()` | Send fire count. |
534
- | `packet:getRecvFires()` | Receive fire count. |
535
- | `packet:getDrops()` | Gate rejections (rate limit, NaN, validate). |
536
- | `Lync.getPlayerStats(player)` | Returns `{ bytesSent, bytesReceived }` or nil. Server only. |
537
- | `Lync.resetStats()` | Zeros all counters in-place. |
406
+ The single-item path saves 2 bytes per packet per frame vs always writing a count. Maximum 127 packet IDs (7 bits).
538
407
 
539
- ## Flush Control
408
+ ### XOR Framing
540
409
 
541
- By default Lync flushes at 60hz (every Heartbeat). You can change this at runtime.
410
+ Reliable channels XOR the current frame against the previous before sending. The receiver XOR's against its previous decoded frame to recover the original. Produces long zero runs that compress well under Roblox's internal deflate.
542
411
 
543
- ```luau
544
- Lync.setFlushRate(30) -- flush every ~33ms instead of ~16ms
545
- Lync.flush() -- force an immediate flush, resets the accumulator
546
- ```
412
+ XOR operates in u32-aligned chunks with u8 remainder. Mismatched frame sizes are handled: excess bytes in a longer frame are copied directly.
547
413
 
548
- | Function | What it does |
549
- |:---------|:------------|
550
- | `Lync.setFlushRate(hz)` | 1 to 60. Default 60. Callable at runtime. At 60hz, flushes every Heartbeat directly (no accumulator). Below 60, uses an elapsed-time accumulator with drift correction. |
551
- | `Lync.flush()` | Immediate flush. Skips the next scheduled Heartbeat flush to prevent double-sending and XOR chain desync. |
414
+ Unreliable channels skip XOR (no guaranteed frame ordering).
552
415
 
553
416
  ## Security
554
417
 
418
+ ### Schema Validation
419
+
420
+ Every incoming packet on the server passes through Gate before listeners fire:
421
+
422
+ - **`_typeCheck`**: Rejects wrong `typeof`.
423
+ - **`_isInteger` + `_min`/`_max`**: Rejects non-integers, NaN, inf, out-of-range.
424
+ - **`_schema`** (struct codecs): Recursive per-field validation.
425
+ - **Fallback**: NaN/inf scan up to `validationDepth` levels for codecs without metadata.
426
+
427
+ Rejected packets fire `onDrop` and are silently discarded. Other packets in the same frame from the same player are unaffected.
428
+
429
+ ### Rate Limiting
430
+
431
+ Two modes (mutually exclusive):
432
+
433
+ **Token bucket:** `{ maxPerSecond = N, burst = M }`. Tokens refill at N/sec. Burst defaults to 1. Each fire costs one token.
434
+
435
+ **Cooldown:** `{ cooldown = seconds }`. Rejects fires within `cooldown` seconds of the last accepted fire.
436
+
437
+ Global rate limit: `Lync.configure({ globalRateLimit = { maxPerSecond = N } })`. Checked before per-packet limits.
438
+
555
439
  ### Bandwidth Throttle
556
440
 
557
- Server-side per-player bandwidth strike counter. Counts consecutive oversized frames with decay. Protects against clients flooding the server.
441
+ `Lync.configure({ bandwidthLimit = { softLimit = bytes, maxStrikes = N } })`. Per-player. Oversized frames increment strikes. Small frames decrement (decay). Exceeding `maxStrikes` drops the entire frame.
558
442
 
559
- ```luau
560
- Lync.setBandwidthLimit(16384, 10) -- 16KB soft limit, 10 strikes before drop
561
- ```
443
+ ## Stats
562
444
 
563
- Fires `onDrop` with reason `"bandwidth"` when a player exceeds the threshold. Read failures (corrupted buffers, XOR desync) also count as strikes.
445
+ Disabled by default. Zero overhead when off. Enable via `Lync.configure({ stats = true })`.
564
446
 
565
- ### Unknown Codec Warning
447
+ | Function | Behavior |
448
+ |:---------|:---------|
449
+ | `packet:stats()` | `{ bytesSent, bytesReceived, fires, recvFires, drops }` |
450
+ | `Lync.stats.player(player)` | `{ bytesSent, bytesReceived }` or `nil`. Server only. |
451
+ | `Lync.stats.reset()` | Zeros all counters. |
566
452
 
567
- If a packet uses `Lync.unknown` anywhere in its codec tree without a `validate` callback, Lync prints a warning at define time. The `unknown` codec bypasses schema validation entirely; client data goes through Roblox's sidecar without type checking. Adding `validate` suppresses the warning.
453
+ ## Debug
568
454
 
569
- ## Packet Capture
455
+ | Function | Behavior |
456
+ |:---------|:---------|
457
+ | `Lync.debug.pending()` | In-flight query request count. |
458
+ | `Lync.debug.registrations()` | Frozen array of `{ name, id, kind, isUnreliable }`. |
570
459
 
571
- Server-only debug tool. Records raw and XOR'd buffer hex for analysis.
460
+ ## Configuration
572
461
 
573
- ```luau
574
- Lync.startCapture("My test")
575
- -- fire packets...
576
- Lync.flush()
577
- Lync.stopCapture()
462
+ `Lync.configure(options)` — call before `Lync.start()`.
578
463
 
579
- Lync.startCapture("Another test")
580
- -- fire packets...
581
- Lync.flush()
582
- Lync.stopCapture()
464
+ | Option | Default | Range | Behavior |
465
+ |:-------|--------:|:------|:---------|
466
+ | `channelMaxSize` | 262,144 | 4,096–1,048,576 | Max bytes per channel buffer per frame. |
467
+ | `validationDepth` | 16 | 4–32 | Max recursion for NaN/inf scanning. |
468
+ | `poolSize` | 16 | 2–128 | ChannelState reuse pool size. |
469
+ | `bandwidthLimit` | none | — | `{ softLimit, maxStrikes }`. Per-player. |
470
+ | `globalRateLimit` | none | — | `{ maxPerSecond }`. Per-player across all packets. |
471
+ | `stats` | `false` | — | Enables stat counters. |
583
472
 
584
- Lync.dumpCaptures() -- writes JSON to ServerStorage.LyncCapture
585
- ```
473
+ ## Limits
586
474
 
587
- Each entry contains the label, frame number, raw hex (pre-XOR), XOR'd hex (post-XOR, nil for unreliable), byte count, and refs count. Hex is capped at 512 bytes per buffer to keep the output manageable.
588
-
589
- | Function | What it does |
590
- |:---------|:------------|
591
- | `Lync.startCapture(label?)` | Start recording. Tags entries with the label. |
592
- | `Lync.stopCapture()` | Stop recording. |
593
- | `Lync.dumpCaptures()` | Writes all entries as JSON to a StringValue in ServerStorage, then clears. |
594
-
595
- ## Limits & Configuration
596
-
597
- Call these before `Lync.start()` unless noted otherwise.
598
-
599
- | What | Default | How to change | Notes |
600
- |:-----|--------:|:--------------|:------|
601
- | Packet types | 255 | Cant change | u8 on the wire. Each query eats 2 IDs. |
602
- | Buffer per channel per frame | 256 KB | `Lync.setChannelMaxSize(n)` | 4 KB to 1 MB. |
603
- | Concurrent queries | 65,536 | Cant change | Varint correlation IDs. Freed on response or timeout. `Lync.queryPendingCount()` returns in-flight count. Queries default to 30/s rate limit. |
604
- | Stats | Off | `Lync.enableStats()` | Zero overhead when off. Counters on packets and players. |
605
- | NaN/inf scan depth | 16 | `Lync.setValidationDepth(n)` | 4 to 32. |
606
- | Channel pool | 16 | `Lync.setPoolSize(n)` | 2 to 128. Extra gets GCd. |
607
- | Flush rate | 60 hz | `Lync.setFlushRate(n)` | 1 to 60. Runtime-safe. |
608
- | Bandwidth limit | 16 KB / 10 strikes | `Lync.setBandwidthLimit(n, m)` | Server-only. Per-player. |
609
- | Namespaces | 64 | Cant change | |
610
- | Delta + unreliable | Nope | Cant change | Errors at define time. |
475
+ | Constraint | Value |
476
+ |:-----------|------:|
477
+ | Max packet/query registrations | 127 (7-bit wire ID, queries use 2 each) |
478
+ | Max buffer per channel per frame | 256 KB default, 1 MB max |
479
+ | Max concurrent query requests | 65,536 (varint correlation IDs) |
480
+ | `Lync.enum` variants | 256 |
481
+ | `Lync.bitfield` total bits | 32 |
482
+ | `Lync.tagged` variants | 256 |
483
+ | Bool packing density | 8 per byte |
484
+ | String inline varint threshold | 191 bytes (1B prefix), 192+ uses multi-byte |
485
+ | Delta + unreliable | Not allowed (define-time error) |
486
+
487
+ ## Benchmarks
488
+
489
+ Run `rojo serve bench.project.json`, open in Studio with one local server + one client.
490
+
491
+ See `bench/Run.server.luau` for full configuration and methodology.
492
+
493
+ ### Wire Sizes
494
+
495
+ Exact byte count per codec write. Raw payload only no batch framing overhead included.
496
+
497
+ | Codec | Input | Bytes |
498
+ |:------|:------|------:|
499
+ | `bool` | `true` | 1 |
500
+ | `int(0, 255)` | `42` | 1 |
501
+ | `int(0, 65535)` | `1000` | 2 |
502
+ | `int(0, 1000000)` | `500000` | 4 |
503
+ | `int(-128, 127)` | `-50` | 1 |
504
+ | `f16` | `42.5` | 2 |
505
+ | `f32` | `3.14` | 4 |
506
+ | `f64` | `π` | 8 |
507
+ | `nothing` | `nil` | 0 |
508
+ | `string` | `""` (empty) | 1 |
509
+ | `string` | 5 chars | 6 |
510
+ | `string` | 191 chars (max inline prefix) | 192 |
511
+ | `string` | 192 chars (varint prefix) | 194 |
512
+ | `string` | 1000 chars | 1002 |
513
+ | `vec2` | lossless | 8 |
514
+ | `vec2(0, 100, 1)` | u8 quantized | 2 |
515
+ | `vec3` | lossless | 12 |
516
+ | `vec3(0, 100, 1)` | u8 quantized | 3 |
517
+ | `vec3(-1000, 1000, 0.1)` | u16 quantized | 6 |
518
+ | `cframe` | lossless | 24 |
519
+ | `cframe()` | smallest-three | 16 |
520
+ | `color3` | RGB | 3 |
521
+ | `ray` | origin + direction | 24 |
522
+ | entity struct | 6 fields + bool (lossless) | 34 |
523
+ | entity struct | quantized fields (compact) | 13 |
524
+ | bitfield | bool + uint packed | 2 |
525
+ | `array` × 100 entities | 100× struct(6× u8) | 601 |
526
+ | `array` × 1000 bools | bitpacked | 127 |
527
+
528
+ ### Codec Throughput
529
+
530
+ Isolated CPU cost. No networking. Encode + decode measured independently. 100k iterations with warmup.
531
+
532
+ | Codec | Bytes | Encode | Decode | Round-trips/sec |
533
+ |:------|------:|-------:|-------:|----------------:|
534
+ | `bool` | 1 | 44ns | 29ns | 13,862,127 |
535
+ | `int(0, 255)` | 1 | 42ns | 28ns | 14,387,868 |
536
+ | `int(0, 65535)` | 2 | 41ns | 28ns | 14,395,324 |
537
+ | `f16` | 2 | 61ns | 42ns | 9,686,824 |
538
+ | `f32` | 4 | 41ns | 25ns | 15,003,300 |
539
+ | `f64` | 8 | 41ns | 26ns | 14,844,063 |
540
+ | `string` (empty) | 1 | 30ns | 22ns | 19,240,019 |
541
+ | `string` (10 chars) | 11 | 46ns | 60ns | 9,441,889 |
542
+ | `string` (100 chars) | 101 | 48ns | 91ns | 7,166,506 |
543
+ | `string` (1000 chars) | 1002 | 76ns | 238ns | 3,179,953 |
544
+ | `vec2` | 8 | 75ns | 43ns | 8,417,720 |
545
+ | `vec3` | 12 | 53ns | 27ns | 12,360,328 |
546
+ | `vec3` (quantized) | 3 | 130ns | 85ns | 4,636,348 |
547
+ | `cframe` (lossless) | 24 | 92ns | 144ns | 4,232,266 |
548
+ | `cframe()` (compressed) | 16 | 123ns | 170ns | 3,413,621 |
549
+ | `color3` | 3 | 125ns | 58ns | 5,482,756 |
550
+ | `udim2` | 16 | 235ns | 112ns | 2,880,482 |
551
+ | entity struct | 34 | 239ns | 395ns | 1,578,183 |
552
+ | entity compact | 13 | 377ns | 490ns | 1,153,064 |
553
+ | bitfield flags | 2 | 142ns | 332ns | 2,107,486 |
554
+ | 100× entity array | 601 | 15.2µs | 34.1µs | 20,306 |
555
+ | 1000× bool array | 127 | 4.3µs | 5.1µs | 106,806 |
556
+
557
+ ### Delta Savings
558
+
559
+ Byte cost across three consecutive writes: initial (full), identical repeat (unchanged), and single-field mutation (changed).
560
+
561
+ | Codec | Full | Unchanged | Changed | Savings |
562
+ |:------|-----:|----------:|--------:|--------:|
563
+ | `deltaStruct` (entity) | 35B | 1B | 35B | 97% |
564
+ | `deltaStruct` (compact) | 14B | 1B | 14B | 93% |
565
+ | `deltaArray` (100× entity) | 602B | 1B | 1B | 100% |
566
+ | `deltaArray` (1000× bool) | 128B | 1B | 1B | 99% |
567
+ | `deltaMap` (string → u8) | 19B | 1B | 19B | 95% |
568
+
569
+ ### Batch Framing
570
+
571
+ MSB single-item batches use a 1-byte header. Multi-item batches add a u16 count after the header.
572
+
573
+ | Scenario | Total bytes | Per-item overhead |
574
+ |:---------|----------:|------------------:|
575
+ | 1 × u8 (single-item) | 2B | 1B |
576
+ | 10 × u8 (multi-item) | 13B | 0.3B |
577
+
578
+ ### Network Throughput
579
+
580
+ Live sends to one player. Measured over 8 seconds. FPS and Kbps at median and tail.
581
+
582
+ | Packet | Fires/frame | FPS median | FPS p1 | Kbps median | Kbps p95 | Kbps p99 |
583
+ |:-------|:---:|----:|----:|-----:|-----:|-----:|
584
+ | booleans | 1000 | 60 | 59.9 | 2.5 | 6.2 | 6.2 |
585
+ | entity struct | 1000 | 60 | 59.9 | 2.3 | 2.4 | 2.4 |
586
+ | entity compact | 1000 | 60 | 59.9 | 2.4 | 2.5 | 2.5 |
587
+ | 100× entities | 100 | 60 | 59.9 | 2.3 | 3.1 | 3.1 |
588
+ | 1000× bools | 100 | 60 | 59.9 | 2.3 | 2.3 | 2.3 |
589
+ | bitfield flags | 1000 | 60 | 59.9 | 2.4 | 2.5 | 2.5 |
590
+ | cframe lossless | 1000 | 60 | 59.9 | 2.5 | 2.5 | 2.5 |
591
+ | cframe compressed | 1000 | 60 | 59.8 | 2.3 | 2.3 | 2.3 |
592
+
593
+ ---
594
+
595
+ ### Cross-Library Comparison
596
+
597
+ The tables below use the same data shapes and methodology as [Blink's published benchmarks](https://github.com/1Axen/blink/blob/main/benchmark/Benchmarks.md): 1,000 fires/frame, same data every frame, 10 seconds, Kbps scaled by 60/FPS.
598
+
599
+ Numbers for `blink`, `zap`, `bytenet`, and `roblox` are copied directly from [Blink v0.17.1 results](https://github.com/1Axen/blink/blob/main/benchmark/Benchmarks.md) (2025-04-30).
600
+
601
+ > [!NOTE]
602
+ > **Architectural differences that affect these numbers:**
603
+ > - Lync batches all sends into one buffer per Heartbeat frame. Other tools fire one RemoteEvent per `send()`, paying ~40 bytes of Roblox overhead per call.
604
+ > - Lync includes server-side schema validation and rate limiting. Other tools do not.
605
+ > - Lync bitpacks bool arrays (1,000 bools ≈ 127 bytes vs ~1,002 bytes for 1-byte-per-bool).
606
+ > - Lync uses runtime codecs. Blink and Zap use code generation with zero runtime schema.
607
+ > - Delta compression is not exercised here (same data every frame). See [Delta Savings](#delta-savings) for the real-world impact.
608
+ > - FPS is hardware-dependent. Kbps is FPS-scaled, making it comparable across machines.
609
+
610
+ **Tool versions:** blink v0.17.1 · zap v0.6.20 · bytenet v0.4.3 · lync v2.1.0
611
+
612
+ **Data shapes:** Entities = `100× struct { id u8, x u8, y u8, z u8, orientation u8, animation u8 }`. Booleans = `1000× bool`. [Source](https://github.com/1Axen/blink/blob/main/benchmark/src/shared/benches).
613
+
614
+ #### Entities — FPS
615
+
616
+ | Tool | Median | P0 | P80 | P90 | P95 | P100 | Loss |
617
+ |:-----|-------:|---:|----:|----:|----:|-----:|-----:|
618
+ | roblox | 16.00 | 16.00 | 15.00 | 15.00 | 15.00 | 15.00 | 0% |
619
+ | **lync** | **60.00** | **61.00** | **60.00** | **60.00** | **60.00** | **58.00** | **0%** |
620
+ | blink | 42.00 | 45.00 | 42.00 | 42.00 | 42.00 | 42.00 | 0% |
621
+ | zap | 39.00 | 40.00 | 38.00 | 38.00 | 38.00 | 38.00 | 0% |
622
+ | bytenet | 32.00 | 34.00 | 32.00 | 32.00 | 32.00 | 31.00 | 0% |
623
+
624
+ #### Entities — Kbps
625
+
626
+ | Tool | Median | P0 | P80 | P90 | P95 | P100 | Loss |
627
+ |:-----|-------:|---:|----:|----:|----:|-----:|-----:|
628
+ | roblox | 559,364 | 559,364 | 676,716 | 676,716 | 676,716 | 784,082 | 0% |
629
+ | **lync** | **3.68** | **3.61** | **3.72** | **3.75** | **3.75** | **4.18** | **0%** |
630
+ | blink | 41.81 | 26.30 | 42.40 | 42.48 | 42.48 | 42.62 | 0% |
631
+ | zap | 41.71 | 25.46 | 42.19 | 42.32 | 42.32 | 42.93 | 0% |
632
+ | bytenet | 41.64 | 22.84 | 42.36 | 42.82 | 42.82 | 43.24 | 0% |
633
+
634
+ #### Booleans — FPS
635
+
636
+ | Tool | Median | P0 | P80 | P90 | P95 | P100 | Loss |
637
+ |:-----|-------:|---:|----:|----:|----:|-----:|-----:|
638
+ | roblox | 21.00 | 22.00 | 20.00 | 19.00 | 19.00 | 19.00 | 0% |
639
+ | **lync** | **60.00** | **61.00** | **60.00** | **60.00** | **60.00** | **59.00** | **0%** |
640
+ | blink | 97.00 | 98.00 | 97.00 | 96.00 | 96.00 | 96.00 | 0% |
641
+ | zap | 52.00 | 53.00 | 51.00 | 51.00 | 51.00 | 49.00 | 0% |
642
+ | bytenet | 35.00 | 37.00 | 35.00 | 35.00 | 35.00 | 34.00 | 0% |
643
+
644
+ #### Booleans — Kbps
645
+
646
+ | Tool | Median | P0 | P80 | P90 | P95 | P100 | Loss |
647
+ |:-----|-------:|---:|----:|----:|----:|-----:|-----:|
648
+ | roblox | 353,107 | 196,827 | 690,748 | 842,240 | 842,240 | 1,124,176 | 0% |
649
+ | **lync** | **2.49** | **2.44** | **2.50** | **2.52** | **2.52** | **2.54** | **0%** |
650
+ | blink | 7.91 | 7.41 | 7.93 | 7.99 | 7.99 | 8.00 | 0% |
651
+ | zap | 8.10 | 5.75 | 8.17 | 8.22 | 8.22 | 8.27 | 0% |
652
+ | bytenet | 8.11 | 5.07 | 8.35 | 8.46 | 8.46 | 8.47 | 0% |
653
+
654
+ #### Wire Size Comparison
655
+
656
+ | Data | Lync | Other tools | Difference |
657
+ |:-----|-----:|------------:|-----------:|
658
+ | 100× entities | 601B | ~602B | -1B |
659
+ | 1000× bools | 127B | ~1002B | -875B (87% smaller) |
611
660
 
612
661
  ## License
613
662