@axpecter/lync 2.0.0 → 2.1.1

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 +342 -428
  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 +251 -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,22 @@
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="#benchmarks">Benchmarks</a>
8
9
  </p>
9
10
 
11
+ 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.
12
+
10
13
  ## Install
11
14
 
12
- **Wally (Luau)**
15
+ **Wally**
13
16
 
14
17
  ```toml
15
18
  [dependencies]
16
- Lync = "axp3cter/lync@2.0.0"
19
+ Lync = "axp3cter/lync@2.1.1"
17
20
  ```
18
21
 
19
22
  **npm (roblox-ts)**
@@ -26,52 +29,46 @@ npm install @axpecter/lync
26
29
  import Lync from "@axpecter/lync";
27
30
  ```
28
31
 
29
- Or grab the `.rbxm` from [releases](https://github.com/Axp3cter/Lync/releases/latest) and drop it in `ReplicatedStorage`.
32
+ Or grab the `.rbxm` from [Releases](https://github.com/Axp3cter/Lync/releases/latest).
30
33
 
31
34
  > [!IMPORTANT]
32
- > Define everything before calling `Lync.start()`. Packets, queries, namespaces, all of it.
35
+ > Define all packets, queries, and groups before calling `Lync.start()`.
33
36
 
34
37
  ## Example
35
38
 
36
- **Shared**
39
+ **Shared** (`ReplicatedStorage.Net`)
37
40
 
38
41
  ```luau
39
42
  local Lync = require(game.ReplicatedStorage.Lync)
40
43
 
41
44
  local Net = {}
42
45
 
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 },
46
+ Net.State = Lync.packet("State", Lync.deltaStruct({
47
+ position = Lync.vec3,
48
+ health = Lync.float(0, 100, 0.5),
49
+ shield = Lync.float(0, 100, 0.5),
50
+ status = Lync.enum("idle", "moving", "attacking", "dead"),
51
+ alive = Lync.bool,
52
+ }))
53
+
54
+ Net.Hit = Lync.packet("Hit", Lync.struct({
55
+ targetId = Lync.int(0, 65535),
56
+ damage = Lync.float(0, 200, 0.1),
57
+ headshot = Lync.bool,
58
+ }), {
59
+ rateLimit = { maxPerSecond = 30, burst = 5 },
60
60
  validate = function(data, player)
61
61
  if data.damage > 200 then return false, "damage" end
62
62
  return true
63
63
  end,
64
64
  })
65
65
 
66
- Net.Chat = Lync.definePacket("Chat", {
67
- value = Lync.struct({ msg = Lync.boundedString(200), channel = Lync.u8 }),
68
- })
66
+ Net.Chat = Lync.packet("Chat", Lync.struct({
67
+ msg = Lync.string(200),
68
+ channel = Lync.int(0, 255),
69
+ }))
69
70
 
70
- Net.Ping = Lync.defineQuery("Ping", {
71
- request = Lync.nothing,
72
- response = Lync.f64,
73
- timeout = 3,
74
- })
71
+ Net.Ping = Lync.query("Ping", Lync.nothing, Lync.f64, { timeout = 3 })
75
72
 
76
73
  return table.freeze(Net)
77
74
  ```
@@ -83,22 +80,15 @@ local Lync = require(game.ReplicatedStorage.Lync)
83
80
  local Net = require(game.ReplicatedStorage.Net)
84
81
  local Players = game:GetService("Players")
85
82
 
86
- local alive = Lync.createGroup("alive")
83
+ local alive = Lync.group("alive")
87
84
 
88
- Lync.onSend(function(data, name, player)
89
- print("[out]", name)
90
- return data
91
- end)
92
-
93
- Lync.onDrop(function(player, reason, name, data)
85
+ Lync.onDrop(function(player, reason, name)
94
86
  warn(player.Name, "dropped", name, reason)
95
87
  end)
96
88
 
97
89
  Lync.start()
98
90
 
99
- Players.PlayerAdded:Connect(function(player)
100
- alive:add(player)
101
- end)
91
+ Players.PlayerAdded:Connect(function(player) alive:add(player) end)
102
92
 
103
93
  game:GetService("RunService").Heartbeat:Connect(function()
104
94
  Net.State:send({
@@ -110,24 +100,14 @@ game:GetService("RunService").Heartbeat:Connect(function()
110
100
  }, alive)
111
101
  end)
112
102
 
113
- Net.Hit:listen(function(data, player)
103
+ Net.Hit:on(function(data, player)
114
104
  local target = Players:GetPlayerByUserId(data.targetId)
115
105
  if not target then return end
116
-
117
106
  alive:remove(target)
118
107
  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
108
  end)
127
109
 
128
- Net.Ping:listen(function(request, player)
129
- return os.clock()
130
- end)
110
+ Net.Ping:handle(function(_, player) return os.clock() end)
131
111
  ```
