@axpecter/lync 2.3.3 → 3.0.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 (135) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +302 -299
  3. package/Types.luau +215 -0
  4. package/api/Define.luau +154 -0
  5. package/api/Lifecycle.luau +119 -0
  6. package/api/Packet.luau +108 -0
  7. package/api/Query.luau +149 -0
  8. package/api/Set.luau +189 -0
  9. package/channel/Blocks.luau +124 -0
  10. package/channel/Group.luau +118 -0
  11. package/channel/Link.luau +135 -0
  12. package/channel/Outbox.luau +261 -0
  13. package/channel/Recipients.luau +129 -0
  14. package/channel/Transport.luau +138 -0
  15. package/codec/Base.luau +236 -0
  16. package/codec/Composites.luau +128 -0
  17. package/codec/Datatypes.luau +162 -0
  18. package/codec/Legality.luau +61 -0
  19. package/codec/Scalars.luau +72 -0
  20. package/codec/Text.luau +89 -0
  21. package/codec/init.luau +39 -0
  22. package/index.d.ts +285 -0
  23. package/init.luau +91 -0
  24. package/kernel/Bits.luau +16 -0
  25. package/kernel/Catalog.luau +121 -0
  26. package/kernel/Heap.luau +80 -0
  27. package/kernel/Listeners.luau +95 -0
  28. package/kernel/Log.luau +197 -0
  29. package/kernel/Tally.luau +128 -0
  30. package/kernel/Threads.luau +44 -0
  31. package/kernel/Wide.luau +267 -0
  32. package/package.json +15 -32
  33. package/runtime/Boundary.luau +180 -0
  34. package/runtime/Dispatch.luau +91 -0
  35. package/runtime/Handles.luau +114 -0
  36. package/runtime/Handshake.luau +90 -0
  37. package/runtime/Pending.luau +196 -0
  38. package/runtime/Router.luau +379 -0
  39. package/runtime/Session.luau +731 -0
  40. package/shape/Compile.luau +340 -0
  41. package/shape/Folds.luau +332 -0
  42. package/shape/Frames.luau +170 -0
  43. package/shape/Grid.luau +169 -0
  44. package/shape/Ir.luau +540 -0
  45. package/shape/Joins.luau +125 -0
  46. package/shape/Legality.luau +273 -0
  47. package/shape/Lower.luau +419 -0
  48. package/shape/Order.luau +64 -0
  49. package/shape/Regions.luau +113 -0
  50. package/shape/Rotation.luau +472 -0
  51. package/shape/Serial.luau +258 -0
  52. package/shape/Words.luau +89 -0
  53. package/shape/emit/Block.luau +453 -0
  54. package/shape/emit/Column.luau +338 -0
  55. package/shape/emit/Leaf.luau +462 -0
  56. package/shape/emit/Word.luau +234 -0
  57. package/shape/emit/init.luau +222 -0
  58. package/shape/init.luau +78 -0
  59. package/state/Apply.luau +330 -0
  60. package/state/Audience.luau +126 -0
  61. package/state/Catchup.luau +218 -0
  62. package/state/Coalesce.luau +43 -0
  63. package/state/Fieldset.luau +177 -0
  64. package/state/Newest.luau +339 -0
  65. package/state/Plan.luau +441 -0
  66. package/state/Sections.luau +468 -0
  67. package/state/Store.luau +188 -0
  68. package/state/Sync.luau +762 -0
  69. package/wire/Bytes.luau +118 -0
  70. package/wire/Clock.luau +37 -0
  71. package/wire/Protocol.luau +110 -0
  72. package/wire/Sequence.luau +40 -0
  73. package/wire/Varint.luau +124 -0
  74. package/src/Types.luau +0 -180
  75. package/src/api/Group.luau +0 -156
  76. package/src/api/Packet.luau +0 -317
  77. package/src/api/Query.luau +0 -338
  78. package/src/api/Scope.luau +0 -90
  79. package/src/api/Signal.luau +0 -193
  80. package/src/codec/Base.luau +0 -136
  81. package/src/codec/composite/Array.luau +0 -473
  82. package/src/codec/composite/Map.luau +0 -485
  83. package/src/codec/composite/Optional.luau +0 -54
  84. package/src/codec/composite/Shared.luau +0 -311
  85. package/src/codec/composite/Struct.luau +0 -494
  86. package/src/codec/composite/Tagged.luau +0 -103
  87. package/src/codec/composite/Tuple.luau +0 -143
  88. package/src/codec/datatype/Buffer.luau +0 -66
  89. package/src/codec/datatype/CFrame.luau +0 -109
  90. package/src/codec/datatype/Color.luau +0 -32
  91. package/src/codec/datatype/Instance.luau +0 -52
  92. package/src/codec/datatype/IntVector.luau +0 -39
  93. package/src/codec/datatype/NumberRange.luau +0 -39
  94. package/src/codec/datatype/Ray.luau +0 -36
  95. package/src/codec/datatype/Rect.luau +0 -31
  96. package/src/codec/datatype/Region.luau +0 -62
  97. package/src/codec/datatype/Sequence.luau +0 -184
  98. package/src/codec/datatype/String.luau +0 -94
  99. package/src/codec/datatype/UDim.luau +0 -46
  100. package/src/codec/datatype/Vector.luau +0 -130
  101. package/src/codec/meta/Auto.luau +0 -318
  102. package/src/codec/meta/Bitfield.luau +0 -160
  103. package/src/codec/meta/Custom.luau +0 -33
  104. package/src/codec/meta/DeltaScalar.luau +0 -390
  105. package/src/codec/meta/Enum.luau +0 -50
  106. package/src/codec/meta/Float.luau +0 -38
  107. package/src/codec/meta/Nothing.luau +0 -13
  108. package/src/codec/meta/Unknown.luau +0 -41
  109. package/src/codec/primitive/Bool.luau +0 -26
  110. package/src/codec/primitive/Float16.luau +0 -99
  111. package/src/codec/primitive/Int.luau +0 -59
  112. package/src/codec/primitive/Number.luau +0 -24
  113. package/src/codec/primitive/Signed.luau +0 -32
  114. package/src/codec/primitive/Varint.luau +0 -165
  115. package/src/codec/primitive/Zint.luau +0 -99
  116. package/src/index.d.ts +0 -423
  117. package/src/init.luau +0 -362
  118. package/src/internal/Baseline.luau +0 -57
  119. package/src/internal/Channel.luau +0 -484
  120. package/src/internal/Middleware.luau +0 -100
  121. package/src/internal/Pool.luau +0 -66
  122. package/src/internal/Registry.luau +0 -119
  123. package/src/internal/Transport.luau +0 -28
  124. package/src/transport/Bridge.luau +0 -110
  125. package/src/transport/Client.luau +0 -97
  126. package/src/transport/Gate.luau +0 -488
  127. package/src/transport/Reader.luau +0 -331
  128. package/src/transport/Server.luau +0 -233
  129. package/src/util/Array.luau +0 -18
  130. package/src/util/Buffer.luau +0 -90
  131. package/src/util/Constants.luau +0 -30
  132. package/src/util/Log.luau +0 -68
  133. package/src/util/Player.luau +0 -14
  134. package/src/util/Quantize.luau +0 -84
  135. package/src/util/Quat.luau +0 -124
package/README.md CHANGED
@@ -1,381 +1,384 @@
1
1
  <h1 align="center">Lync</h1>
2
- <p align="center">Buffer networking for Roblox.</p>
3
- <p align="center">
4
- <a href="https://github.com/Axp3cter/Lync/releases/latest">Releases</a> ·
5
- <a href="#install">Install</a> ·
6
- <a href="#example">Example</a> ·
7
- <a href="#api">API</a> ·
8
- <a href="#codecs">Codecs</a> ·
9
- <a href="#benchmarks">Benchmarks</a>
10
- </p>
11
-
12
- Schemas, packets, queries, groups, validation, rate limiting. Every send batches into one buffer per player per frame; identical frames XOR to ones already in flight; delta codecs collapse unchanged state to a single byte. No code generation.
13
2
 
14
- ## Install
3
+ <p align="center">Typed buffer networking for Roblox: packets, queries, and replicated sets.</p>
15
4
 
16
- Wally add to your `wally.toml`:
5
+ Write the schema once. Both sides require it, the Luau types fall out of it, and every value packs
6
+ into bit level buffers that batch into one frame per client on flush.
17
7
 
18
- ```toml
19
- Lync = "axp3cter/lync@2.3.3"
20
- ```
8
+ ```lua
9
+ -- Net.luau, required by both sides
10
+ return Lync.define("arena", {
11
+ Fighters = Lync.replicate(Lync.struct({
12
+ name = Lync.str(1, 20),
13
+ team = Lync.enum({ "red", "blue" }),
14
+ score = Lync.int(0, 1000000):monotonic(),
15
+ pos = Lync.vec3(Lync.quant(-512, 512, 0.1)):newest(10),
16
+ })):keyBy("team"),
21
17
 
22
- npm (roblox-ts):
18
+ Strike = Lync.packet(Lync.vec3(Lync.quant(-512, 512, 0.1))):unreliable(),
23
19
 
24
- ```bash
25
- npm install @axpecter/lync
20
+ Sell = Lync.query(
21
+ Lync.struct({ item = Lync.enum({ "sword", "shield" }) }),
22
+ Lync.struct({ earned = Lync.int(0, 1000000) })
23
+ ),
24
+ })
26
25
  ```
27
26
 
28
- ```typescript
29
- import Lync from "@axpecter/lync";
27
+ ```lua
28
+ -- server
29
+ Net.Strike:onServer(function(at, player) end)
30
+ Net.Sell:onServer(function(order, player) return { earned = 25 } end)
31
+ Net.Fighters:add(player.UserId, { name = "Ada", team = "red", score = 0, pos = Vector3.zero })
32
+
33
+ Lync.start()
34
+ RunService.PostSimulation:Connect(Lync.flush)
30
35
  ```
31
36
 
32
- **Important.** Define every packet, query, and group before `Lync.start()`. Definitions assign sequential 7-bit IDs that both peers must agree on; defining late on one side desyncs the wire.
37
+ ```lua
38
+ -- client
39
+ Net.Fighters:onChanged(function(id, record, old) board(id, record.score) end)
33
40
 
34
- ## Example
41
+ Lync.start()
42
+ RunService.PostSimulation:Connect(Lync.flush)
35
43
 
36
- **Shared** — `ReplicatedStorage.Net`
44
+ Net.Strike:fireServer(aim())
45
+ local ok, receipt = Net.Sell:request({ item = "sword" })
46
+ ```
37
47
 