132
112
 
133
113
  **Client**
@@ -140,474 +120,408 @@ Lync.start()
140
120
 
141
121
  local scope = Lync.scope()
142
122
 
143
- scope:listen(Net.State, function(state)
123
+ scope:on(Net.State, function(state)
144
124
  local character = game.Players.LocalPlayer.Character
145
125
  if not character then return end
146
126
  character:PivotTo(CFrame.new(state.position))
147
127
  end)
148
128
 
149
- scope:listen(Net.Chat, function(data)
150
- print("[chat]", data.msg)
151
- end)
129
+ scope:on(Net.Chat, function(data) print("[chat]", data.msg) end)
152
130
 
153
131
  Net.Hit:send({ targetId = 123, damage = 45.5, headshot = true })
154
132
 
155
133
  local serverTime = Net.Ping:request(nil)
156
- if serverTime then
157
- print("server clock:", serverTime)
158
- end
134
+ if serverTime then print("server clock:", serverTime) end
159
135
  ```
160
136
 
161
- ## Lifecycle
162
-
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"` |
167
-
168
137
  ## Packets
169
138
 
170
- `Lync.definePacket(name, config)` returns a Packet.
171
-
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. |
139
+ `Lync.packet(name, codec, options?)`
180
140
 
181
- **Server, single `send` with targets:**
141
+ ### Options
182
142
 
183
- ```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
190
- ```
143
+ | Field | Type | Default | Description |
144
+ |:------|:-----|:--------|:------------|
145
+ | `unreliable` | `boolean` | `false` | Send over `UnreliableRemoteEvent`. Cannot use with delta codecs. |
146
+ | `rateLimit` | `RateLimitConfig` | none | Server-side rate limiting. |
147
+ | `validate` | `(data, player) → (bool, string?)` | none | Server-side validation. Return `false, "reason"` to drop. |
148
+ | `maxPayloadBytes` | `number` | none | Max bytes per payload. |
149
+ | `timestamp` | `"frame"`, `"offset"`, or `"full"` | none | Appends a timestamp. `"frame"` = 1B counter. `"offset"` = 2B ms. `"full"` = 8B clock. Received as third argument. |
191
150
 
192
- **Client:**
151
+ ### Sending
193
152
 
194
153
  ```luau
195
- packet:send(data) -- send to server
154
+ -- Server
155
+ packet:send(data, player)
156
+ packet:send(data, Lync.all)
157
+ packet:send(data, Lync.except(p1, p2))
158
+ packet:send(data, { p1, p2, p3 })
159
+ packet:send(data, group)
160
+
161
+ -- Client
162
+ packet:send(data)
196
163
  ```
197
164
 
198
- **Shared (both contexts):**
165
+ ### Receiving
199
166
 
200
- | Method | What it does |
167
+ | Method | Description |
201
168
  |:-------|:------------|
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. |
169
+ | `packet:on(fn)` | `fn(data, sender, timestamp?)`. Returns a Connection. |
170
+ | `packet:once(fn)` | Fires once, then disconnects. |
171
+ | `packet:wait()` | Yields until next fire. Returns `data, sender, timestamp?`. |
172
+ | `packet:name()` | Returns the packet name. |
173
+ | `packet:stats()` | Returns `{ bytesSent, bytesReceived, fires, recvFires, drops }`. Requires stats enabled. |
206
174
 
207
175
  ## Queries
208
176
 
209
- `Lync.defineQuery(name, config)` returns a Query. Basically RemoteFunctions but built on RemoteEvents. Returns `nil` if the other side times out or errors.
177
+ `Lync.query(name, requestCodec, responseCodec, options?)`
210
178
 
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. |
179
+ Request-response built on packets. Returns `nil` on timeout.
218
180
 
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? }`. |
181
+ ### Options
227
182
 
228
- ## Namespaces
183
+ | Field | Type | Default | Description |
184
+ |:------|:-----|:--------|:------------|
185
+ | `timeout` | `number` | 5 | Seconds before yielding `nil`. |
186
+ | `rateLimit` | `RateLimitConfig` | `{ maxPerSecond = 30 }` | Server-side rate limiting. |
187
+ | `validate` | `(data, player) → (bool, string?)` | none | Server-side validation. |
229
188
 
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.
189
+ ### Methods
231
190
 
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.
191
+ | Method | Context | Description |
192
+ |:-------|:--------|:------------|
193
+ | `query:handle(fn)` | Both | Register handler. Server: `fn(request, player) → response`. Client: `fn(request) → response`. |
194
+ | `query:request(data)` | Client | Send to server, yield for response. |
195
+ | `query:request(data, player)` | Server | Send to one client. |
196
+ | `query:request(data, target)` | Server | Send to multiple. Returns `{ [Player]: response? }`. |
197
+ | `query:name()` | Both | Returns the query name. |
198
+ | `query:stats()` | Both | Combined stats for request and response channels. |
233
199
 
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
- })
255
-
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)
260
-
261
- -- Or via the typed sub-tables
262
- Combat.packets.Hit:send(data, player)
263
- Combat.queries.Stats:request(nil)
264
- ```
200
+ Each query consumes two packet IDs internally.
265
201
 
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. |
202
+ ## Groups
284
203
 
285
- ## Connection
204
+ `Lync.group(name)`
286
205
 
287
- Returned by `packet:listen()`, `packet:once()`, `query:listen()`, and `ns:listenAll()`.
206
+ Named player sets. Members auto-removed on `PlayerRemoving`. Iterable with `for player in group do`.
288
207
 
289
- | | What it does |
290
- |:-------|:------------|
291
- | `connection.connected` | `boolean` |
292
- | `connection:disconnect()` | Stops the listener. |
208
+ | Method | Returns | Description |
209
+ |:-------|:--------|:------------|
210
+ | `group:add(player)` | `boolean` | `true` if added. |
211
+ | `group:remove(player)` | `boolean` | `true` if removed. |
212
+ | `group:has(player)` | `boolean` | Membership check. |
213
+ | `group:count()` | `number` | Member count. |
214
+ | `group:destroy()` | — | Clears members, frees name. |
293
215
 
294
216
  ## Scope
295
217
 
296
- Batches connections for lifecycle-aligned cleanup.
218
+ `Lync.scope()`
219
+
220
+ Batches connections for cleanup.
297
221
 
298
222
  ```luau
299
223
  local scope = Lync.scope()
300
-
301
- scope:listen(packetA, fnA)
302
- scope:listen(packetB, fnB)
303
- scope:listenAll(namespace, fnC)
304
-
224
+ scope:on(packetA, fnA)
225
+ scope:on(packetB, fnB)
226
+ scope:add(someRBXScriptConnection)
305
227
  scope:destroy() -- disconnects everything
306
228
  ```
307
229
 
308
- | Method | What it does |
230
+ | Method | Description |
309
231
  |:-------|:------------|
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. |
315
-
316
- ## Groups
317
-
318
- Named player sets. Members get removed automatically on `PlayerRemoving`. `Lync.createGroup(name)` returns a Group object.
319
-
320
- ```luau
321
- local vips = Lync.createGroup("vips")
232
+ | `scope:on(source, fn)` | Connect and track. |
233
+ | `scope:once(source, fn)` | Connect once and track. |
234
+ | `scope:add(connection)` | Track an existing connection. |
235
+ | `scope:destroy()` | Disconnect all. Safe to call multiple times. |
322
236
 
323
- vips:add(player)
324
- vips:remove(player)
325
- vips:has(player)
237
+ ## Connection
326
238
 
327
- packet:send(data, vips)
328
- ```
239
+ Returned by `packet:on()`, `packet:once()`, `query:handle()`, and middleware functions.
329
240
 
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. |
241
+ | Field / Method | Description |
242
+ |:---------------|:------------|
243
+ | `connection.connected` | `boolean` |
244
+ | `connection:disconnect()` | Stops the listener. Safe mid-fire, safe to call multiple times. |
339
245
 