38
- ```luau
39
- local Lync = require(game.ReplicatedStorage.Lync)
48
+ `record.score` is a `number` and `receipt.earned` is a `number`. Nothing was annotated and nothing
49
+ was generated.
40
50
 
41
- return table.freeze({
42
- State = Lync.packet("State", Lync.deltaStruct({
43
- position = Lync.vec3,
44
- health = Lync.float(0, 100, 0.5),
45
- status = Lync.enum("idle", "moving", "attacking", "dead"),
46
- alive = Lync.bool,
47
- })),
51
+ ---
48
52
 
49
- Hit = Lync.packet("Hit", Lync.struct({
50
- targetId = Lync.int(0, 65535),
51
- damage = Lync.float(0, 200, 0.1),
52
- }), {
53
- rateLimit = { maxPerSecond = 30, burst = 5 },
54
- validate = function(data) return data.damage <= 200, "damage" end,
55
- }),
53
+ ## Install
56
54
 
57
- Ping = Lync.query("Ping", Lync.nothing, Lync.f64, { timeout = 3 }),
58
- })
55
+ ```toml
56
+ [dependencies]
57
+ Lync = "axp3cter/lync@3.0.0"
59
58
  ```
60
59
 
61
- **Server**
60
+ ```bash
61
+ npm install @axpecter/lync
62
+ ```
62
63
 
63
- ```luau
64
- local Lync = require(game.ReplicatedStorage.Lync)
65
- local Net = require(game.ReplicatedStorage.Net)
66
- local Players = game:GetService("Players")
64
+ Or drop `lync.rbxm` from the latest release into `ReplicatedStorage`.
67
65
 
68
- local alive = Lync.group("alive")
69
- Players.PlayerAdded:Connect(function(p) alive:add(p) end)
66
+ ---
70
67
 
71
- Net.Hit:on(function(data, sender) end)
72
- Net.Ping:handle(function() return os.clock() end)
68
+ ## Primitives
73
69
 
74
- Lync.start()
70
+ | | Carries | Send | Receive |
71
+ | --- | --- | --- | --- |
72
+ | `packet` | events | `fireServer` `fireClient` | `onServer` `onClient` |
73
+ | `query` | a request and its reply | `request` | `onServer` `onClient` |
74
+ | `replicate` | server owned records | `add` `update` `remove` `clear` | `onAdded` `onChanged` `onRemoved` |
75
75
 
76
- game:GetService("RunService").Heartbeat:Connect(function()
77
- Net.State:send(getState(), alive)
78
- end)
76
+ ---
77
+
78
+ ## Codecs
79
+
80
+ A codec says how one value validates, encodes and decodes. Out of range throws on the way out and
81
+ drops on the way in.
82
+
83
+ ```lua
84
+ local Health = Lync.int(0, 100) -- 7 bits on the wire, where an f32 is 32
79
85
  ```
80
86
 
81
- **Client**
87
+ ### Numbers
82
88
 
83
- ```luau
84
- local Lync = require(game.ReplicatedStorage.Lync)
85
- local Net = require(game.ReplicatedStorage.Net)
89
+ | | Type | |
90
+ | --- | --- | --- |
91
+ | `int(min, max)` | `number` | A bounded whole number. |
92
+ | `quant(min, max, step)` | `number` | Rounds onto a grid. |
93
+ | `angle(degrees)` | `number` | Cyclic, wrapping at a whole turn. |
94
+ | `f32()` `f64()` | `number` | Roughly 7 and 15 digits. Reach for `quant` to pick the loss yourself. |
95
+ | `vlq()` `vli()` | `number` | Unbounded integers exact to 2^53, unsigned and signed. |
96
+ | `bool()` | `boolean` | One flag. Reach for `bitfield` past one. |
97
+
98
+ ### Text
99
+
100
+ | | Type | |
101
+ | --- | --- | --- |
102
+ | `str(min, max)` | `string` | Byte length bounded. |
103
+ | `str.alphabet(symbols, min, max)` | `string` | Every character from the set. Smaller set, fewer bits. |
104
+ | `str.alphanum` `.base32` `.base64` `.digits` `.hex` | `string` | Presets over `alphabet`, each `(min, max)`. |
105
+ | `buffer(min, max)` | `buffer` | Opaque bytes. Also how you relay bytes you never open. |
106
+
107
+ ### Roblox
108
+
109
+ | | Type | |
110
+ | --- | --- | --- |
111
+ | `vec2(c?)` `vec3(c?)` | `Vector2` `Vector3` | `f32` per component, or hand each one a codec. |
112
+ | `vec3.unit(degrees)` | `Vector3` | A direction. Any nonzero vector normalizes at encode. |
113
+ | `vec3.bounded(max, step)` | `Vector3` | A direction plus a quantized magnitude. |
114
+ | `cframe(position, rotation)` | `CFrame` | A position codec paired with a rotation codec. |
115
+ | `rotation.none()` `.axis()` `.direction()` `.quat()` | `CFrame` | Zero, one, two, or three degrees of freedom. |
116
+ | `color3()` `.rgb565()` `.palette(t)` | `Color3` | Floats, one 16 bit word, or an index into `t`. |
117
+ | `inst(class?)` | `Instance?` | Always optional. A receiver that cannot see it gets nil. |
118
+
119
+ UDim2, Region3, Ray and the rest go over with `:as`.
86
120
 
87
- Lync.start()
121
+ ### Composites
88
122
 
89
- local scope = Lync.scope()
90
- scope:on(Net.State, function(state) end)
123
+ | | Type | |
124
+ | --- | --- | --- |
125
+ | `struct({ k = c })` | `{ k: ... }` | Named fields. An undeclared field throws on encode. |
126
+ | `array(c, min, max)` | `{ T }` | An ordered list, count bounded. |
127
+ | `map(k, v, min, max)` | `{ [K]: V }` | A dictionary, bounded the same way. |
128
+ | `optional(c)` | `T?` | May be absent. |
129
+ | `tagged(field, { k = c })` | union | One struct per variant, the chosen name rides in `field`. |
130
+ | `enum({ "a", "b" })` | `string` | One of a fixed set. |
131
+ | `bitfield({ "a", "b" })` | `{ a: boolean }` | Packed flags, one bit each. |
91
132
 
92
- Net.Hit:send({ targetId = 123, damage = 45 })
93
- local serverTime = Net.Ping:request(nil)
94
- ```
133
+ ### Modifiers
95
134
 