340
246
  ## Middleware
341
247
 
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.
343
-
344
248
  ```luau
345
249
  Lync.onSend(function(data, name, player)
346
- if shouldDrop(data) then
347
- return Lync.DROP
348
- end
349
- data.timestamp = os.clock()
250
+ return data -- or return Lync.DROP to discard
251
+ end)
252
+
253
+ Lync.onReceive(function(data, name, player)
350
254
  return data
351
255
  end)
352
- ```
353
256
 
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. |
257
+ Lync.onDrop(function(player, reason, name, data)
258
+ warn(player.Name, "dropped", name, reason)
259
+ end)
260
+ ```
360
261
 
361
- Packets that fail validation are dropped individually. Other packets in the same frame from the same player are unaffected.
262
+ All three return a Connection.
362
263
 
363
- ## Target Descriptors
264
+ ## Targets
364
265
 
365
- Used as the second argument to `packet:send()` on the server.
266
+ Server-side second argument to `packet:send()`.
366
267
 
367
- | Target | What it does |
268
+ | Target | Description |
368
269
  |:-------|:------------|
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`. |
270
+ | `player` | Single player. |
271
+ | `Lync.all` | All connected players. |
272
+ | `Lync.except(...)` | Everyone except specified players or groups. |
273
+ | `{ p1, p2, ... }` | Array of players. |
274
+ | `group` | All members of a group. |
275
+
276
+ ## Codecs
277
+
278
+ ### Numbers
279
+
280
+ `Lync.int(min, max)` picks the smallest wire type for your range.
281
+
282
+ | Codec | Bytes | Description |
283
+ |:------|------:|:------------|
284
+ | `Lync.int(0, 255)` | 1 | u8 |
285
+ | `Lync.int(0, 65535)` | 2 | u16 |
286
+ | `Lync.int(0, 4294967295)` | 4 | u32 |
287
+ | `Lync.int(-128, 127)` | 1 | i8 |
288
+ | `Lync.int(-32768, 32767)` | 2 | i16 |
289
+ | `Lync.int(-2147483648, 2147483647)` | 4 | i32 |
290
+ | `Lync.f16` | 2 | Half-precision float. ~3 digits. ±65504. |
291
+ | `Lync.f32` | 4 | Single-precision float. |
292
+ | `Lync.f64` | 8 | Double-precision float. |
293
+ | `Lync.bool` | 1 | Bitpacked inside structs and arrays (8 per byte). |
294
+ | `Lync.float(min, max, precision)` | 1–4 | Quantized float. Clamped to range. |
295
+
296
+ ### Strings & Buffers
297
+
298
+ | Codec | Description |
299
+ |:------|:------------|
300
+ | `Lync.string` | Variable length. Binary-safe. |
301
+ | `Lync.string(maxLength)` | Same, but rejects on read if length exceeds `maxLength`. |
302
+ | `Lync.buff` | Variable-length buffer. |
303
+
304
+ ### Roblox Types
305
+
306
+ | Codec | Bytes |
307
+ |:------|------:|
308
+ | `Lync.vec2` | 8 |
309
+ | `Lync.vec3` | 12 |
310
+ | `Lync.cframe` | 24 |
311
+ | `Lync.color3` | 3 |
312
+ | `Lync.inst` | 2 |
313
+ | `Lync.udim` | 8 |
314
+ | `Lync.udim2` | 16 |
315
+ | `Lync.numberRange` | 8 |
316
+ | `Lync.rect` | 16 |
317
+ | `Lync.ray` | 24 |
318
+ | `Lync.vec2int16` | 4 |
319
+ | `Lync.vec3int16` | 6 |
320
+ | `Lync.region3` | 24 |
321
+ | `Lync.region3int16` | 12 |
322
+ | `Lync.numberSequence` | variable |
323
+ | `Lync.colorSequence` | variable |
324
+
325
+ ### Quantized Variants
326
+
327
+ Call the codec to get a quantized version.
328
+
329
+ | Codec | Bytes | Description |
330
+ |:------|------:|:------------|
331
+ | `Lync.vec2(min, max, precision)` | 2–8 | Per-component quantization. |
332
+ | `Lync.vec3(min, max, precision)` | 3–12 | Per-component quantization. |
333
+ | `Lync.cframe()` | 16 | Compressed rotation. ≤0.16° angular error. Saves 8B vs lossless. |
415
334
 
416
335
  ### Composites
417
336
 
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. |
337
+ | Codec | Description |
338
+ |:------|:------------|
339
+ | `Lync.struct({ key = codec })` | Named fields. Bools are automatically bitpacked. |
340
+ | `Lync.array(codec, maxCount?)` | Variable-length list. Bool arrays are bitpacked. |
341
+ | `Lync.map(keyCodec, valueCodec, maxCount?)` | Key-value pairs. |
342
+ | `Lync.optional(codec)` | 1-byte nil flag + value if present. |
343
+ | `Lync.tuple(...)` | Ordered positional values. |
344
+ | `Lync.tagged(tagField, { name = codec })` | Discriminated union with 1-byte tag. |
426
345
 
427
346
  ### Delta
428
347
 
429
- Reliable only. Lync will error if you try to use these with `unreliable = true`.
348
+ Only works with reliable transport. Sends 1 byte when data hasn't changed.
430
349
 
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. |
350
+ | Codec | Description |
351
+ |:------|:------------|
352
+ | `Lync.deltaStruct(schema)` | Delta-compressed struct. |
353
+ | `Lync.deltaArray(codec, maxCount?)` | Delta-compressed array. |
354
+ | `Lync.deltaMap(keyCodec, valueCodec, maxCount?)` | Delta-compressed map. |
436
355
 
437
356
  ### Meta
438
357
 
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. |
449
-
450
- ## Benchmarks
451
-
452
- ### Lync Tests
453
-
454
- 1,000 packets/frame, 10 seconds, one player.
455
-
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 |
462
-
463
- ### Cross-Library Comparison
464
-
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 |
358
+ | Codec | Description |
359
+ |:------|:------------|
360
+ | `Lync.enum(...)` | String enum. Up to 256 variants. 1 byte. |
361
+ | `Lync.bitfield(schema)` | Sub-byte packing. 1–32 bits. |
362
+ | `Lync.custom(size, write, read)` | User-defined fixed-size codec. |
363
+ | `Lync.nothing` | Zero bytes. Reads `nil`. |
364
+ | `Lync.unknown` | Bypasses serialization entirely. Use with `validate`. |
365
+ | `Lync.auto` | Self-describing. Supports nil, bool, numbers, strings, buffers, and Roblox types. |
502
366
 
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.
505
-
506
- ## Stats
507
-
508
- Off by default. Call `Lync.enableStats()` before `Lync.start()` to activate. When disabled, zero overhead on send and receive paths.
367
+ ## Rate Limiting
509
368
 
510
- Per-packet counters are available directly on the Packet object. Per-player counters are available via `Lync.getPlayerStats()`.
511
-
512
- ```luau
513
- Lync.enableStats()
514
- Lync.start()
369
+ Two modes (pick one per packet):
515
370
 
516
- -- Per-packet (both sides)
517
- print(Net.State:getBytesSent(), Net.State:getFires(), Net.State:getDrops())
371
+ **Token bucket:** `{ maxPerSecond = N, burst = M }`
518
372
 
519
- -- Per-player (server only)
520
- local stats = Lync.getPlayerStats(player)
521
- if stats then
522
- print(stats.bytesSent, stats.bytesReceived)
523
- end
373
+ **Cooldown:** `{ cooldown = seconds }`
524
374
 
525
- Lync.resetStats() -- zeros everything in-place
526
- ```
375
+ Global limit across all packets: `Lync.configure({ globalRateLimit = { maxPerSecond = N } })`
527
376
 
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. |
377
+ ## Configuration
538
378
 
539
- ## Flush Control
379
+ `Lync.configure(options)` call before `Lync.start()`.
540
380
 