96
- ## API
97
-
98
- ### Lifecycle
99
-
100
- | Function | Description |
101
- |:---|:---|
102
- | `Lync.configure(opts)` | Apply options. Must precede `start()`. |
103
- | `Lync.start()` | Initialize transport. Call once. |
104
- | `Lync.isStarted()` | `true` after `start()`. |
105
- | `Lync.flush()` | Force an immediate send. |
106
- | `Lync.flushRate(hz)` | 1–60 Hz. Default 60. |
107
- | `Lync.reset()` | Restore module state to post-require defaults. For tests / hot reload. |
108
-
109
- ### Configure options
110
-
111
- | Option | Default | Range | Description |
112
- |:---|---:|:---|:---|
113
- | `channelMaxSize` | 262144 | 4 KB – 1 MB | Per-frame buffer cap. |
114
- | `validationDepth` | 16 | 4–32 | Schema-walk recursion limit. |
115
- | `poolSize` | 16 | 2–128 | Reusable channel-state pool. |
116
- | `bandwidthLimit` | none | — | `{ softLimit, maxStrikes }` per-player throttle. |
117
- | `globalRateLimit` | none | — | `{ maxPerSecond }` across all packets per player. |
118
- | `stats` | `false` | — | Enables `:stats()` and `Lync.stats.player()`. |
119
-
120
- ### Packets
121
-
122
- `Lync.packet(name, codec, options?)`
123
-
124
- ```luau
125
- -- Server
126
- packet:send(data, player)
127
- packet:send(data, Lync.all)
128
- packet:send(data, Lync.except(p1, group1))
129
- packet:send(data, { p1, p2, p3 })
130
- packet:send(data, group)
131
-
132
- -- Client
133
- packet:send(data)
134
-
135
- -- Both sides
136
- local conn = packet:on(function(data, sender, timestamp) end)
137
- packet:once(fn)
138
- local data, sender, timestamp = packet:wait()
139
- packet:name()
140
- packet:stats() -- requires stats=true
135
+ | | Scope | |
136
+ | --- | --- | --- |
137
+ | `:validate(fn)` | any | `fn(value, ctx)` returns nil to pass or a reason to drop. |
138
+ | `:as(to, from)` | any | Maps the wire type to and from your own. |
139
+ | `:monotonic()` | set fields | Only climbs, so deltas cost less. A decrease throws. |
140
+ | `:newest(hz?)` | set fields | Only the latest matters, and `hz` caps the rate. |
141
+
142
+ A modifier answers a new codec. A set marker inside a packet or query throws at start.
143
+
144
+ ---
145
+
146
+ ## Packets
147
+
148
+ ```lua
149
+ Move = Lync.packet(Lync.vec3()):unreliable(),
150
+ Aim = Lync.packet(Lync.rotation.quat(0.2)):newest(20):timestamped(),
141
151
  ```
142
152
 
143
- | Option | Type | Description |
144
- |:---|:---|:---|
145
- | `unreliable` | boolean | Use `UnreliableRemoteEvent`. Rejected with delta codecs (a dropped frame would desync the baseline). |
146
- | `rateLimit` | `RateLimitConfig` | Server-side per-player. |
147
- | `validate` | `(data, player) → (bool, string?)` | Drop on `false`. Reason is forwarded to `onDrop`. |
148
- | `maxPayloadBytes` | number | Reject oversize incoming payloads early. |
149
- | `timestamp` | `"frame"`, `"offset"`, `"full"` | Append 1B / 2B / 8B timestamp. Read as the third arg. |
153
+ | | | |
154
+ | --- | --- | --- |
155
+ | server | `fireClient(to, payload)` | Encodes once however many receive it. |
156
+ | server | `onServer(fn)` | `fn(payload, player, sent?)`. Any number of listeners. |
157
+ | client | `fireServer(payload)` | |
158
+ | client | `onClient(fn)` | `fn(payload, sent?)`. Any number of listeners. |
150
159
 
151
- ### Queries
160
+ Delivery is reliable and ordered by default. The first two below are alternatives, and
161
+ `:timestamped()` stacks with either.
152
162
 
153
- `Lync.query(name, requestCodec, responseCodec, options?)`
163
+ | | |
164
+ | --- | --- |
165
+ | `:unreliable()` | Lossy and unordered, and every fire still goes out. Must fit the unreliable cap. |
166
+ | `:newest(hz?)` | Lossy, latest wins. Stale arrivals and unchanged values send nothing. |
167
+ | `:timestamped()` | Adds `sent`, an instant on the shared clock. |
168
+
169
+ `to`, and a set's `audience`, take any of these.
154
170
 
155
- Request-response on top of two paired registrations. Single-target requests yield until reply or timeout; multi-target requests gather a partial map.
171
+ | | |
172
+ | --- | --- |
173
+ | `Lync.all` | Every client. |
174
+ | `Player` | That client. |
175
+ | `{ Player }` | The listed clients. |
176
+ | `Group` | Its members at send time. |
177
+ | `Lync.except(t)` | Everyone but `t`, a player, list, or group. |
156
178
 
157
- ```luau
158
- -- Server
159
- query:handle(function(data, player) return response end)
160
- local resp = query:request(data, player) -- response?
161
- local map = query:request(data, group) -- { [Player]: response? }
179
+ Firing at nobody is reported and throws in Studio. Listeners may yield, and one that throws does
180
+ not stop the others.
162
181
 
163
- -- Client
164
- query:handle(function(data) return response end)
165
- local resp = query:request(data) -- yields; nil on timeout
182
+ ---
183
+
184
+ ## Queries
185
+
186
+ ```lua
187
+ local ok, res, data = Net.Sell:request({ item = "sword" })
188
+ if ok then print(res.earned) else print(res, data.elapsed) end
166
189
  ```
167
190
 
168
- | Option | Default | Description |
169
- |:---|:---|:---|
170
- | `timeout` | 5 | Seconds before yielding `nil`. |
171
- | `rateLimit` | `{ maxPerSecond = 30 }` | Server-side. |
172
- | `validate` | none | `(data, player) → (bool, string?)` |
191
+ No ending raises. A reply, a deadline, and a counterparty that never answered all arrive the same
192
+ way.
193
+
194
+ | | | |
195
+ | --- | --- | --- |
196
+ | client | `request(value, timeout?)` | Yields for the reply. Timeout defaults to 10 s. |
197
+ | server | `request(client, value, fn, timeout?)` | `fn(ok, res, data)` on completion. Never yields. |
198
+ | server | `onServer(fn)` | The lone responder. What it returns is the reply. |
199
+ | client | `onClient(fn)` | The lone responder. |
173
200
 
174
- ### Groups
201
+ Four outcomes: `timeout`, `unanswered` when the other side registered none, `leave` when the
202
+ counterparty goes, and `shutdown` when `close` runs first. A domain failure like insufficient funds
203
+ is a value in your response codec, not an outcome.
175
204
 
176
- `Lync.group(name)` — named player set. Members auto-removed on `PlayerRemoving`. Iterable: `for player in group do`.
205
+ ---
177
206
 
178
- | Method | Returns | Description |
179
- |:---|:---|:---|
180
- | `:add(p)` / `:remove(p)` | `boolean` | `true` if membership changed. |
181
- | `:has(p)` | `boolean` | |
182
- | `:count()` | `number` | |
183
- | `:destroy()` | — | Clear members and free the name. |
207
+ ## Sets
184
208
 
185
- ### Scope
209
+ ```lua
210
+ Net.Fighters:add(id, { name = "Ada", team = "red", score = 0, pos = Vector3.zero })
211
+ Net.Fighters:update(id, { score = 10 }) -- only score is sent
212
+ ```
186
213
 
187
- `Lync.scope()` batches connections for a single `:destroy()`.
214
+ The server owns the set and each client holds exactly the records its audiences allow. Without
215
+ `keyBy` the whole set goes to everyone. `keyBy(field)` splits records by that field's value, and
216
+ `audience(key, to)` gives each key its viewers.
188
217
 
189
- ```luau
190
- local scope = Lync.scope()
191
- scope:on(packet, fn)
192
- scope:once(packet, fn)
193
- scope:add(rbxConnection)
194
- scope:destroy()
218
+ ```lua
219
+ Net.Fighters:audience("red", redTeam)
220
+ Net.Fighters:update(id, { team = "blue" }) -- migrates atomically inside this flush
221
+ -- viewers of "red" alone -> onRemoved(id, "removed")
222
+ -- viewers of "blue" alone -> onAdded(id, record)
223
+ -- viewers of both keys -> onChanged(id, record, old)
195
224
  ```
196
225
 
197
- ### Targets
226
+ | Server | |
227
+ | --- | --- |
228
+ | `add(id, record)` | Throws if the id is live. |
229
+ | `update(id, fields)` | Only the named fields. `Lync.none` clears an optional one. |
230
+ | `remove(id)` `clear()` | Throws on an absent id. |
231
+ | `audience(key, to)` | Keyed sets only. |
198
232
 
199
- Server-side `:send` second arg.
233
+ | Both sides | |
234
+ | --- | --- |
235
+ | `get(id)` | The live record or nil. Borrowed, so copy what you keep. |
236
+ | `#set`, iteration | Count and walk the local view. |
237
+ | `onAdded(fn)` | `fn(id, record)` on first sight: an add, a late join, a visibility gain. |
238
+ | `onChanged(fn)` | `fn(id, record, old)`. The net record after a flush against the one before. |
239
+ | `onRemoved(fn)` | `fn(id, cause)` with `"removed"` or `"cleared"`. |
200
240
 