541
- By default Lync flushes at 60hz (every Heartbeat). You can change this at runtime.
381
+ | Option | Default | Description |
382
+ |:-------|--------:|:------------|
383
+ | `channelMaxSize` | 262,144 | Max buffer bytes per frame (4,096–1,048,576). |
384
+ | `validationDepth` | 16 | Max recursion depth for input validation (4–32). |
385
+ | `poolSize` | 16 | Buffer pool size (2–128). |
386
+ | `bandwidthLimit` | none | `{ softLimit, maxStrikes }`. Per-player bandwidth throttle. |
387
+ | `globalRateLimit` | none | `{ maxPerSecond }`. Global per-player rate limit. |
388
+ | `stats` | `false` | Enables `packet:stats()` and `Lync.stats.player()`. |
542
389
 
543
- ```luau
544
- Lync.setFlushRate(30) -- flush every ~33ms instead of ~16ms
545
- Lync.flush() -- force an immediate flush, resets the accumulator
546
- ```
390
+ ### Lifecycle
547
391
 
548
- | Function | What it does |
392
+ | Function | Description |
549
393
  |:---------|:------------|
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. |
552
-
553
- ## Security
394
+ | `Lync.configure(options)` | Set options before start. |
395
+ | `Lync.start()` | Initialize transport. Call once after all definitions. |
396
+ | `Lync.started` | Read-only boolean. `true` after `start()`. |
397
+ | `Lync.flush()` | Force an immediate send. |
398
+ | `Lync.flushRate(hz)` | Set flush rate. 1–60. Default 60. |
554
399
 
555
- ### Bandwidth Throttle
400
+ ### Stats
556
401
 
557
- Server-side per-player bandwidth strike counter. Counts consecutive oversized frames with decay. Protects against clients flooding the server.
402
+ Enable with `Lync.configure({ stats = true })`.
558
403
 
559
- ```luau
560
- Lync.setBandwidthLimit(16384, 10) -- 16KB soft limit, 10 strikes before drop
561
- ```
404
+ | Function | Description |
405
+ |:---------|:------------|
406
+ | `packet:stats()` | `{ bytesSent, bytesReceived, fires, recvFires, drops }` |
407
+ | `Lync.stats.player(player)` | `{ bytesSent, bytesReceived }` — server only. |
408
+ | `Lync.stats.reset()` | Zeros all counters. |
562
409
 
563
- Fires `onDrop` with reason `"bandwidth"` when a player exceeds the threshold. Read failures (corrupted buffers, XOR desync) also count as strikes.
410
+ ### Debug
564
411
 
565
- ### Unknown Codec Warning
412
+ | Function | Description |
413
+ |:---------|:------------|
414
+ | `Lync.debug.pending()` | Number of in-flight query requests. Useful for detecting leaks. |
415
+ | `Lync.debug.registrations()` | Frozen array of `{ name, id, kind, isUnreliable }` for all registered packets and queries. |
566
416
 
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.
417
+ ## Limits
568
418
 
569
- ## Packet Capture
419
+ | Constraint | Limit |
420
+ |:-----------|------:|
421
+ | Packet + query registrations | 127 |
422
+ | Buffer per frame | 256 KB default, 1 MB max |
423
+ | Concurrent query requests | 65,536 |
424
+ | Enum variants | 256 |
425
+ | Bitfield bits | 32 |
426
+ | Tagged variants | 256 |
570
427
 
571
- Server-only debug tool. Records raw and XOR'd buffer hex for analysis.
428
+ ## Benchmarks
572
429
 