201
- | Target | Description |
202
- |:---|:---|
203
- | `Player` | One player. |
204
- | `Lync.all` | All connected. |
205
- | `Lync.except(...)` | Everyone except given Players or Groups. |
206
- | `{ p1, p2 }` | Array of players. |
207
- | `group` | All members. |
241
+ Only changed fields go out, and only to viewers. Two updates in one flush ship once. An add and a
242
+ remove in one flush ship nothing. Ids are exact to 2^53, so UserIds work as they are, Studio's
243
+ negative test ids included.
208
244
 
209
- ### Middleware
245
+ ---
210
246
 
211
- ```luau
212
- -- Return Lync.DROP from onSend to discard a packet.
213
- Lync.onSend(function(data, name, player) return data end)
214
- Lync.onReceive(function(data, name, player) return data end)
215
- Lync.onDrop(function(player, reason, name, data) end)
216
- ```
247
+ ## Lifecycle
217
248
 
218
- All return a `Connection`. A throwing hook surfaces to the caller and aborts the chain at that point.
249
+ | | |
250
+ | --- | --- |
251
+ | `Lync.start()` | Seals definitions and responders. Once per side, after every definition. |
252
+ | `Lync.flush()` | Sends everything buffered. Nothing sends without it, and empty flushes are free. |
253
+ | `Lync.close()` | A final flush, resolves outstanding requests as `shutdown`, releases the transports. |
254
+ | `Lync.stats(name)` | A frozen snapshot of a namespace's counters. Monotonic, so two diff into rates. |
255
+
256
+ ```lua
257
+ Lync.flush(8192) -- every namespace, 8 KB of state per client
258
+ Lync.flush("arena", 8192) -- one namespace, on its own cadence
259
+ ```
219
260
 
220
- ### Connection
261
+ The budget throttles state replication only, so packets, requests and responses always send in
262
+ full. Under 1024 throws. Give latency critical traffic its own namespace.
221
263
 
222
264
  | | |
223
- |:---|:---|
224
- | `c.connected` | `boolean` |
225
- | `c:disconnect()` | Idempotent. |
265
+ | --- | --- |
266
+ | `Lync.group()` | A mutable membership set that goes anywhere a recipient does. |
267
+ | `add` `remove` `has` | Manage membership. `#group` and iteration cover the set. |
268
+ | `destroy()` | Empties it and releases it. Any later use throws. |
226
269
 
227
- ### Stats
270
+ A group thins out as players leave, and audiences store the group itself.
228
271
 
229
- Enable with `Lync.configure({ stats = true })`.
272
+ ---
230
273
 
231
- | Function | Description |
232
- |:---|:---|
233
- | `Lync.stats.player(p)` | `{ bytesSent, bytesReceived }`. Server only. |
234
- | `Lync.stats.reset()` | Zero all counters. |
235
- | `packet:stats()` | `{ bytesSent, bytesReceived, fires, recvFires, drops }`. Aggregated across the request + response registrations on queries. |
274
+ ## Validation
236
275
 
237
- ### Debug
276
+ Inbound checking has two stages, and a rejection at the first is a drop.
238
277
 
239
- | Function | Description |
240
- |:---|:---|
241
- | `Lync.debug.pending()` | In-flight query correlation IDs. |
242
- | `Lync.debug.registrations()` | Frozen `{ name, id, kind, isUnreliable }` per registration. |
278
+ ```lua
279
+ Damage = Lync.int(0, 500):validate(function(amount, ctx)
280
+ -- ctx.player, ctx.now, ctx.last
281
+ if ctx.last ~= nil and ctx.now - ctx.last < 0.1 then return "faster than 10 Hz" end
282
+ return nil
283
+ end)
284
+ ```
243
285
 
244
- `capture` / `stop` / `dump` are reserved no-ops for capture/replay tooling.
286
+ `last` is stamped on every arrival, accepted or not, so a flood of junk cannot reset a sender's
287
+ clock. The table is reused, so copy anything you keep. A rejected payload is dropped before your
288
+ code sees it and logged as one warning. A rejected request is answered by nothing, so the requester
289
+ times out.
245
290
 
246
- ## Codecs
291
+ ---
247
292
 
248
- ### Numbers
293
+ ## Errors and logging
249
294
 
250
- | Codec | Bytes | Notes |
251
- |:---|---:|:---|
252
- | `int(min, max)` | 1 / 2 / 4 | Picks narrowest u8/u16/u32/i8/i16/i32. |
253
- | `zint(min?, max?)` | 1 5 | Variable-length signed via zigzag varint. 1 byte for [-96, 95]. |
254
- | `f16` / `f32` / `f64` | 2 / 4 / 8 | `f16` ±65504, ~3 digits. |
255
- | `float(min, max, precision)` | 1 / 2 / 3 / 4 | Quantized; picks u8 / u16 / u24 / u32 wire form. |
256
- | `bool` | 1 | Auto-bitpacked inside `struct` and `array`. |
257
-
258
- ### Strings & buffers
259
-
260
- | Codec | Notes |
261
- |:---|:---|
262
- | `string` | Variable length. Binary-safe. |
263
- | `string(maxLength)` | Bounded. Rejects on read if exceeded. |
264
- | `buff` | Variable-length raw `buffer`. |
265
-
266
- ### Roblox types
267
-
268
- | Codec | Bytes |
269
- |:---|---:|
270
- | `vec2` / `vec3` | 8 / 12 |
271
- | `cframe` | 24 |
272
- | `color3` | 3 |
273
- | `inst` | 2 (sidecar ref index) |
274
- | `udim` / `udim2` | 8 / 16 |
275
- | `numberRange` | 8 |
276
- | `rect` | 16 |
277
- | `ray` | 24 |
278
- | `vec2int16` / `vec3int16` | 4 / 6 |
279
- | `region3` / `region3int16` | 24 / 12 |
280
- | `numberSequence` / `colorSequence` | variable |
281
-
282
- ### Quantized variants
283
-
284
- Call as a function for compression.
285
-
286
- | Codec | Bytes | Notes |
287
- |:---|---:|:---|
288
- | `vec2(min, max, precision)` | 2 / 4 / 6 / 8 | Per-component, narrowest fitting width. |
289
- | `vec3(min, max, precision)` | 3 / 6 / 9 / 12 | Per-component, narrowest fitting width. |
290
- | `cframe()` | 16 | Smallest-three quaternion. ≤ 0.16° rotation error. |
295
+ | | |
296
+ | --- | --- |
297
+ | Programmer error | A call on the wrong side, a second responder, a second `start`. Throws on the spot. |
298
+ | Dropped input | A validate reason, a payload the schema turns away. A warning, never thrown. |
299
+ | Environmental | A listener or responder that throws. Reported with its trace, and never stalls the rest. |
300
+ | Transport | Timeout, a leaver, shutdown. An outcome code handed back, never thrown. |
301
+
302
+ Every `on*` returns a connection with `:disconnect()`. When a player leaves, each pending request
303
+ touching them resolves as `leave`.
304
+
305
+ ```lua
306
+ Lync.onLog(function(kind, message, data)
307
+ if data.player ~= nil then flagSuspicious(data.player, data) end
308
+ end)
309
+ ```
291
310
 
292
- ### Composites
311
+ `kind` is `warn` for something dropped, `error` for a fault contained, or `debug` in Studio only.
312
+ `data` always carries a file and a line. `Lync.console` is the default printer and is itself a
313
+ connection, so disconnect it to format records yourself. Match on `data`, never on the message.
293
314
 
294
- | Codec | Notes |
295
- |:---|:---|
296
- | `struct({k = c})` | Named fields. Bools auto-bitpacked into a tail block. |
297
- | `array(c, max?)` | List. Bool arrays bitpacked. Direct path for fixed-size elements. |
298
- | `map(k, v, max?)` | Key-value pairs; keys sorted at encode for stable wire bytes. |
299
- | `optional(c)` | 1B presence flag + value. |
300
- | `tuple(...)` | Positional. All-direct fast path when every element is fixed-size. |
301
- | `tagged(field, {name = c})` | Discriminated union. 1B tag. Up to 256 variants. |
315
+ ---
302
316
 
303
- ### Delta — reliable transport only
317
+ ## Types
304
318
 
305
- Tracks the previous frame's value and ships only what changed. Rejected on `unreliable = true`.
319
+ Handlers, records and replies come out typed from the schema alone.
306
320
 
307
- | Codec | Static | Mutation |
308
- |:---|:---:|:---:|
309
- | `deltaStruct(schema)` | 1 B | per-field |
310
- | `deltaArray(c, max?)` | 1 B | per-changed-index |
311
- | `deltaMap(k, v, max?)` | 1 B | per-changed-key |
312
- | `deltaInt(min, max)` | 1 B | 1–5 B |
313
- | `deltaFloat(min, max, precision)` | 1 B | 1–5 B |
314
- | `deltaVec3(min, max, precision)` | 3 B | 3–15 B |
315
- | `deltaCFrame(posMin, posMax, precision)` | 1 B | 4–13 B |
321
+ ```lua
322
+ type Fighter = Types.Infer<typeof(Codec)> -- { name: string, score: number, tag: string? }
323
+ ```
316
324
 
317
- - `deltaArray` element / `deltaMap` key+value cannot themselves contain delta state. Use `deltaStruct` for per-field deltas inside.
318
- - `deltaVec3` and `deltaCFrame` error on out-of-range components.
325
+ `Infer` reads a codec, `Schema` reads a table of codecs as the record it describes, and `Update`
326
+ types the argument to a set update. All three live in the `Types` module beside `Lync`. Everything
327
+ else is on `Lync` directly, for signatures at module boundaries.
328
+
329
+ ```lua
330
+ local codec: Lync.Codec<number>
331
+ local packet: Lync.Packet<Vector3>
332
+ local query: Lync.Query<Order, Receipt>
333
+ local set: Lync.Set<Fighter>
334
+ local group: Lync.Group
335
+ local conn: Lync.Connection
336
+ local to: Lync.Recipient -- Lync.All | Player | { Player } | Group | Lync.Except
337
+ local kind: Lync.LogKind -- "warn" | "error" | "debug"
338
+ local cause: Lync.Cause -- "removed" | "cleared"
339
+ local code: Lync.OutcomeCode -- "timeout" | "unanswered" | "leave" | "shutdown"
340
+ local ctx: Lync.ValidateContext -- player, now, last
341
+ local log: Lync.LogData -- file, line, player?, definition?
342
+ local why: Lync.OutcomeData -- definition, elapsed?
343
+ local done: Lync.Outcome<Receipt> -- a server request's completion callback
344
+ local snap: Lync.Stats -- flushes, sentBytes, receivedBytes, drops, definitions
345
+ local row: Lync.DefinitionStats -- one definition's line in that snapshot
346
+ local clear: Lync.None -- the type of Lync.none
347
+ ```
319
348
 