573
- ```luau
574
- Lync.startCapture("My test")
575
- -- fire packets...
576
- Lync.flush()
577
- Lync.stopCapture()
430
+ Run `rojo serve bench.project.json` with one server + one client.
431
+
432
+ ### Wire Sizes
433
+
434
+ | Codec | Bytes |
435
+ |:------|------:|
436
+ | `bool` | 1 |
437
+ | `int(0, 255)` | 1 |
438
+ | `int(0, 65535)` | 2 |
439
+ | `f16` | 2 |
440
+ | `f32` | 4 |
441
+ | `f64` | 8 |
442
+ | `string` (5 chars) | 6 |
443
+ | `string` (1000 chars) | 1002 |
444
+ | `vec3` | 12 |
445
+ | `vec3(0, 100, 1)` | 3 |
446
+ | `cframe` | 24 |
447
+ | `cframe()` | 16 |
448
+ | `color3` | 3 |
449
+ | entity struct (6 fields) | 34 |
450
+ | entity compact (quantized) | 13 |
451
+ | bitfield | 2 |
452
+ | 100× entities | 601 |
453
+ | 1000× bools (bitpacked) | 127 |
454
+
455
+ ### Codec Throughput
456
+
457
+ 100k iterations, isolated CPU. No networking.
458
+
459
+ | Codec | Encode | Decode | Round-trips/sec |
460
+ |:------|-------:|-------:|----------------:|
461
+ | `bool` | 44ns | 29ns | 13.9M |
462
+ | `int(0, 255)` | 42ns | 28ns | 14.4M |
463
+ | `f32` | 41ns | 25ns | 15.0M |
464
+ | `f64` | 41ns | 26ns | 14.8M |
465
+ | `string` (10 chars) | 46ns | 60ns | 9.4M |
466
+ | `string` (1000 chars) | 76ns | 238ns | 3.2M |
467
+ | `vec3` | 53ns | 27ns | 12.4M |
468
+ | `cframe` | 92ns | 144ns | 4.2M |
469
+ | `cframe()` | 123ns | 170ns | 3.4M |
470
+ | entity struct | 239ns | 395ns | 1.6M |
471
+ | 100× entities | 15.2µs | 34.1µs | 20K |
472
+ | 1000× bools | 4.3µs | 5.1µs | 107K |
473
+
474
+ ### Delta Savings
475
+
476
+ | Codec | Full | Unchanged | Savings |
477
+ |:------|-----:|----------:|--------:|
478
+ | `deltaStruct` (entity) | 35B | 1B | 97% |
479
+ | `deltaStruct` (compact) | 14B | 1B | 93% |
480
+ | `deltaArray` (100× entity) | 602B | 1B | 100% |
481
+ | `deltaArray` (1000× bool) | 128B | 1B | 99% |
482
+ | `deltaMap` (string → u8) | 19B | 1B | 95% |
483
+
484
+ ### Network Throughput
485
+
486
+ 1000 fires/frame, 8 seconds, one player.
487
+
488
+ | Packet | FPS | Kbps |
489
+ |:-------|----:|-----:|
490
+ | booleans | 60 | 2.5 |
491
+ | entity struct | 60 | 2.3 |
492
+ | entity compact | 60 | 2.4 |
493
+ | bitfield flags | 60 | 2.4 |
494
+ | cframe lossless | 60 | 2.5 |
495
+ | cframe compressed | 60 | 2.3 |
578
496
 
579
- Lync.startCapture("Another test")
580
- -- fire packets...
581
- Lync.flush()
582
- Lync.stopCapture()
497
+ ### Cross-Library Comparison
583
498
 
584
- Lync.dumpCaptures() -- writes JSON to ServerStorage.LyncCapture
585
- ```
499
+ Same methodology as [Blink's benchmarks](https://github.com/1Axen/blink/blob/main/benchmark/Benchmarks.md): 1,000 fires/frame, same data every frame, 10 seconds.
586
500
 
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.
501
+ Other tool numbers from [Blink v0.17.1](https://github.com/1Axen/blink/blob/main/benchmark/Benchmarks.md) (2025-04-30).
588
502
 
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. |
503
+ > [!NOTE]
504
+ > 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](#delta-savings).
505
+
506
+ #### Entities 100× struct(6× u8)
507
+
508
+ | Tool | FPS | Kbps |
509
+ |:-----|----:|-----:|
510
+ | roblox | 16 | 559,364 |
511
+ | **lync** | **60** | **3.68** |
512
+ | blink | 42 | 41.81 |
513
+ | zap | 39 | 41.71 |
514
+ | bytenet | 32 | 41.64 |
515
+
516
+ #### Booleans 1000× bool
517
+
518
+ | Tool | FPS | Kbps |
519
+ |:-----|----:|-----:|
520
+ | roblox | 21 | 353,107 |
521
+ | **lync** | **60** | **2.49** |
522
+ | blink | 97 | 7.91 |
523
+ | zap | 52 | 8.10 |
524
+ | bytenet | 35 | 8.11 |
611
525
 
612
526
  ## License
613
527