320
- ### Meta
349
+ ### roblox-ts
321
350
 
322
- | Codec | Notes |
323
- |:---|:---|
324
- | `enum(...)` | String enum. ≤ 256 variants. 1B u8 index. |
325
- | `bitfield(schema)` | 1–32 bits total. `{ type = "bool" }`, `{ type = "uint", width }`, `{ type = "int", width }`. |
326
- | `custom(size, write, read, typeCheck?)` | User-defined fixed-size codec. |
327
- | `nothing` | 0 bytes; reads `nil`. For fire-and-forget signals. |
328
- | `unknown` | Bypasses serialization through the channel sidecar. Must be paired with `validate`. |
329
- | `auto` | Self-describing: nil / bool / numbers / strings / buffers / Roblox datatypes. 1B type tag + payload. |
351
+ The same surface, with five differences.
330
352
 
331
- ## Rate limiting
353
+ | | Luau | roblox-ts |
354
+ | --- | --- | --- |
355
+ | calls | `set:add(id, r)` | `set.add(id, r)` |
356
+ | count | `#set` | `set.size()` |
357
+ | helpers | `Types.Infer<C>` | `Lync.Infer<C>` |
358
+ | instance class | `Lync.inst("Player")` | `Lync.inst<Player>()` |
359
+ | audience key | untyped | typed, so `audience` before `keyBy` is a compile error |
332
360
 
333
- Per-packet, pick one mode:
361
+ The package is scoped, so add both roots in `tsconfig.json` and both folders in your project file.
334
362
 
335
- - Token bucket: `{ maxPerSecond = N, burst = M }`
336
- - Cooldown: `{ cooldown = seconds }`
363
+ ```json
364
+ "typeRoots": ["node_modules/@rbxts", "node_modules/@axpecter"]
365
+ ```
337
366
 
338
- Global per-player cap: `Lync.configure({ globalRateLimit = { maxPerSecond = N } })`.
367
+ ---
339
368
 
340
369
  ## Limits
341
370
 
342
- | | |
343
- |:---|---:|
344
- | Packet + query IDs (combined) | 127 |
345
- | Buffer per frame | 1 MB max |
346
- | In-flight queries | 65,535 |
347
- | Enum / tagged variants | 256 |
348
- | Bitfield total bits | 32 |
349
- | Sidecar refs per frame | 65,535 |
350
-
351
- ## Benchmarks
352
-
353
- `rojo serve bench.project.json` with one server + one client.
354
-
355
- ### Cross-library — 1000 fires/frame, 10 s
356
-
357
- [Blink's methodology](https://github.com/1Axen/blink/blob/main/benchmark/Benchmarks.md): same payload reused every frame, identical entity / bool shapes. Other tools from Blink v0.17.1.
358
-
359
- | Tool | `array<entity>[100]` | `array<bool>[1000]` |
360
- |:---|:---|:---|
361
- | roblox | 16 fps · 559,364 Kbps | 21 fps · 353,107 Kbps |
362
- | **lync** | **59 fps · 3.37 Kbps** | **61 fps · 2.45 Kbps** |
363
- | blink | 42 fps · 41.81 Kbps | 97 fps · 7.91 Kbps |
364
- | zap | 39 fps · 41.71 Kbps | 52 fps · 8.10 Kbps |
365
- | bytenet | 32 fps · 41.64 Kbps | 35 fps · 8.11 Kbps |
366
-
367
- ### Network bandwidth — 100 fires/frame, 8 s
368
-
369
- | Workload | Naive Kbps | Optimized | Savings |
370
- |:---|---:|:---|---:|
371
- | `array<entity>[100]` random | 3,607 | `deltaArray` 3 of 100 mutated | **154** (–96%) |
372
- | `array<entity>[100]` reused | 3,607 | XOR baseline (identical frames) | **2.4** (–99.9%) |
373
- | `array<bool>[1000]` random | 762 | XOR baseline (1 bit flipped) | **20.4** (–97%) |
374
- | `struct(state)` random | 201 | `deltaStruct` 1 field mutated | **29.0** (–86%) |
375
- | `map<id, vec3>[200]` 5 keys mutated | 657 | `deltaMap` 5 keys mutated | **393** (–40%) |
376
- | `array<cframe>[50]` random | 4,585 | — | — |
377
- | `vec3` walking motion (continuous diff) | — | `deltaVec3` | **19.5** |
378
- | `CFrame` walking pose (pos + rot) | — | `deltaCFrame` | **41.1** |
371
+ | | | |
372
+ | --- | --- | --- |
373
+ | set fields | 64 | Nest the extras in a `struct`, which counts as one field. |
374
+ | ids and integers | exact to 2^53 | Past it, carry the value as a `str` or a `buffer`. |
375
+ | request timeout | 10 s | Pass a timeout per call. |
376
+ | in flight requests | 32768 per namespace | You are leaking requests, and the cap is a detector. |
377
+ | state budget | 32 KB/s per client | Raise it with a flush budget, or split the traffic. |
378
+ | unreliable schema | just under 1 KB | Drop `:unreliable()`, or narrow the codec. Checked at start. |
379
+ | one frame | 1 MB | A loop that fires and never flushes. The throw names the size. |
380
+
381
+ ---
379
382
 
380
383
  ## License
381
384