Changelog
Changelog
Every release of BlinkBlox since it forked from upstream Blink at v0.18.8. The GitHub release notes
tell the longer story of each one, with the measurements and the reports that led to it.
A line marked Recompile both modules means a client and a server must be generated by the same release to talk to each other; the schema signature added in 0.23.0 makes a mismatch refuse at startup instead of misreading packets.
1.3.9 — 2026-11-06
A maintenance release. The compiler, generated wire format and plugin code are those of 1.3.8; modules generated by the two releases are interchangeable.
Changed
- The toolchain and CLI archives use LuneBlox 0.11.4, from 0.11.3, and the
@lunetypedef alias follows it. Luau remains at 0.740. The runtime backports the conditional-spill code generator fix, preserves numeric Instance attributes as Float64, and fixes empty-model DOM cleanup. Its fast flags match Roblox client 0.746.0.7461267, includingLuauCodegenIntegerCompare = false, and itsasync-executordependency moves off the yanked 1.14.0 release to 1.14.1. - Documentation dependencies are refreshed: Astro 7.4.7, Starlight 0.42.9 and the
postcss-selector-parseroverride 7.1.7, with the lockfile regenerated. The remaining tool and workflow action pins were checked and are already the latest stable releases.
Fixed
- The optional OCALE workflow verifies the full installer checksum. Its pinned
rocale-cli0.1.3 SHA-256 was missing the final hex digit, which stopped installation before the cloud comparison could run. The corrected digest was verified against the downloaded Linux archive.
Validation
The full type and test gates pass on 0.11.4, with unchanged goldens; all five CLI archives, the plugin and documentation build successfully, and npm audit reports zero vulnerabilities. The local OCALE codec experiment passes all nine checks; no credentialed Roblox run was performed.
The unchanged compiler was timed on LuneBlox 0.11.3 and 0.11.4, on an Apple M1: three full ABBA
rounds of Runtime and Scenarios, native and interpreted, 200 frames each. Interpreted fire
and decode medians differ by at most 1.1% in Runtime; scenario medians agree at their printed
precision apart from sub-microsecond drift on Instances. An initial 3-4% drift in native entity
decode did not reproduce in seven focused ABBA rounds of 400 frames: Entities decodes in
3.949 to 3.943 ms, EntitiesRandom in 3.948 to 3.941 ms (0.11.3 to 0.11.4, median of fourteen
runs each). Paired-round differences vary in sign; no repeatable slowdown was found. These
measure the runtime update, not a compiler optimization; bytes and remote-call counts do not move.
Upgrading
No schema, wire format or handler signature changes; existing generated modules need no recompiling.
Run rokit install to update the development toolchain.
1.3.8 — 2026-10-28
A maintenance release. The compiler, generated wire format and plugin code are those of 1.3.7; modules generated by the two releases are interchangeable.
Added
- An OCALE conformance experiment (
scripts/ocale/): the committed generated codec (test/Golden/Datatypes/DatatypesServer.luau) is bundled into a single script that runs unchanged on LuneBlox and in Roblox Open Cloud Luau Execution, and the two runs are compared field by field — exact serialised bytes, buffer lengths, decoded values, repeated writes and truncated-input rejection. The local half runs in CI on every pull request; the cloud half is a manual and weekly workflow restricted to this repository, so no Roblox credential can reach an untrusted PR.
1.3.7 — 2026-10-27
A maintenance release. The compiler, generated wire format and plugin code are those of 1.3.6; modules generated by the two releases are interchangeable.
Changed
- The toolchain runs on LuneBlox 0.11.3, from 0.11.2. Tests, benchmarks and release builds pick
up its compression security fixes and Roblox property migrations. The
@lunetypedef alias follows it. No schema or generated-module change needs recompiling.
1.3.6 — 2026-10-26
A channel whose events are small decodes a packet in one call rather than one an event. The wire does not change, and a module from 1.3.5 talks to one from this release: nothing needs recompiling, though recompiling is what brings the faster decode. Every handler is called as it was.
Changed
-
A channel’s decoder holds the packet’s loop when none of its events reads an Instance or runs a loop of its own – an array, a map, packed booleans. Its decoder had always been a function the loop called once an event; re-measured with linearisation back (1.3.5), holding the loop was faster on small events and slower on large ones, ABBA on the M1:
Decode, native Function Held loop Tiny, a thousand one-byte events0.056 ms 0.050 ms Broadcast, a unit to every player 0.0111 ms 0.0104 ms ManyEvents, a hundred among 128 0.0086 ms 0.0083 ms Instances, a hundred carrying a part 0.0128 ms 0.0154 ms EntitiesRandom, a thousand arrays of structs3.92 ms 4.07 ms So the shape is chosen per channel, and a channel that reads an Instance or an array keeps the function: its code is what 1.3.5 generated, and the benchmark schema’s reliable channel compiles to exactly that. The rest decode 4-6% faster in
benchmark/Scenarios.luau, natively and interpreted; nothing got slower, and firing is untouched. A refused call or a missing Instance still leaves only its own event, andtest/DecoderShapes.luauchecks it in the held loop.
1.3.5 — 2026-10-25
A housekeeping release. A generated module differs from 1.3.4’s by the version in its header: it runs the same code, the wire does not change, and nothing needs recompiling. What moves is the runtime it is measured and built on.
Changed
- The CLI archives are built on LuneBlox 0.11.2, up from 0.11.1, and the tests and benchmarks
run on it. 0.11.2 sets the fast flags of the Roblox client 0.744, which turned
DebugCodegenOptSizeoff: native code gets block linearisation back. On the M1, two LuneBlox builds that differ only in the flag table ran 1.3.4’s thousand-event benches 13 to 36% faster to fire natively and 19 to 38% faster to decode, ABBA:Entitiesfired in 1.58 ms from 2.47 andBooleansdecoded in 1.75 from 2.81. Interpreted code did not move. Native rows published before this release carry the old flag. - LuneBlox’s macOS binaries are built with Apple Clang 21 from 0.11.2, where 0.11.1’s came from
Clang 17 and interpreted about 30% slower than the same source allows (
Booleansfired in 15.8 ms interpreted, 11.0 built locally). 0.11.2’s published binary matches a local build, native and interpreted. Interpreted rows taken on the M1 before this release carry the slower build. - CLAUDE.md says both, and that the reason a channel’s decoder is a function called once an event, rather than a loop with the dispatch inside it, was measured without linearisation and has not been measured again.
1.3.4 — 2026-10-25
A housekeeping release. A generated module differs from 1.3.3’s by the version in its header: it runs the same code, the wire does not change, and nothing needs recompiling.
Changed
- The CLI archives are built on LuneBlox 0.11.1, up from 0.11.0, with the same Luau and fast
flags; the suite passes unchanged and BlinkBlox’s
Runtimerows are 0.11.0’s. 0.11.1 fixesluneblox setup, which putEnumandwarnin.luaurc’sglobalsin every project: it now does so only where.vscode/settings.jsonturns the Roblox types off, and this project’s does not. type-check.shfails ifluneblox setupchanges.luaurc, and shows what it changed, where 1.3.3 put the file back without a word. The one change setup should still make is the alias after a LuneBlox bump that did not move it, and a name added toglobalswould hide the Roblox types again; either is now a red gate rather than a silent one. Under 0.11.0’s setup it fails, naming the two globals.
1.3.3 — 2026-10-25
A housekeeping release. A generated module differs from 1.3.2’s by the version in its header: it runs the same code, the wire does not change, and nothing needs recompiling.
Changed
- The CLI archives are built on LuneBlox 0.11.0, up from 0.10.18, and the tests and benchmarks
run on it. It runs the same Luau with the same fast flags; 0.10.19 between them fixed the
certificate checks behind
net, and 0.11.0 makes Roblox’sEnuma global. The suite passes unchanged, and BlinkBlox’sRuntimerows match 0.10.18’s, ABBA in turn:BooleansRandomfires in 6.08 ms native and decodes in 2.86 to 6.08 and 2.91, 17.90 and 9.45 interpreted to 17.92 and 9.44. Every other pin was already the latest release.
Fixed
type-check.shkeeps.luaurcas it is written. LuneBlox 0.11.0’ssetupaddsEnumandwarnto itsglobals, and luau-lsp types a name listed there asanyover what the Roblox definitions say:local N: number = Enum.KeyCode.Awent from an error to nothing. The plugin and the generated modules useEnum, so the gate puts the file back once setup has run.
1.3.2 — 2026-10-25
A benchmarks release. A generated module differs from 1.3.1’s by the version in its header: it runs the same code, the wire does not change, and nothing needs recompiling. Satset joins the comparison, and of what it does differently its run framing was measured and dropped: up to a byte an event saved, at 32-68% on an event’s write.
Changed
- The benchmarks run Satset v0.5.0 (bookek/satset) beside the other tools, in
Runtime,Rivalsand Studio. Its token bucket is lifted in its modes as the other tools’ limits are, and every table now opens with what was lifted. It has no Instance type, soRivalsleaves it out ofInstances, and it runsTiny, which until now was BlinkBlox’s alone, since its framing is the one thing it does differently. On an Apple M1, side by side, it is slower everywhere –Entitiesfires in 38.4 ms native against BlinkBlox’s 2.5 and decodes in 43.0 against 4.5 – and smaller before compression on runs of one event: it writes a run of one id under one 3- or 5-byte header, no index each, soTinycosts it 1.01 bytes an event to BlinkBlox’s 2.00 andBooleans127.01 to 128.00. Compression keeps that byte on random payloads: random booleans cost Satset 127.15 bytes an event in Studio to BlinkBlox’s 128.26. Runtime’szstdcolumn compresses at level 3, as Roblox’s own compression measures. It used Lune’s default, the highest level, which found three-byte repeats Roblox does not and took a framed event’s index and length back: BlinkBlox’s random booleans read 125.81 bytes an event where Studio measured 128.26. Every level from 1 to 9 matches Studio; the column now reads 128.01.BatchUnreliablecosts no time, measured. Its page said an event waits up to a frame for the flush.luneblox run Latency(new, inbenchmark) fires unreliable events in Studio early and late in the frame and sends them at once, at a Heartbeat flush or at atask.defer: each arrived at the same time whichever way, 16 ms after a late fire and 33 after an early one, since Roblox sends a frame’s remote calls together. A deferred flush was the candidate for batching without a wait, and there is no wait to remove, so the flush stays where it is.- Studio’s benchmark runs each tool three times on each bench, in an order turned by one each
round, and publishes the median of the rounds’ bytes and drain beside the lowest and highest
round, with frame rates and fire times pooled over all three. Bandwidth moved by about a byte an
event from one run to the next, enough to swap two tools’ places. The fire table says whether each
tool’s client code runs natively in Studio.
generatekeeps the raw results intools/studio.json, andluneblox run generate -- --from tools/studio.jsonwrites the tables again without Studio. - Studio’s tables are rerun, on an Apple M1 with 1.3.1’s modules, Satset among them. Since 1.2.1
BlinkBlox’s client is interpreted there while most other tools’ are native, which its
Fire msnow reflects;Benchmarks.mdsays so above the tables.
Fixed
- Studio’s benchmark stopped at Warp. Its mode registered a client’s schemas in an
__indexthat waits for the server’s ids, and a metamethod cannot yield; on a client that reached the events first, the whole run ended there. The wait is inFireandOnnow.
1.3.1 — 2026-10-21
A housekeeping release. A generated module differs from 1.3.0’s by the version in its header: it runs the same code, the wire does not change, and nothing needs recompiling.
Changed
- The CLI archives are built on LuneBlox 0.10.18, up from 0.10.17. It runs the same Luau with
the same fast flags, so no timing moves; what it adds is to the standard library – Roblox’s
Random, buffers in JSON asJSONEncodewrites them,utf8.graphemes– and errors that name their cause, among themstdio.prompt’s when no terminal is attached. The suite and the goldens pass on it unchanged. - Every pin is the latest release again. StyLua is 2.6.0, which leaves the tree as it was, and lefthook 2.2.2; the docs site is on Astro 7.4.1.
Fixed
- The docs site’s dev server ran a Vite with known flaws. Vite 8.3.0 to 8.3.2 carried a
launch-editorthat could misread an editor’s arguments into a command, chiefly on Windows; Astro 7.4.1 brings Vite 8.3.4. It was never in the compiler or in generated code.
1.3.0 — 2026-10-20
Five more Roblox types, and calls and checks that cost less. The wire and the encoding of every existing type do not change, so a module built by 1.2.1 still talks to one built by 1.3.0; the new types are part of the schema signature like any other, so only a schema that uses one is affected.
Added
PhysicalProperties, 24 bytes. All six fields asf32, which is how Roblox stores them, so nothing is lost; one built from a material cannot be told from one built from numbers –new(Enum.Material.Plastic)has anAcousticAbsorptionof 0.3,new(0.7, 0.3, 0.5)one of 1 – so none is left out. Measured in Studio,PhysicalProperties.newclamps every field into its range and lets a NaN through, so a client could hand the server a NaN density for a part. A field outside its range –Density0 to 100,Friction0 to 2,Elasticity0 to 1, the weights 0 to 100,AcousticAbsorption0 to 1 – or NaN is refused on arrival, naming the field. A part’sCustomPhysicalPropertiesisniluntil set, so it goes asPhysicalProperties?.NumberSequence, 25 to 241 bytes. A count, then each keypoint’s time, value and envelope asf32. Checked as aColorSequenceis – 2 to 20 keypoints, the count before anything is read for it, times from 0 to 1 in order – and a NaN value or an envelope below 0 is refused too: Roblox’s constructor throws on the envelope and lets the NaN through.Rect, 16 bytes;Region3andRay, 24. ARegion3goes as its two corners, which is allRegion3.newtakes. The constructor rounds the centre and size it works out tof32, so a region arrives exactly when its corners are whole numbers and otherwise within onef32step of its largest component; sending the centre and size instead differs as often.--from quicknet,--from packetand--from warpconvert the five where the draft saidunknown. OnlyFont,EnumItemandNil(and Packet’sCharactersandStatictypes) are left without an equivalent.benchmark/Runtime.luau -- --validationstimes BlinkBlox built withWriteValidations.
Changed
- An answered call no longer pays for the message of one that was not. Every call armed its
timeout through two closures, and every call the server made of a client also built the warning it
would print, reading
Player.Nameoff the Instance. The message is built only once the timer runs out, and arming it makes one closure. Its words do not change. WriteValidationschecks a number, string, boolean or buffer withtype().typeofalso asks a userdata for its own name, which a value of those types never is. The Roblox types keeptypeof. The messages do not change.
Measured
On LuneBlox 0.10.17 on an Apple M1.
- The timer. Arming and cancelling one, the three shapes alternated in one process, five runs of
five rounds, the fastest: 1.2.1’s took 1747 ns interpreted and 1646 natively, 1.3.0’s 1261 and
1377. It was faster in every round of every run. Handing the arguments to
task.delayinstead of a closure was tried first and dropped: cheaper interpreted, but dearer natively, which is how a server runs it – 1670 ns against 1633. type()underWriteValidations.benchmark/Runtime.luau -- --validations, the two checks alternated in one harness: a thousand fires a frame took 3% less interpreted (BooleansRandom 29.64 to 28.74 ms, Entities 20.70 to 20.16, Booleans 27.86 to 26.68), the medians of three rounds, and the same natively – BooleansRandom 6.10 ms withtypeofand 6.11 withtypeover eight rounds, inside the 1% the rounds of one build differ by.- The generated modules.
benchmark/definitions/Definition.blink, compiled withoption ManualReplication = trueby 1.2.1’s source and by this release’s, differs only in the invocation timer, which the benchmark never arms: every line a fire, a flush or a decode runs is the same.Runtimeover both releases’ modules was taken, but with Studio running beside it at 60% of a core, and it moved one release’s identical code by up to 40% from run to run, so its figures are not given here. - The compiler.
luneblox run Performance, six runs of each release in ABBA order, the median of each run’s median in milliseconds, 1.2.1 then 1.3.0: lexing 1.44 and 1.40, parsing 4.78 and 4.78, generating the server 18.42 and 18.30, the client 17.84 and 17.70.
Upgrading
- Recompile to get the new types and the lighter timer. A module built by 1.2.1 and one built by 1.3.0 still talk to each other.
PhysicalProperties,NumberSequence,Rect,Region3andRayare types now, so a declaration of one of those names must be renamed (E2002), and a top-level type namedNumberSequenceKeypointis refused beside aNumberSequence(E3005). So are declarations namedExpireInvocationandReleaseGonePlayer, the runtime’s two new functions. Handlers, their arguments and theirReasons do not change.
1.2.1 — 2026-10-14
Two fixes to the generated modules, both seen in a production game’s error reports. The wire and the schema signature do not change, so a module built by 1.2.1 talks to one built by 1.2.0; recompile to get the fixes.
Fixed
- A rate-limit handler owns the report, as a packet-drop handler already did. A packet refusal
printed its warning only while no
SetPacketDropHandlerhandler was installed, but a rate refusal printedRate limited event "E" from X, N refused.whatever the game had installed, so a game that reported refusals throughSetRateLimitHandlersaw each one twice. Now, with a rate-limit handler installed, the handler is called and nothing is printed; with none, the warning is printed as before, word for word. The same holds for the other two reports that reach that handler: a function’s calls past itsConcurrency("F" from X already has N calls running, N refused.) and, while no packet-drop handler takes it, the inbound budget (Dropped packets from X over the inbound budget of N bytes a second, N refused.). The schedule and the counts do not change. - Only the server module opens with
--!native. The client and types modules asked to be compiled natively too, and a client device that cannot logsNative code generation of script ... failed: Native code generation is not supported on this device.for each module that asks. Every Roblox server supports native code, and most players’ clients run code interpreted anyway, so the client and types modules now go without it.--!strict,--!optimize 2and the lint directives stay on all three.
Upgrading
- A game that installs
SetRateLimitHandlerno longer sees the rate,Concurrencyand inbound budget warnings in the output. If it relied on them, print from the handler. - A client module no longer runs natively where a device could have compiled it, Studio among them, so a client’s timings in Studio come closer to a player’s.
1.2.0 — 2026-10-12
One new option. The wire and the schema signature do not change, and a schema that does not set the option compiles to the module 1.1.8 gave it but for the version in its header: nothing needs recompiling.
Added
option ExportRatesadds a frozenRatestable to the server, client and types modules, and areadonlydeclaration to the TypeScript output: the rate limits the server enforces, keyed by the schema’s own names –Rates.Report.Burstis 4. A client that paces what it sends, or holds back as many as a burst, reads the numbers instead of repeating them.ExportLimitscould not carry them: an event’s entry inLimitsis its data’s bounds, a range itself where the data is one value.- An entry holds
Rate,Burst, and a function’sConcurrencywhere it declares one. They are the enforced numbers, not only the written ones:Ratefalls back toDefaultRate, andBurstto the rate rounded up. - Only an inbound declaration that something limits has an entry. What the server sends, a stream’s send rate and an inbound event with no rate are left out, and a scope nests its own.
- The structural words follow
Casing. With the option on,Ratesis a reserved top-level name (E3005); off, nothing is emitted and the name is free.
- An entry holds
Changed
- The server’s rate guard and the exported table take a declaration’s rate and burst from one function, so the table cannot state a limit the module does not enforce. The guard’s emitted code is what it was.
Measured
benchmark/definitions/Definition.blink, compiled with option ManualReplication = true by 1.1.8’s
source and by this release’s, gives the same Server.luau and Client.luau byte for byte, so the
generated modules run as they did. The compiler itself, luneblox run Performance, three rounds of
each release in turn, the fastest median in milliseconds, 1.1.8 then 1.2.0: lexing 2.30 and 2.32,
parsing 8.44 and 7.85, generating the server 30.29 and 30.69, the client 29.05 and 28.47. The rounds
of one release differ by more than the releases do.
Upgrading
Nothing to do. To read the limits, add option ExportRates = true and rename a top-level
declaration called Rates, if the schema has one.
1.1.8 — 2026-10-12
A housekeeping release. A generated module differs from 1.1.7’s by the version in its header: it runs the same code, the wire does not change, and nothing needs recompiling.
Changed
--from quicknetrecords QuickNet v0.4.0-beta definitions (commitedba9a2, the head of main the day it was released; its tag points at v0.3.5’s source).Data.FontandData.Rayhave no equivalent and becomeunknown, asRectdoes, and so doesData.custom: the game’s own encoder and decoder are functions a schema cannot hold. Each is named in the checklist.--from warprecords Warp 1.1.0-pre8 schemas. Its newvector3f32andvector2f32becomevectorandVector2; they stopped the conversion as names the converter did not know.- The benchmarks fetch QuickNet v0.4.0-beta and Warp 1.1.0-pre8. QuickNet 0.4.0 drops a packet
over 50,000 bytes and the events past the hundredth in one, for every event, so
download.luaulifts both as it lifts Packet’s and Warp’s limits. On one machine, side by side, QuickNet 0.4.0 firesEntitiesin 13.7 ms native and 31.1 interpreted, from 0.3.5’s 18.0 and 40.1, and decodes it in 28.2 and 51.4, from 32.1 and 61.0; BlinkBlox’s are 4.7 and 12.9 there. Warp pre8 times as pre7 did, and now frames its unreliable packets too: 2 bytes more each, 1,050 bytes a frame forSelfStateto 50 players, from 950. The published tables are still 0.3.5’s and pre7’s. - The CLI archives are built on LuneBlox 0.10.17, up from 0.10.16, and the tests and benchmarks run on it. Its Luau, fast flags and mlua are 0.10.16’s; the suite and the goldens pass on it unchanged.
1.1.7 — 2026-10-10
A housekeeping release. A generated module differs from 1.1.6’s by the version in its header: it runs the same code, the wire does not change, and nothing needs recompiling.
Changed
- The CLI archives are built on LuneBlox 0.10.16, up from 0.10.15. That release moves twenty of the runtime’s crates to their newest compatible versions and changes nothing a script sees; the suite and the goldens pass on it unchanged.
1.1.6 — 2026-10-10
A housekeeping release. A generated module differs from 1.1.5’s by the version in its header: it runs the same code, the wire does not change, and nothing needs recompiling.
Changed
- The release bundle is built on every pull request.
luneblox run buildran only in the release workflow, so a broken darklua config surfaced after the tag existed. The checks build it now, which adds about twenty seconds. - A release runs the suite before it uploads.
release.ymlhas averifyjob that the build waits for, so a tag cut from a tree that fails its tests uploads no binaries. - Luau’s reserved words are listed once. The value formatter the tests print with kept its own
copy of
Grammar.LUAU_KEYWORDS; it reads that table now, and joins a table’s lines withtable.concat. What it prints is unchanged. - The docs site’s npm dependencies are watched by Dependabot, one grouped pull request a week.
- Every pin is the latest release again. The CLI archives are built on LuneBlox 0.10.15, up
from 0.10.13; lefthook is 2.2.1,
actions/setup-node7.1.0, and the docs site is on Astro 7.3.8, Starlight 0.42.6 and starlight-links-validator 0.27.0.
Fixed
- The docs site built with a
postcss-selector-parserthat has a known flaw. A selector could be made to cost quadratic time to parse (fixed in 7.1.6).docs/package.jsonoverrides it to 7.1.6; the built site is byte-identical. It was never in the compiler or in generated code.
1.1.5 — 2026-10-05
A housekeeping release. A generated module differs from 1.1.4’s by the name of one parameter and the version in its header: it runs the same code and prints the same warning, the wire does not change, and nothing needs recompiling.
Changed
TimeoutInvocation’s parameter is named for what it holds. The function in every generated module that warns when a call is never answered took the opening of its warning,"F" was never answered, in a parameter calledName. It isUnanswerednow. The warning’s text is 1.1.4’s._G.VERSIONis read in exactly one module.src/Modules/Version.luaunow answers two questions, which version this is and whether it is a bundled build, and the TypeScript emitter asks it instead of testing the global itself. The benchmarks label a run from source0.0.0, as the CLI does, where they printed(unbundled); no published figure is re-measured or changed.pesdeandwally, which publish a release, are pinned inrokit.tomlbeside the other tools, and the release procedure is written down inCLAUDE.md.
1.1.4 — 2026-10-05
The generated modules change by the text of one warning. The wire does not change, so nothing needs recompiling; a game that matches that warning’s text in its output must match the new one.
Fixed
- The warning for a call that timed out read badly. An
Invokenobody answered warned"F" was never answered and has been failed after N seconds.It now says"F" was never answered within N seconds, so the call failed., and on the server still names the player:"F" was never answered by X within N seconds, so the call failed.When it is raised and what the caller gets are unchanged. - A run from source reported two versions. The CLI printed
BlinkBlox DEBUGwhereLibrary.VERSIONand the header of every module it generated said0.0.0. All three now read one module,src/Modules/Version.luau, and say0.0.0. A release build is unaffected: it carries its version as before.
Changed
- The docs site’s
http-cache-semanticsis updated to 4.3.0, past GHSA-ch52-4w7c-c8xp. It is a dependency of the site’s build, not of the compiler or of anything a game runs.
1.1.3 — 2026-10-04
The generated modules change by one cast, where a struct received from the other side has an
Instance(Class) field. The wire does not change, so nothing needs recompiling.
Fixed
- A received struct’s
Instance(Class)field failed the new type solver. Its class check was emitted asValue.Target:IsA("Humanoid")on a field of anany, whichLuauSolverV2reports as a call on an error type; a game checking its generated server under the new solver saw one error for each such field. The check is now written(Value.Target :: Instance):IsA("Humanoid"). It runs the same code. No golden under the new-solver gate had such a field: the one schema that did,Test.blink, is past the size that solver finishes.
Added
- Compared with hand-written buffers,
a guide: when writing a buffer reader by hand is the better choice, what one has to get right once
a hostile client is the sender, and the reader the compiler writes for the same event. The code it
quotes is checked against a golden by
test/DocGenerated.luau, which also refuses a generated module that uses a metatable.
Changed
- The README’s Performance section is one table instead of four and says what the numbers are for; the docs’ front page puts “Where it fits” above “Measured”. The other tables are in Benchmarks, unchanged.
1.1.2 — 2026-10-04
The compiler is unchanged: a schema compiles to exactly what 1.1.1 produced, and the wire does not change, so nothing needs recompiling. What moves is what the release is built with and runs on.
Changed
- The release executables carry LuneBlox 0.10.13 (from 0.10.12): mlua 0.12.2 and the fast flags
of the Roblox client 0.741, on the same Luau 0.740. Timed on an Apple M1 with this release’s
compiler on both runtimes, the generated modules’ medians agree within 1% on Booleans, Entities
and their random-payload variants, native and interpreted; the short
Tinybench, timed six times a mode at 2000 frames on each, reads 0.199-0.200 ms a frame to fire and 0.278-0.282 ms to decode interpreted on both. - The rest of the toolchain is at its latest release: rojo 7.7.1, selene 0.32.0, lefthook 2.1.16, and Starlight 0.42.5 for the docs.
- Usage text that still said
lune runsaysluneblox run:bump’s usage line, the benchmark’s “runluneblox run build --downloadfirst”, and the golden test’s hint to re-record the snapshots. Upstream Lune was never a dependency of the toolchain; the pesde package still targets theluneenvironment and runs on any Lune.
1.1.1 — 2026-10-02
The generated modules change only under option TrafficStats, and only by the counting lines
below; without the option a schema compiles to exactly what 1.1.0 produced. The wire does not
change, so nothing needs recompiling for the other side’s sake.
Fixed
GetTrafficStats().Reliableleft out the server’s broadcast batch. A reliableFireAllis written once and sent at the flush, in oneFireAllClientsor to each player who has not taken it in a packet of their own, and neither send was counted: only a player’s own batch was. The events in it were counted as they were fired, once per player, so a game that mostly broadcast saw an event’sSentBytespass its whole channel’s – 97FireAlls of a 19-byte event to one player read 1843 bytes for the event and about 300 forReliableandUnreliabletogether. The broadcast batch now counts its bytes and one packet for every player it is sent to: all ofPlayers:GetPlayers()for aFireAllClients, and the players who did not diverge otherwise. A diverged player’s copy was already counted in their own packet and is not counted twice.- The client’s
Reliableleft out a batch cut into packets. A frame pastMaxEventsPerPacket,MaxPacketSizeorMaxInstancesPerPacketgoes out as several packets, and the flush counted none of them; each is now a packet with its bytes. - The unreliable channel was looked at and is unchanged: every unreliable send, a stream’s and a
Per: Playerstream’s included, counts its channel as it is sent.
Upgrading
- A game that reads
Reliable.SentorReliable.SentByteswill see them rise to what was sent. Event counts, the received side and every handler are as they were.
Tests
test/TrafficChannels.luaubuilds servers with three, two and no players: a frame of broadcasts, a mix of diverged and undiverged players, and a cut client batch, each held to the exact bytes the stub remote was fired with and to the channel never counting less than its events.
Speed
- No slower than 1.0.3. Both compilers in one process, their runs alternated on 1.0.3’s Test.blink (mean of the faster half of 300 to 400 runs, in both orders, Apple M1): parsing 4.55 to 4.67 ms against 4.55 to 4.66, generating the server module 19.11 to 19.34 against 19.06 to 19.33, the client module 18.25 to 18.69 against 18.23 to 18.67 – within 0.4% either way. The two modules generated from that schema are byte for byte 1.0.3’s after the version line, so the runtime is unchanged.
- The compiler’s own code differs from 1.0.3 in one file,
src/Generator/Traffic.luau, whose new work runs only underoption TrafficStats. The converters are loaded only under--from.
1.1.0 — 2026-10-02
Not published on its own: it went out as part of 1.1.1, the same day.
A release for games that are somewhere else: converters and guides for coming from zap, ByteNet, Packet, QuickNet, Warp and plain remotes. The generated modules do not change: apart from the version in its first line, no schema’s output differs from 1.0.3’s. Nothing to do on upgrading.
Added
blinkblox <config> --from zapconverts a zap config into a draft schema, written beside it as.blink. It reads zap 0.6.29’s whole grammar – options, types, events, functions, namespaces – and compiles the draft itself before handing it over. What zap has no way to say is left for the reader as aTODO(convert)comment where the answer belongs, never guessed: aRatefor each event a client fires, an upper bound for each length a client sends. Only what a client sends is marked; an unbounded array in an event the server fires is the server’s own. What has no equivalent – a union, a set,AlignedCFrame, an option – is named in the list the command prints, with its line, and nothing is dropped silently.--from bytenetrecords ByteNet 0.4.3 definitions into a draft schema. ByteNet’s packets are defined by running Luau, so the command runs the definitions file against a stand-in for ByteNet that records each namespace and packet instead of sending anything: with placeholders forgameandscript, with a module beside the file run in the same way, and with no filesystem, network or process library. A ByteNet packet has no direction, so each event is left for the reader to give one, writtenFrom: Clientuntil then – the direction that is checked and rate limited. A struct that one function builds for several packets is declared once. A name the stand-in does not know stops the command, so a later ByteNet’s type cannot go missing from a draft.- A guide, Coming from ByteNet,
with a wrapper that answers to
send,sendTo,sendToAllandlistenand turns the arguments around: the player goes first in a schema’s calls. --from packetrecords Packet 1.7.0 definitions, the same way. Packet’s types are read as Packet reads them: a table holding one type is an array and any other a struct. The one-byte length ofStringandBufferis a bound and is kept asstring(..255); theLongones are marked when a client sends them. A packet with aResponsebecomes a function, and since Packet lets either side call it, the draft asks which does.Rect,Region3,NumberSequence,EnumItem,Charactersand theStatictypes have no equivalent, and each use is named.- A guide, Coming from Packet,
with a wrapper that answers to
:Fire,:FireClient,OnServerEvent:ConnectandOnServerInvoke. --from quicknetrecords QuickNet v0.3.5-beta definitions, the same way, and carries what the others cannot: QuickNet limits how often a client may fire, soSetRateLimit(Max, Time)becomesRate: Max / Timeand an event that sets none gets QuickNet’s own default,Rate: 60. One lifted withmath.hugeis marked for a rate like any other library’s. A uniform array isT[], a static array of one typeT[3], a uniform dictionary a map, a static one a struct, a literal of names an enum; a union, a static array of different types and the types with no equivalent are each named.- A guide, Coming from QuickNet,
with a wrapper that answers to
:FireServer,:FireClient,:FireAllClients,OnServerEvent:Connectand:InvokeServer. --from warprecords Warp 1.1.0-pre7 schemas, the same way: eachuseSchemabecomes an event. Warp decides an event’s direction and its reliability where it is fired, so the draft asks for both. An event the definitions name and give no schema – inreg_namespaces, or by connecting to it – is kept withunknownfor its data and said to be; acustom_datatypeand the types with no equivalent are named. Schemas handed over from a table come out in alphabetical order, so a draft is the same on every run.- A guide, Coming from Warp, with a
wrapper that takes an event’s name as
Fire,FiresandConnectdo. - A guide, Coming from RemoteEvents,
for a game on plain remotes, which has no definitions to convert: how each remote becomes a
declaration, how the checks at the top of a listener become types, and a wrapper that answers to
:FireServer,:FireClient,OnServerEvent:Connectand:InvokeServer. - An overview, Switching to BlinkBlox: the converters in one table, what each library’s draft carries over and what it asks for, and the guides. The README names the converters under its benchmark tables.
- Every wrapper the guides print is run by the test suite against generated modules, as every schema in the docs is compiled.
- The converter is loaded only under
--from, so a compile does not read it. - A guide, Coming from zap: the
conversion, a table of what maps to what, the call sites that differ, and a short wrapper that
gives a generated module zap’s names (
SetCallback,Call,FireSet,SendEvents) so a game can move its config first and its call sites after. The wrapper is run by the test suite against generated modules, as every schema in the docs is compiled.
1.0.3 — 2026-10-02
The generated modules do not change: all eighty outputs of the sixteen test schemas – server, client, shared types and both TypeScript declarations – are byte for byte what 1.0.2 produced. Nothing to do on upgrading.
Changed
- The complexity limit is 15, down from the 20 it went in at. Nineteen more functions were over
it and each was split, none exempted. On the compiler’s hot paths the rare branches moved out and
the common one stayed inline: the lexer’s end-of-file and unknown-character exits left
GetNextToken, and a pack leftGenerators.LuauType, whose other types no longer test for one on the way out. - TypeScript declarations are not among the committed snapshots, so they were compared directly, old compiler against new, with the Luau outputs beside them.
Speed
- No slower than 1.0.2, measured as before with both compilers in one process and their runs alternated on Test.blink (mean of the faster half of 800 runs, Apple M1): lexing 2.14 ms against 2.15, parsing 7.47 against 7.47 over six runs, generating the server module 29.31 against 29.34 and the client 28.89 against 28.90.
1.0.2 — 2026-10-02
The generated modules do not change: a schema compiles to exactly what 1.0.1 produced, and every
committed snapshot in test/Golden is byte for byte what it was. Nothing to do on upgrading.
Changed
- A function’s complexity is a gate. The compiler’s files were capped at 500 lines, which left a
file free to be one function: the one emitting an
eventwas 430 lines and scored 50 on cyclomatic complexity. selene’shigh_cyclomatic_complexitynow denies a function past 20, in the compiler, the Studio plugin and the tests alike, with no exemptions. Seventeen functions were over it and each was split: the receiving side of an event intoGenerator/EventReceive.luau, the answering side of a function intoGenerator/FunctionAnswer.luau, the connections and the decode loop intoGenerator/Packets.luau, and the rest into named functions or handler tables in place. - The test oracle was among the seventeen. Its old and new copies were given the same 32,700 drawn values of Test.blink’s 131 exported types and agreed on all 564,328 answers, and the generator drew the same values from the same seed.
- Lizard was tried for this first and dropped: its Lua reader knows neither Luau’s type annotations
nor backtick strings, and found 25 of the 33 functions in
Generator/Blocks.luau.
Faster
- Parsing a type no longer makes a closure for it.
Parser.Typebuilt one, with the declaration it wraps boxed as an upvalue, for every type in the schema; it is a function of the module now. Measured with both compilers loaded in one process and their runs alternated, on Test.blink: parse 7.48 ms against 1.0.1’s 7.50, generating the server module 30.65 against 30.71 and the client 29.74 against 29.86, each the mean of the faster half of 800 runs on an Apple M1.
1.0.1 — 2026-09-30
The generated modules do not change: a schema compiles to exactly what 1.0.0 produced.
Added
- The compiler is on Wally, as
xopoiii/blinkblox, for code that runs it inside Roblox – a plugin, an in-Studio build step, an editor.Compile(Schema, Options?)returns the server, client and types modules’ text and the warnings it raised;Options.Filessupplies what animportnames, andOptions.Profilethe profile. It is the same lexer, parser and generator as the CLI and the Studio plugin, bundled into one module with the version built in (src/Library.luau, built byluneblox run buildintorelease/wally). A game still needs only the generated modules. luneblox run bumprewrites the Wally pin in the docs too, andscripts/check-versions.shfails on a stale one.
1.0.0 — 2026-09-30
A release built from other libraries’ bug reports. The DevForum threads and issue trackers of upstream Blink, zap, ByteNet, Packet, Warp and QuickNet were read in full, about two hundred reports, and each was checked against this fork: most it already prevented, and this release closes the rest it could. It is 1.0.0 because the generated server is now what the fork set out to make – safe to point at the open internet – and because what it reports to a game changes (see Upgrading).
The wire does not change: a module built by 1.0.0 talks to one built by 0.40.0 or later, unless the schema uses one of the new types, which the schema signature then tells apart.
Safer
- A missing Instance refuses its own event, not the rest of the packet. An Instance the sender
had arrives as
nilwhen it is not streamed in, not replicated to the receiver, or destroyed in flight – routine under StreamingEnabled. The read threw, and the packet’s one pcall discarded every event after it. The slot is read either way, so the cursor is sound: the event is refused once its payload is read, reported, and the packet goes on. A call missing an Instance is answered with a failure. A flat event is refused where the Instance is missing and pays nothing when it arrives, so decoding is as fast as 0.42.1’s. - An array of Instances was measured against the table beside the packet, which a missing last
Instance shortens, and threw the packet away; it is measured against
MaxInstancesPerPacket. A map’s entry count had no check before its loop; it has one. - A second copy of a module in another Luau VM errors at require. Every Actor is a VM, and the
_Gguard cannot see across them: a copy required from an Actor connected to the same remotes and decoded every packet twice, with two inbound budgets for one player. The server and the client each claim the remote with an attribute. Remotes the server makes areArchivable = false, so a place saved while running does not keep them. - The client’s queues are capped. An event with no listener queued without limit and warned on every event past 256 – zap’s “death loop”. It stops at 256, as the server’s does, and warns once.
Added
Vector2,UDim,UDim2,NumberRange,ColorSequenceandTweenInfo. AColorSequenceis 2 to 20 keypoints; its reader checks the count before reading for it, and that the times run from 0 to 1 in order, and aNumberRangethatMaxis not belowMin, so a hostile packet is refused with a message rather than by the constructor.Enum(Name), an item of a Roblox enum: itsValuein au16, looked up byValueon arrival – never by position inGetEnumItems(), which can differ between client and server during an engine rollout. TheValueis checked on send whateverWriteValidationssays.Enumis a type only directly before its name, so a declaration already calledEnumstill parses;E3037for anEnumwith no name.option RemotesFolder = "Name"keeps the two remotes in a folder in ReplicatedStorage.W3036warns when a file holding events or functions is imported more than once: each import compiles them again, as separate events.- A client still waiting for its remotes after five seconds says once why – the server module makes
them – and names
ClientConnectTimeout, then keeps waiting. - A second
.Onon a function warns that it replaced the first. - A send called with
:instead of.says so, on the server where a player’s first send of a frame lands and in theWriteValidationstype error; ordinary sends pay nothing for it. - The CLI and the Studio plugin warn when a generated module passes 50,000 lines.
- A Common pitfalls guide.
Changed
- The generated modules keep their types under the new type solver. Past about 200 events the
solver gave up on the returned table and a game’s code lost every type; the table is now assembled a
member a statement.
scripts/type-check.shchecks every golden under both solvers, andtest/Consumerschecks that a game’s use of the modules type-checks and its misuse does not. - The Studio plugin’s editor parses the schema once typing pauses for 0.3 seconds and recolours it once a frame: on a 1,200-line schema each cost about 15 ms, and both ran on every key.
- The plugin’s changes are undo steps. Deleting a schema destroyed it beyond any undo; saving, deleting and generating are now each one step, and a generation whose write fails is rolled back.
- The toolchain runs on LuneBlox 0.10.12, which fixes a Luau 0.740 native-code fault on ARM64 that made native benchmarks die silently on Apple silicon.
- Tests load modules with full channels, 256 events each way, natively.
Performance
Against 0.42.1, Runtime over both releases’ modules in one harness (--sources), LuneBlox 0.10.12
on an Apple M1, releases alternated, three rounds of every bench and eight of the two below:
nothing got slower. Fire, flush and decode stay within 1% on every bench, native and interpreted.
| Native decode, ms a frame | 0.42.1 fastest / median | 1.0.0 fastest / median |
|---|---|---|
| Booleans | 4.426 / 4.435 | 4.431 / 4.473 |
| EntitiesRandom | 7.050 / 7.885 | 6.982 / 8.065 |
Those two medians moved with the machine, not the code: both releases’ runs fall into the same two clusters (Booleans 4.43 or 4.98, EntitiesRandom 7.0 or 9.0), and the decoders differ only in type annotations, which the compiler erases. The checks this release adds sit at require, on a player’s first send of a frame, or on the path of an event that failed.
Upgrading
- Nothing to regenerate for the wire. Regenerate to get the fixes: each applies only to the side built by 1.0.0.
- What reaches the decode-error handler changes. A missing Instance used to fail the whole packet
with whatever the read threw; now each event that lost one is reported on its own, with a
Failurethat begins “An Instance it carried did not arrive”. On the server that isSetDecodeErrorHandler, through the same once-a-second schedule. It describes the other side’s view of the world, not a malformed packet: a game that strikes or bans on decode errors should not count it. On the client a handler hears every one, and without a handler each event warns once. - Requiring a module a second time from an Actor now errors. Require each module from one VM.
- The client’s queue for an event with no listener stops at 256 and warns once, rather than growing.
Vector2,UDim,UDim2,NumberRange,ColorSequenceandTweenInfoare now types, so a declaration of one of those names must be renamed (E2002). A top-level type namedEnumorEnumItemis refused (E3005) only in a schema that usesEnum(Name)orTweenInfo, and one namedColorSequenceKeypointonly beside aColorSequence.
0.42.1 — 2026-09-29
Neither the wire nor the generated modules change.
Changed
- The toolchain runs on LuneBlox 0.10.10, which keeps Luau 0.740 and Roblox’s flags; only its CI moved, so the benchmark figures stay comparable.
- CI and the release workflow use the Node.js 24 majors of their actions – GitHub deprecated the
Node.js 20 ones – and
CompeyDev/setup-rokitv0.2.1. The docs build on the current Node LTS, and their dependencies are at their latest releases.
0.42.0 — 2026-09-29
A release about the server’s decode and the client’s flush, measured against every library this fork is compared with – QuickNet and Warp join the benchmarks – and against 0.41.2 in one harness. The wire does not change: modules from 0.40.0 on still talk to these.
Faster
Against 0.41.2, on LuneBlox, median of five runs (see the benchmarks):
- A packet decodes without allocating. Each packet made a closure for its events and each Sync listener ran under a pcall of its own. A channel’s decoder is now a function of the module, its state beside the cursor, and a Single Sync listener is called directly: when it throws, the packet’s pcall sees the listener was running, reports it as before, and decoding goes on with the next event. A thousand one-byte events decode in about half the time natively, and a hundred events carrying a part in 0.011 ms rather than 0.021 – Blink’s is 0.013.
- Whether the player is still in the game is asked once a packet, before it is decoded and after a listener throws, rather than before every event.
- The client refills the last frame’s packet when the next is the same size, rather than allocating one every frame: a thousand booleans flush in 0.005 ms, from 0.042.
- A packed
boolean[]is read the faster way where the module runs. Testing each bit is fastest natively, copying rows of eight is about 30% faster interpreted; the module measures both as it loads and uses the faster. A thousand 1000-element arrays decode interpreted in 14.2 ms, from 20.2.
Added
option PackedBooleanReader = "Auto" | "Bits" | "Rows":"Auto"(the default) as above; the other two fix the reader and emit only it. A value outside the three isE2003.
Upgrading
- Nothing to regenerate for the wire. A module built by 0.42.0 talks to one built by 0.40.0 or later.
- Two behaviours a game can see, both on the server:
- A Single Sync listener that throws is reported exactly as before – the listener-error handler, or raised on a thread – and the rest of its packet is still decoded.
- A packet whose listener removes its own player mid-packet goes on to its end, at most
MaxEventsPerPacketevents, as a packet already queued when a player leaves always has. A player who has left before the packet is decoded is still refused before any of it is read.
Changed
- The benchmarks compare two more libraries, QuickNet
0.3.5 and Warp 1.1.0-pre7, in
Runtime,Rivalsand the Studio place. Both pack typed events into buffers and batch them a frame, and QuickNet publishes figures against Blink 0.18.8. Neither is on Wally at that version, sodownload.luaufetches each from its GitHub source. QuickNet’s rate limit is lifted per event, as its own benchmark does, and Warp’s 8000-byte inbound cap is raised as Packet’s is. - The harness that loads the other tools grew what they needed and loads them as Roblox would:
@nativefunctions compile natively where the module does not declare--!native, a module’s own code runs inside a function so Roblox’s globals do not count against its 200 locals,@selfand aninit.luau’s./resolve as Luau resolves them, and each side has its ownshared. The two sides now deliver what they sent each other while loading before anything is timed, which Warp’s client needs to learn its event ids. The figures of the tools already compared did not move (a 100-frame run of each before and after, within the run-to-run noise). - The toolchain runs on LuneBlox 0.10.9, and every benchmark runs a full garbage collection before
each measurement.
Runtimetimes every tool one after another in one process, and each inherited the heap the one before it left: BlinkBlox’sBooleansRandomdecode measured 5.3 ms or 7.5 ms with nothing changed but what ran first. The sandbox leavescollectgarbageonly"count", as Roblox does, so LuneBlox 0.10.9 addedluau.collect()for it. BooleansRandomandEntitiesRandomhold 1001 payloads, one more than a frame fires, so no frame repeats the one before it. Warp sends each reliable packet as its XOR against the last, and with 1000 two frames were identical and XORed to zeros: its random payloads compressed to 0.02 bytes an event where every other tool’s stayed at their size.luneblox run Runtime -- --sources <dir>times theServer.luauandClient.luauin a directory instead of generating them, which is how two releases are compared in one harness.- Every published benchmark figure was taken again, on an Intel Core i7-13700K with LuneBlox 0.10.9,
each run alone on one performance core:
Runtimethree times,RivalsandScenariosfive, four releases side by side with--sources, and the runtime comparison. The Studio place ran all eight modes on 0.41.2 on the same machine; on it BlinkBlox holds the 60 FPS cap on the Entities benches too, where on the M1 it held it only on the boolean ones – a faster machine, since Blink rose from 22 to 44 FPS on the same bench. Runtimetimes every row in a process of its own. In one process each tool inherited the heap and the allocator’s state the tools before it left, and the first paid for growing them: BlinkBlox, always first, decoded Booleans natively in 6.5 ms, and in 2.9 run last, where QuickNet took 3.6 in sixth place and 7.4 in first. Measured that way, BlinkBlox decodes fastest on every bench.- Every benchmark holds 64 MB for the whole process. Luau’s collector paces itself on the heap, and QuickNet keeps about 12 MB of buffers alive, so it was collected less often than the tools that keep nothing and decoded booleans faster for it. A game server’s heap is far larger than any tool’s share.
- Warp’s fire time is left out of the Studio tables: its Fire only queues, and it encodes later in the frame, so the figure read 0.1 ms while its frame rate fell to 32.
.gitattributeskeeps every text file LF in the working tree. Git for Windows checks files out with CRLF by default, andstylua --checkthen failed every one at the pre-commit hook.
0.41.2 — 2026-09-28
Neither the wire nor the generated modules change.
Changed
- The toolchain runs on LuneBlox 0.10.8, which keeps Luau 0.740 and Roblox’s flags; the benchmark
figures stay comparable. Its typedefs declare
--!strict, and the type gate failed on apcall(stdio.prompt, ...)that no longer resolved the overload: every prompt is called inside a closure instead. - The Studio benchmark says how many events the server heard. The server always counted them, but
the client showed
Recieve = 0until every bench had run, and the Loss column was floored to a whole percent, so up to one event in a hundred could go missing behind “0%”. Each tool’s count is now asked for once it has drained and printed in the Output – a warning when it falls short of what was sent – and only an exact count reads “0%”.
0.41.1 — 2026-09-28
Neither the wire nor the generated modules change.
Changed
- The toolchain runs on LuneBlox,
lunebloxinrokit.toml: Lune on Luau 0.740, the version Roblox runs, with Roblox’s fast flags, where Lune 0.10.5 runs 0.709 with Luau’s defaults. Tests and benchmarks warn when run on anything else, benchmarks name the runtime in their header, and a release build refuses, since its executables carry the runtime that built them. The same modules timed on each: interpreted fire and decode take less than half the time, and a 128-declaration module that Lune left interpreted compiles natively. Native code on the thousand-event benches is about 20% slower than Lune’s figures said, because Roblox setsDebugCodegenOptSize– the figures now match what a Roblox server runs. - The compiler run through pesde hands itself over to
lunebloxwhen it is installed, with the same arguments and output, and otherwise runs on the Lune pesde started, as before.BLINKBLOX_NO_LUNEBLOX=1keeps it there. - The type gate runs luau-lsp 1.70.1, on Luau 0.740; the generated modules still carry no strict errors.
0.41.0 — 2026-09-28
The wire does not change for any schema that compiled before. A client module now sends at most 60 times a second by default; see Changed.
Faster
- A channel’s events are dispatched by halving the index range rather than one
elseifafter another, so an interpreted client no longer makes a hundred comparisons to reach the hundredth event. A hundred sends over 128 events decode in 0.053 ms a frame interpreted, from 0.086. A channel of up to eight declarations emits exactly what it did; the first event of a larger one pays a few comparisons more (index 0 of 133: 0.090 to 0.093 ms). - A received length is no longer checked against a bound its prefix already meets: never below its minimum, and not above 65535 when it is a varint, which refuses past that itself. Every unbounded string and array carried both checks on every packet. An integer skips a side its read type meets.
- A ranged number decoded into a struct field or an array slot is checked in a local before it is stored, where each bound read the field back out of its table: 30% off decoding a struct of three ranged numbers.
Added
- Quantized floats. A float takes a scale when it has a closed range, and is sent as whole steps
above its minimum in the narrowest unsigned integer that holds the span:
f32<0.01>(-1..1)is one byte where it was four,f32<0.25>(100..200)two,f64<0.001>(0..2048)three of eight. A float with a scale and no closed range was refused before and still is (E3032), so no schema that compiled changes its wire. option OutboundBytesPerSecondandOutboundBurst, a per-player budget for what the server sends, andPriority: Lowon a server’s unreliable event or stream. Every send is charged to the players it reaches, a broadcast as one addition rather than one per player; only a low send is ever left out, while its player’s budget is spent. A low stream held per player waits for the budget and then sends the state it holds by then.E3035refuses a priority on anything else, andW3035reports one in a schema with no budget.
Changed
- A client sends at most
option ClientFlushRatetimes a second, 60 unless the schema says, where it sent every Heartbeat. Measured in Studio, each remote call costs about 11 bytes beside its payload (240 calls of 40 bytes a second went out as 13.2 KB/s, 60 of 160 as 11.2), and the inbound budget charges each packet at least 128 bytes, so a client at 500 frames a second sending every frame spent the whole default budget on floors. At 60 frames or fewer nothing changes, and aStepReplicationthe game calls still sends at once. The same run found no loss, no warning and no ping rise from 60 to 480 packets a second on either remote, so there is no cap Roblox enforces to stay under.
0.40.0 — 2026-09-27
A release about speed and bytes, and a wire-format change: WIRE_VERSION is 3. Recompile both
modules. A 0.40.0 module refuses one built by an earlier release at startup, through the schema
signature. Every figure below is 0.40.0 against 0.39.0, the medians of three to five runs of each
interleaved on one machine, with the benchmark’s own mocks the same for both.
The benchmark gained the paths it had never run – benchmark/Scenarios.luau times a server sending
to fifty players, small unreliable events, streams and Instances, and benchmark/Rivals.luau runs the
same scenarios on upstream Blink, zap, ByteNet and Packet – and it had been timing interpreted code in
its native rows: a mock player or Instance was a table, and native code sends a function whose
parameter is annotated Player or Instance back to the interpreter when a table arrives. They are
userdata now, as in a game.
Faster
- A reliable
FireAllis written once into a broadcast batch and sent in oneFireAllClients, where it was copied into every player’s batch and flushed as a remote call each: with fifty players, 100 FireAlls a frame went from 3.8 ms to 0.035 ms, and the flush from 50 remote calls to 1. A player’s own events keep their order against the broadcasts around them. - One
pcalla packet on receipt rather than one an event, and none on a client’s reliableFire: a write that throws is undone lazily, by whatever touches the batch next. A one-byte event fires 42% and decodes 37% faster natively, 11% and 21% interpreted. - Readers hold the packet in a local,
Allocatekeeps its growth out of line, a Single listener is read once, the decode loop is made once a module, and the rate buckets refill once a packet. An array of structs decodes 6 to 12% faster,boolean[]fires 6% faster natively. - An unreliable send writes into a scratch buffer kept between sends; an unreliable
Fireis 18 to 40% faster. - A
Firecarrying an Instance counts the batch’s Instances only where it can add one: 40% faster natively, level interpreted.
Changed on the wire
- A length nobody bounded, or one whose span needs two bytes, is a varint: one byte under 128, two under 16384, three to 65535. A span past two bytes takes three bytes where it took four. The reader refuses a varint in more bytes than it needs and one past 65535, before anything is read for it.
- An enum’s index and a tagged enum’s tag are bits in the bitfield – as few as tell the values apart, at least one – where each took a byte.
- A stream carries no sequence number: it is ordered by the stamp every packet already carries, so a
stream to everyone goes out in one
FireAllClients, two bytes shorter. The streams to everyone due in a frame share their packets: fifty players’ stream flush makes 1 remote call where it made 50.
Added
option BatchUnreliablegathers a side’s unreliable events into as few packets asMaxUnreliableSizeallows, sent at the flush: eight small inputs a frame are one remote call instead of eight and decode 55 to 60% faster. An event waits up to a frame. It pays where a recipient gets several unreliable events a frame; where each gets one there is nothing to gather.
Fixed
- A client sent a frame’s reliable events as one packet whatever the server’s limits, so an honest
player who fired more than
MaxEventsPerPacket,MaxPacketSizeorMaxInstancesPerPacketin one frame – after a hitch, from a busy interface – was refused and reported to the packet-drop handler. The batch is now cut at event boundaries into packets the server takes. - A stream state too large for one packet was counted as sent and warned about at every step; it is
reported once and stops the stream until the next
Set. Builder.PushLinesdropped the line before a trailing empty one instead of the empty one.
Upgrading
- Rebuild both modules. Honest players no longer reach
SetPacketDropHandlerwithEventsorOversizedfor a frame of many events, so a game that strikes on those reasons now strikes only a client that wrote its own packets. Nothing else a handler receives changes.
0.39.0 — 2026-09-27
A client’s event can say when it was sent, and a stream can hold a state for each player. Both came
out of reading Roblox’s Server Authority work for what a game without it can still take: a server
that judges a hit where the player saw it needs the moment the player acted, and a server that
corrects one player needs a channel to that player alone. The wire format does not change for a
schema that uses neither: every other golden is byte-identical, and so is its signature. A schema
that adds a Stamp changes that event’s data and so its signature – Recompile both modules –
and one that adds Per: Player to a stream does not change what a client receives.
Added
Stamp: <milliseconds>on aFrom: Clientevent. The client’sFirewrites its estimate of the server’s clock (GetServerTimeNow, milliseconds modulo 65536) after the values, and the server’s listener receivesSentAtin seconds after them, held between the moment the event arrived and the window before it – a stamp from further back is the window’s start, one from the future is the present. Two bytes an event. A stamp on a server’s event, a polled one, a window outside 1 to 30000 ms, or beside an element already namedSentAtisE3034;UsePollingleaves a stamped event out, as it does a stream. See the new Stamps page.Per: Playerin a stream’s block.Set,Clear,UrgentandSetFasttake the player first, and each player’s state runs the scheduler on its own – its clock, change check, keepalive andFast– and is sent to that player alone. A player’s state is made on their firstSetorSetFastand dropped when they leave; aSetfor a player who has already left does nothing.
Changed
- The stamp unwrap a stream’s client used lives in the new
src/Templates/Stamp.luauand is shared with the server’s clamp, so a module with a stream carriesStampClock, where the server’s half readWorkspace. The emitted stream runtime also gains the per-player scheduler; a module whose streams are all broadcast never enters it. - An unreliable
Fireto one player and a per-player stream’s send are one generator,Send.One.
Upgrading
- Nothing changes until a schema opts in. Adding a
Stampis a schema change both modules are rebuilt for, and each stamped event’s listener gains a last argument.
0.38.2 — 2026-09-26
The benchmark figures in the README, the Benchmarks page and benchmark/Benchmarks.md are 0.38.1’s,
where they had been a single run of 0.36.2. Nothing the compiler emits changes, and neither does
the compiler: this release is the documentation.
Each figure is now the median of three runs, every tool measured in the same session. The single run they replace had read lower on some rows – EntitiesRandom decode 10.81 ms, against 12.83 now – and that is not a regression: the code a packet runs through is the same in 0.36.2 and 0.38.1, and loaded side by side into one process the two versions’ modules decode at the same speed. It is how far this machine moves between single runs, which is why a figure now takes three, and why the page says so. The Studio figures are still 0.36.2’s, and are labelled as such: that benchmark needs Studio in front of it.
Fixed
- The property test’s bound on reading corrupted bytes failed now and then late in the suite: a read whose slowest honest case takes 22 ms was clocked at 330, a garbage collection landing inside it. A read over the bound is now timed twice more on the same bytes and the fastest counts, so a loop the bytes do not bound still fails and a pause does not.
0.38.1 — 2026-09-26
The compiler is faster than 0.37.0 again. Measured against 0.37.0 on the same schema (0.37.0’s Test.blink, a thousand parses and five hundred generations of each side, medians of three runs interleaved), 0.38.0 had come out 0.7% slower to parse and 1.0 to 1.4% slower to generate – no one feature, each of the six added a little per call. 0.38.1 parses 2% and generates 3.3 to 3.7% faster than 0.37.0. Nothing it emits changes: every golden is byte-identical, schema signatures included, so a module built by 0.38.1 is the one 0.38.0 built.
The generated modules were measured too, and had not slowed: the code a packet runs through is the same as 0.37.0’s, and loaded side by side into one process the two decode at the same speed. An apparent 3% on Entities came from the benchmark generating each version’s modules in the process it then timed, where a larger compiler leaves a different heap behind.
Changed
- Each emitted line’s indentation is taken from a table built once per depth, where it was built with
string.repon every one of the twenty thousand lines a pair of modules has. - The schema signature hashes its two lanes in one pass over a buffer, with FNV’s multiply written as
Hash * 403 + (Hash << 24). It was two passes of per-bytestring.byteand five shifts, 0.7 ms of each module; it is 0.27. The result is the same bit for bit, checked on 100,000 random strings. ConsumeAny, which reads every identifier, compares the next token’s type in place rather than building a table of the types and callingTryConsumefor each.
0.38.0 — 2026-09-26
A check that committed output still matches its schema, a game can hear which player’s event set off
a listener that threw, a module can come up in a place where no server module runs, a game can see
what its own networking costs, event by event, a schema’s numbers say what the game had been saying
by hand around them, and an event can be a stream the server sends on a schedule. The wire format
does not change, and WIRE_VERSION stays 2. A schema that uses none of this compiles to the same
modules as 0.37.0 apart from the new members, and a module built from it talks to a 0.37.0 one built
from the same schema. The schema signature changes only where a number gains a scale or an event
becomes a stream: recompile both modules of such a schema.
Added
--verifycompiles in memory exactly as a real build would – the same output paths, the same--profile– writes nothing, and compares every file it would write with the file on disk, byte for byte. It exits with0when all match and1when any is stale or missing, printing each one with the first line that differs, even under--quiet. A game that commits its generated modules used to check them by regenerating into the working tree and asking git for a diff, which rewrote files under the caller and needed a repository.- Only the files the compiler would write are compared. The compiler never deletes in an output folder, so another file there is not an error.
- It implies
--check: no module, no output directory, never a prompt. With--watchit is refused. - Under
--jsonthe document gains averifyfield, one{ file, status, line }per file, withstatus"current","stale"or"missing";successisfalsewhen any is not current. The field is present only under--verify, and the format’sversionstays 1.
SetListenerErrorHandler(Handler?)on the server module. A server listener that threw was caught – aSyncone by the pcall that keeps the rest of the packet decoding – and its error raised with nobody named, so a game that wanted the player and the event wrapped every listener in a pcall of its own. The handler is called as(Player, Event, Failure, Count):- It hears every server listener: an event’s,
SyncorAsync,SingleorMany, reliable or unreliable, live, throughPredict, or replayed from the queue when.Onconnects; and a function’s, including a return value its serialiser refuses. The function’s caller is still answered with a failure. A polled event has no listener, so nothing there is covered. Failureis the error, as a string.Countis how many errors the call stands for: a modified client can make a listener throw on every packet, so it is called at most once a second per player and event, on the schedule every other report keeps – the first error at once, the rest of that second counted into the next call. A player who has already left is reported at once withCount1.- It runs on a thread of its own, so one that yields or throws does not disturb decoding.
- With a handler installed the server prints none of those errors. Without one, nothing changes: a
Synclistener’s error is raised on a thread of its own, anAsyncone’s on the thread it ran on, and a function’s is the same"Name" encountered an error, ...warning. - The client module has it too, accepted and ignored, as it has
SetRateLimitHandler: a client’s listeners run what the trusted server sent, so their errors stay in the output. It is stubbed in edit mode, declared in the TypeScript output, followsCasing(setListenerErrorHandler,set_listener_error_handler), and is a reserved top-level name (E3005).
- It hears every server listener: an event’s,
option ClientConnectTimeout = <seconds>, any number above zero. The client module waits at most that long for the remotes. When they do not come, requiring it still returns, with the edit-mode stubs in place of the API –Firedoes nothing, listeners are never called,Invokefails at once withThe client is not connected to a server– andConnectedfalse. When they come,Connectedis true. Remotes that appear after the timeout are not picked up: the module is already cached, and the game reloads to try again.Connectedexists only under the option; without it the client waits for as long as it takes, as before.option AutoStart = false: requiring the server module creates no remote and connects neither the remotes norHeartbeat.Start()does all three, and a second call does nothing. Before it,Onand the handler setters register as usual; aFireis dropped, not queued, and the first one warns once (Something was sent before Start() was called, and was dropped.); anInvokeof a client fails at once instead of waiting outInvocationTimeout. The client module ignores the option.Remoteson both modules and their stubs:{ Reliable = "<scope>_BLINK_RELIABLE_REMOTE", Unreliable = "<scope>_BLINK_UNRELIABLE_REMOTE" }, so no game spells the names out.- All three follow
Casing(remotes,start,connected), are stubbed in edit mode, are declared in the TypeScript output where they exist, and are offered by the Studio plugin’s option completion. Both options may sit under@profile. option TrafficStats(defaultfalse). With it on, the server and client modules exportGetTrafficStats()andResetTrafficStats(). Roblox reports only the game’s total (Stats.DataSendKbps), which cannot say how much of it is BlinkBlox’s or which event costs most.GetTrafficStats()returns{ Events, Reliable, Unreliable }, each count{ Sent, SentBytes, Received, ReceivedBytes }, totals since the module started or since the lastResetTrafficStats(), in a fresh table on every call.Eventsis keyed by the event’s path ("Inner.Poll"inside a scope); functions are counted inReliableonly. The fields followCasing.- An event’s bytes are those in the buffer: its index, the sequence number of an ordered event, and its payload. The channels count packets and whole buffers.
- The server counts a send once per recipient –
FireAllonce per player in the game, also for an unreliable event sent throughFireAllClients– since each of them is sent the bytes. - An event is counted as received once it is decoded, before its rate limit. A packet refused before decoding is not counted.
- The counting is integer additions into one table allocated when the module loads, at indices
fixed at compile time; nothing is allocated and no string is looked up per event. An event of a
single size counts only events and has its bytes multiplied out when read. Measured with
lune run Runtime -- --traffic, natively compiled, the best of several runs: fire within 2% of the same build without it, decode within 5%. - Declared in the TypeScript output, present in the edit-mode stub (reporting zeros), completed
by the Studio plugin, and
GetTrafficStats,ResetTrafficStats,BLINK_TRAFFIC_COUNTSandBLINK_TRAFFIC_STATSare reserved (E3005) whether the option is on or not, so turning it on never starts refusing a schema.
benchmark/Runtime.luautakes--trafficto build BlinkBlox with the option, to price it.- Fixed-point numbers: an integer type takes a scale,
u16<0.1>, and sends the nearest whole number of steps,round(v / 0.1), in that integer, read back as the count times the step.12.34in au16<0.1>sends 123 and reads12.3– the exact double the literal is, since a step whose reciprocal is whole is sent asround(v * 10)and read asn / 10. Every integer takes one (u8tou32,i8toi32, the 24-bit ones included). The range is in real units,u16<0.1>(0..6553.5), and each bound must be a whole number of steps (E3009). Rounding is to nearest, a tie away from zero. Past the range it is refused on send underWriteValidations, in real units, and wraps as its integer would without it; the receiver checks it in whole steps. It costs its integer’s size, and is anumberin Luau and TypeScript. The scale is part of the schema signature. saturate,u8<saturate>(1..255),u16<0.1, saturate>: a number is clamped into its range on send instead of refused or wrapped – an integer rounded to nearest first, anf16stopping at 65504 instead of becoming infinity.NaNis sent as the value in range nearest zero (0 when the range holds it), so it neither reaches the wire nor makes a ranged receiver refuse the event. It changes nothing a receiver accepts, and is not part of the signature.saturateis not a keyword: a field may still be called that.option ExportLimitsadds a frozenLimitstable to the server, client and types modules, and areadonlydeclaration to the TypeScript output: every numeric range, scale, string, buffer and array length and vector magnitude the schema declares, keyed by the schema’s own names –Limits.Snapshot.Units.Length.Maxis 16. The structural words followCasing. With the option on,Limitsis a reserved top-level name (E3005); off, nothing is emitted.E3032refuses a scale on a float, a scale of zero or less, a repeated scale orsaturate, and anything else in a number’s brackets.- The Studio plugin offers
saturateinside a number’s brackets, and the docs’ grammar highlights it. test/Numbers.luauandtest/NumberLimits.luaucheck exact bytes and values;test/Oracle.luauandtest/Generate.luaulearnt both modifiers, so the property draws cover them.Stream: { Rate, Fast?, Keepalive?, Epsilon? }on an event (language/streams). The server module getsSet(Value),Clear(),Urgent()andSetFast(Fast)in place of the Fire family; the client’s listener gets(Value?, ServerTime). The scheduler runs insideStepReplication, before the reliable flush, and sends the latest state to every player atRatea second (orFastafterSetFast(true)), keeping the clock’s overrun so ten a second is ten at sixty frames:- the first state after the stream was idle goes at the next step, and
Urgent()sends at the next step and restarts the interval; - a state the same as the last one sent is skipped – numbers within
Epsilon, a vector, Color3 or CFrame per component, tables key by key all the way down, buffers byte by byte – except the first unchanged state after a change, which goes once so the clients learn the thing stopped; - an unchanged state is sent again every
Keepaliveseconds, 1 by default; Clear()sends nil once, if the clients saw a state, and the stream is silent until the nextSet;Set(nil)throws;- each packet carries the server’s clock in milliseconds modulo 65536, two bytes, and the client
unwraps it to seconds nearest its own
GetServerTimeNow(); - a state that fails to send is reported once and stops the stream until the next
Set; - interpolation is left to the game: the stream hands each state its server time, and what to buffer and how far behind to draw depends on what is drawn.
- the first state after the stream was idle goes at the next step, and
- The size analysis counts a stream’s state in full, though it travels as optional: one that can never
fit beside the 6-byte header is
E3018. E3033: a stream that is notFrom: ServerandOrderedUnreliable, is polled, streams an optional, a type pack or a type that cannot be optional, or has aFastno faster thanRate.- TypeScript declarations, edit-mode stubs and
Casing(set_fastunderSnake) for the four members;BLINK_STREAMis a reserved type name. The Studio plugin completes theStreamblock’s fields, on its own lines or on one.Streamis a field likeRate, so highlighting needs nothing. - A guide,
guides/streaming-state, replacing a hand-rolled send loop with a stream. - Where the features meet: under
TrafficStatsa stream’s scheduled sends are counted as aFireListto every player is, once per recipient, under the stream’s name inEventsand on the unreliable channel; underExportLimitsa stream’sLimitsentry holds the bounds of the type it streams, not of its wire pack; a scaled number in a stream’s state is compared against the last one sent in real units, soEpsilonis in the schema’s units too; and underAutoStart = falsethe scheduler, which runs insideStepReplication, does not run beforeStart()connects the Heartbeat – a game that steps replication itself before then has its stream sends dropped, as aFirewould be.test/Crossings.luauchecks each of these, andtest/Sources/Crossing.blinkputs them all in one schema in front of the goldens and the type gate.
Changed
- An
Asynclistener on the server, live or replayed from the queue, is called throughRunListener, which calls it bare when no handler is installed. A replayedSyncone is guarded as on receipt, which 0.37.1 made true on both sides. An event, function, scope or type may not be namedSetListenerErrorHandler, and a type-pack element may not be namedListenerFailedorRunListener. - A top-level event, function, scope or exported type named
Remotes– in the spelling the casing gives it – is refused (E3005), as areStartunderAutoStart = falseandConnectedunderClientConnectTimeout. A plain type may keep the name. A schema that used one of these names has to rename it.
Upgrading
A game that built these for itself can drop them once it is on this release:
- A drift check that regenerates the modules and diffs them: run
blinkblox <schema> --verifyinstead, with the same--profilethe build uses. - A pcall around every listener to log the player and event: install one
SetListenerErrorHandler; the report arrives already bounded to once a second per player and event, with the count. - A hard-coded
<RemoteScope>_BLINK_RELIABLE_REMOTEand a wait of its own for the server: setoption ClientConnectTimeoutand require the client module directly, checkingConnectedwhere it matters. Anything that still needs a remote’s name readsRemotes. - Lazy requires of the server module: set
option AutoStart = false, require it at the top like any module, and callStart()in the real game’s bootstrap only. - Guessing which event costs what out of
DataSendKbps: setoption TrafficStats = trueand readGetTrafficStats()on the server to split it by event. - Numbers scaled by hand – tenths sent as
x * 10, counts clamped withmath.min(n, 255): declare themu16<0.1, saturate>oru8<saturate>(1..255)and send the raw values. A scale changes the schema signature andsaturatedoes not, so both modules are rebuilt together, as after any schema change.saturatesends NaN as the value in range nearest zero, wheremath.clamppassed it through. - A comparison against a length cap copied out of the schema: set
option ExportLimits = trueand compare with the module’sLimits. - A snapshot sent by hand on a timer, with a stamp of its own: declare the event a
Stream. Two behaviours may differ from a hand-written one: “still” is per axis rather than by distance, and any change is followed by one unchanged send. Clearing the stream arrives as nil rather than an empty list. WithTrafficStatson, its sends are counted under the event and on the unreliable channel.
No handler signature, Reason or refusal route changes in any of this.
0.37.1 — 2026-09-25
Two listener fixes, found by setting this fork’s dispatch beside the Luau signal libraries
(stravant/goodsignal, jiwonz/luau-signal) and Roblox’s own reactive signals. None of them was worth
taking as a library: goodsignal’s reused runner thread is what Spawn has done since 0.23.0, and
signals is reactive state, not events. The wire format and the schema signature do not change;
regenerate to pick the fixes up.
Fixed
- A queue replayed to a
Synclistener was unguarded. WhenOnconnected, each event queued before it was handed to the listener in a bare call, where one arriving later is caught and reported on a thread of its own. A listener that threw on a queued event threw out ofOnafter it had been bound: the caller never got its disconnect, the rest of the queue was never delivered, and the nextOnwas handed the same queue again. The queue is now taken before the first event is replayed, and each call is guarded as on receipt. - A listener disconnected during a dispatch was still called. A
Manyevent’s dispatch walks the list it started with, which is what makes a self-disconnecting listener safe; it also reached a listener that an earlier one had just disconnected. It is now passed over, asRBXScriptSignaldoes. The check costs one comparison per listener until something disconnects mid-dispatch. A listener that disconnects a later one of the same event now stops that one receiving the event in flight, where it used to receive it once more.
0.37.0 — 2026-09-25
A game can act on every packet the server refuses. The wire format does not change, and neither
does the schema signature: a 0.37.0 module talks to a 0.36.x one built from the same schema, and
WIRE_VERSION stays 2.
Added
SetPacketDropHandler(Handler?)on the server module. A packet the server refuses before it decodes it, or cuts short at the event cap, used to be a warning and nothing more: the output could see the player, the game could not, so a modified client could be read about and never struck or banned. The handler is called as(Player, Reason, Refused, Detail):Reasonis"Malformed"(the remote’s arguments are not a buffer and a table),"Oversized"(overMaxPacketSize),"Budget"(over the inbound byte budget),"Instances"(overMaxInstancesPerPacket) or"Events"(MaxEventsPerPacketevents decoded, the rest dropped).Refusedis how many packets of that reason the call stands for. It is called at most once a second per player and reason, on the schedule the warnings already kept.Detailis the packet’s length in bytes forOversizedandBudget, its instance count forInstances, the events decoded forEvents, and 0 forMalformed.- It runs on a thread of its own, so one that yields or throws does not disturb decoding.
- With a handler installed those warnings are not printed, and
Budgetgoes to it instead of to the rate-limit handler. Without one, nothing changes: the same warnings, word for word, andBudgetstill reachesSetRateLimitHandlerwithEventnil. - The client module has it too, accepted and ignored, as it has
SetRateLimitHandler. It is stubbed in edit mode, followsCasing(setPacketDropHandler,set_packet_drop_handler), and is a reserved top-level name (E3005).
MaxInstancesPerPacketmay be 0, for a game whose clients send no Instance: the server then refuses any packet carrying one before reading it. Every other numeric option is still at least 1.E3031: withMaxInstancesPerPacket = 0, a client-sent declaration that carries anInstanceorunknownis refused, since none of its packets could arrive. Client-sent means the data of aFrom: Clientevent or function, and the return of aFrom: Serverfunction, which the client sends back on the same remote. An offending declaration in an imported file is reported at itsimport.- The Studio plugin’s editor completes more than keywords and types.
optionis offered at the start of a line, followed by every option’s name and, where the language fixes it, its value. Inside aneventor afunctiona line starts with the fields it takes and has not been given yet, and each field is followed by the values it accepts, where before every:offered the primitive types.@profileis offered at the start of a line, and a profile inside it.
Performance
- The editor’s completion no longer splits the whole document on every keystroke to find the line
under the cursor; it reads that line alone. The declaration around the cursor is found by reading
back from it with plain
string.find, only as far as the nearesteventorfunction. On a 2400-line schema a keystroke’s completion went from 0.25 ms to 0.01, and inside a 2000-field struct from 0.48 ms to 0.03.
Fixed
- The TypeScript declarations had no
SetRateLimitHandlerorSetDecodeErrorHandler, so a roblox-ts project reached them through a cast. They are declared now, besideSetPacketDropHandler, in the module’sCasing. --helpsaid--compactprints the full message after the compacted one. It prints only the one line.- Under
option Casing = CamelorSnake, a top-level event, function or scope named in that spelling of a module member –stepReplication,set_packet_drop_handler– compiled and replaced the member in the returned table, since the reserved check compared thePascalspelling only. And ascope StepReplicationpassed even underPascal, since scopes were never checked. Both areE3005now; a name in some other casing’s spelling still compiles, since it collides with nothing. - A top-level type named
Entry,Queue,BufferSave,InvocationsorDropReasonredefined a type alias the runtime declares for itself, and the generated module failed its own--!strictcheck. The runtime’s aliases are spelledBLINK_*now, and a type of one of those names isE3005.
Changed
- The server’s limits – rate buckets, calls in flight, the inbound budget and how refusals are
reported – moved from
src/Templates/Server.luau, which had reached the 500-line cap, intosrc/Templates/Limits.luau. Apart from the new handler, the generated module holds the same code, in a different order.
Documentation
- The docs, the README and the llms files were checked against every release from 0.26.0 on. What
they had wrong: a malformed event ending only itself (it ends its packet),
InboundBurst’s default and minimum, the open side of anf16orf24range,--jsonon the runs that print plain text, the namesE3005covers, and the releaseE3030arrived in. What they had left out:Concurrencyin the server’s check order and the rate-limit handler, send-side length checks below a minimum,f24’s subnormals, trailing commas and strict modules. The version strings, the benchmark tables and the wire table are brought up to date. llms-full.txtandllms-small.txtfollow the sidebar’s order, with the benchmarks and the changelog last in the full file and left out of the small one. The small one keeps its notes, which carry rules, andllms.txtcarries a short brief.
0.36.2 — 2026-09-25
The wire format does not change.
Changed
- A
boolean[]is written faster, most of all when its elements vary. The writer loads a byte’s eight elements into locals before it branches on any of them, and underWriteValidationschecks their types in that same pass instead of a loop of its own. On the benchmark’s thousand arrays of a thousand random booleans a frame, natively compiled, a frame’s fires went from 13.9 ms to 8.2; with the same array every time, from 3.9 to 3.3. Interpreted, 27.6 to 25.4 on random data, and the same on constant data. The validated writer measured 17.4 against 9.7 on random data. What the M1 run showed as a slowdown on random data was branch misprediction, not memory: a pass that only reads the elements costs the same on both. The forms without a branch –v and k or 0, a{ [true] = 1 }lookup – measured no faster or slower. The reader already did not depend on the data, and a lookup table there was three times slower natively, so it stays as it was. These timings are from the run the change was developed against, not the published M1 run;benchmark/Benchmarks.mdhas the published numbers.
Benchmarks
- Blink 0.18.9, the last release of the original project BlinkBlox forked from, is benchmarked beside it, compiling the same schema with its own compiler, in Studio and on Lune.
- The tables name the tools Roblox remotes, BlinkBlox, Blink, zap, ByteNet and Packet, always in
that order, where they had printed the harness’s ids (
blink,upstream) in no fixed order. lune run Runtimetimes zap, ByteNet, Packet and Blink as well as BlinkBlox, natively compiled and interpreted, and times the server decoding what they sent, which Studio does not. Each row also gives the encoder’s bytes per event and those bytes after zstd.- The Studio harness reports bytes per event instead of Kbps. The old column sampled
Stats.DataSendKbpsonce a second and scaled it by 60 / FPS, which read low for every tool below 60 FPS: Packet’s EntitiesRandom came to 486 bytes an event, under the 600 bytes of random data in it. The published numbers were retaken on an Apple M1 with both harnesses.
0.36.1 — 2026-09-25
The wire format does not change.
Fixed
--helpnamed the executableblinkblox.exeon every platform. Only the Windows release has the extension; the usage line now names the one the running platform has.
0.36.0 — 2026-09-25
The numbers 0.35.0 left open, measured on a Mac with Studio: the unreliable payload limit, and the Studio benchmark against a fifth tool. The wire format does not change.
Changed
MaxUnreliableSizedefaults to 980, up from 900, and counts 6 bytes for each Instance an event sends beside its buffer.benchmark/Probe.luaumeasured what Roblox delivers, the same in both directions: a buffer alone arrives up to 994 bytes, and each Instance beside it costs exactly 6 bytes more (988 with one, 970 with four, 898 with sixteen). The old 900 was a guess that left 100 bytes for the rest. Counting the buffer alone was not safe either way: sixteen Instances beside a 900-byte buffer are 1002 bytes, which Roblox drops. The compile-time check (W3019) and the check on send both count Instances now, and the warning a dropped send prints gives the counted size.
Benchmarks
- The Studio benchmark includes Packet, 1.7.0, beside plain remotes, zap and ByteNet. Packet’s server drops what a player sends past 8000 bytes a heartbeat, with no option to change it, so the harness raises that constant in its downloaded copy, as the bench’s schema raises BlinkBlox’s own inbound limits.
- The published Studio numbers are from 0.35.0 on an Apple M1, with the random benches and the fire time. BlinkBlox is at 60 FPS on three benches and 52 on the fourth; zap is between 22 and 37, and ByteNet, Packet and plain remotes between 15 and 18. A frame’s thousand fires took BlinkBlox 4 to 15 ms, zap 21 to 35, ByteNet 38 to 45, Packet 50 to 77 and plain remotes 28 to 110.
- The Lune tables are re-measured on the same M1, 0.34.0 and 0.35.0 back to back.
lune run build --localcompiles the bench’s schema with the checkout’s compiler. The download fetches the latest release, which had been 0.33.0 while 0.34.0 and 0.35.0 were merged.- The harness’s disabled Warp mode is removed. Its download had never worked, and bundling it printed a warning on every build.
Documentation
- The site builds with no warnings, down from 25. Starlight’s i18n collection is declared, the 404
page is the site’s own (
docs/src/pages/404.astro) rather than a lookup that warned on every build, and Vite 8’s notice about Astro’s ownastro:head-injectdirective, one per MDX page, is filtered by exactly that code and text.
0.35.0 — 2026-09-25
Performance, measured before it was changed, and a benchmark that measures the right thing. The wire format does not change.
Changed
The generated code is faster on both sides. Each change was kept only if benchmark/Runtime.luau
showed it was faster. Medians for 1000 events a frame, natively compiled, 0.34.0 then 0.35.0, from
the run the changes were developed against; the published M1 numbers are in
benchmark/Benchmarks.md:
| Bench | Fire (ms) | Decode (ms) |
|---|---|---|
boolean[0..1000] |
11.9 → 5.5 | 21.7 → 6.5 |
100 structs of six u8 |
4.6 → 3.9 | 73.3 → 17.0 |
one u8 |
unchanged | 0.43 → 0.28 |
Interpreted code, which is what most clients run, fires and decodes boolean[] between 1.9 and 2.6
times faster, and decodes structs between 1.8 and 2 times faster.
- A send buffer that grew past 4 KB was let go at every flush. The senders that most needed their buffer kept, the heavy ones, regrew it from 64 bytes every frame instead: 23 allocations and copies a frame at 600 KB. A buffer is now let go only after 60 flushes in a row that each used under a quarter of it. A reliable send on the server updates the player’s saved buffer in place instead of allocating a new one.
boolean[]is packed and unpacked a byte at a time, with eight elements unrolled against constant masks. It used to be a bit at a time, with a modulo, a shift and a branch for every element, and the reader appended each one withtable.insert.- A decoded struct is created with a slot for each field its reader always assigns. It used to be
created as
{}and then filled in, which rehashed the table four times for six fields. - The function that decodes one event is made once per packet. It used to be a new closure for every event.
- The compiler parses ten times faster: 166 ms to 12 ms for the test schema. Substituting a generic argument copied the scope the template was declared in, and with it the symbol table of the whole schema, once for each argument. The lexer tried every rule at every token. It now tries only the rules that can match the token’s first byte. Its output is unchanged, checked over every schema in the repository and 3000 random inputs. Lexing went from 9.5 ms to 3.2 ms.
Fixed
-
A wide struct generated a module that failed to load with “Out of local registers”. Luau allows a function 200 locals, and every local a serialiser declared for a field stayed live until the function returned: a struct of 50
CFramefields, about 200 strings or arrays, or about 66vector<f16>orvector<f24>fields was enough. Each field’s locals now end with the field, and a bitfield that several fields share is declared at the top of its block. The half-float and 24-bit float readers also shared one local per scope instead of one per value. A struct of 240 fields of every such kind now loads and arrives intact, alone, in an array and in a map (test/Locals.luau). -
The Studio plugin’s editor leaked memory on every parse of a schema that uses
@profile. The editor keeps one parser for the document, and the parser never cleared its record of excluded declarations. Each record held its whole tree, so every parse kept the one before it alive: 16 MB a parse of the test schema. -
In the same editor, a parse that failed inside an attribute left that attribute open. The next parse started inside it: if
@profile("dev")was half-typed, the declaration after it was excluded. -
Every page and comment said Roblox drops an unreliable payload past “roughly 900 bytes”. Roblox documents 1000, and counts every argument of the call as Roblox encodes it, while
MaxUnreliableSizebounds BlinkBlox’s buffer alone. The default stays 900, which leaves 100 bytes for the encoding, untilbenchmark/Probe.luaumeasures what the encoding really costs in Studio.
Benchmarks
benchmark/Runtime.luautimes the generated code without Roblox. It separates a frame’s fires, the flush and the server’s decode, runs the code both natively compiled and interpreted, and reports the bytes each event takes on the wire.- The Studio benchmark was measuring Roblox’s compression more than the encoding. It sent the same
data in every event, which Roblox’s zstd compression shrank to almost nothing.
BooleansRandomandEntitiesRandomnow send a thousand different payloads a frame. - A new column records the CPU time a frame’s fires took. Studio’s 60 FPS cap does not hide it.
- The harness runs on macOS and Linux as well as Windows. It no longer depends on
wmic, 7-Zip orbuild.bat:lune run buildreplaces the batch file. benchmark/Performance.luauhad been timing the rendering of the test schema’s warnings on every parse. It now warms up and reports p99.
0.34.0 — 2026-09-25
Three 24-bit number types, the one idea worth taking from reading 5uphi’s Packet library. No existing type’s encoding changes; only schemas that use the new types are affected.
Added
u24(0 to 16777215) andi24(-8388608 to 8388607) take three bytes, low byte first. Without a range they wrap on send asu16andi16do.f24takes three bytes: a sign, six bits of exponent biased by 31, and seventeen of mantissa, with subnormals, both infinities, NaN and-0. It keeps 18 significant bits wheref16keeps 11, and it rounds to nearest wheref16truncates. Between 512 and 1024 a value arrives within 0.002 of itself, so it is the width for world positions:vector<f24>is 9 bytes wherevectoris 12, andCFrame<quat, f24>is 16. Its largest finite value is 4294950912; anything that rounds past it is infinity.- A range on
f24is checked on receipt against its bounds as they arrive, asf16andf32ranges are. Rounding can move a bound either way, andf24(0.1..0.7)accepts both. - Packet’s own
f24gets both edges wrong. A mantissa that rounds up to 2^17 is taken modulo the field, so 1023.9999999 arrives as 512. A value below its smallest normal writes a negative exponent, so 1e-10 arrives as about 1.8e9. Here the carry lands in the exponent, and the subnormals are encoded.
Fixed
- The Studio plugin’s completion listed primitives of the same length in whatever order the settings
table happened to iterate. They now go in name order after length, so
uoffersu8, u16, u24, u32.
Tests
test/Floats24.luauchecks the f24 patterns against a decoder written from the format, not from the compiler (Generate.Float24FromBits). It checks every pattern of the subnormals, the smallest normals,[1, 2)and the largest binade, and the edges and a seeded sample of every other binade. Each value must read as the format says and write back to itself. A value a quarter of the way between two neighbours must arrive as the lower one, and three quarters as the upper one. It fails with Packet’s dropped carry put back, and with the subnormals left out.- The property specs draw and judge
u24,i24andf24. The oracle decides whatf24holds exactly without using the compiler’s code, and holds a narrowed value to half a unit.
0.33.0 — 2026-09-24
A limit on the calls a server runs at once, and tests for the seams between events. The wire format does not change.
Added
Concurrency: Non a function caps how many of one player’s calls the server runs at once.Ratelimits how many calls start each second. A listener that waits (a DataStore save, an HTTP request, an invocation back to the client) holds each call open while it waits, so calls arriving at an allowed rate still piled up into suspended threads. A client sending its own requests is not bound by the 32 outstanding calls an honest client module keeps to.- A call past the limit is answered with a failure at once.
- It is reported through
SetRateLimitHandlerand a warning, once a second per player. - The place is given back when the call is answered: returned, thrown, or failed to serialise.
- A call queued for a listener that has not connected counts as running.
- The counts go when the player leaves.
- A fractional value is refused (
E2003), andConcurrencyon aFrom: Serverfunction warns (W3020).
Tests
-
test/Composition.luaufires random sequences of client events into one packet, using a schema of its own built on isolated remotes (test/Isolated.luau). It checks four things:- the listeners receive exactly the sequence that was fired;
- the packet is the events’ standalone packets laid end to end;
- a fire the sender refuses leaves the packet byte for byte unchanged;
- a packet cut short delivers every event that ended before the cut, then reports once, naming the event it was cut inside.
It catches the 0.24 phantom event and the 0.29 closed-up holes when either is put back.
-
test/EventIndices.luausends every one of the 256 index bytes, alone, on both channels of every test schema’s server. A declared index must be taken as its declaration, and any other index must be reported exactly once as unknown. It catches the 0.26 channel that never refused when that bug is put back. -
test/Concurrency.luaucovers the limit, and checks that a failing call gives its place back.
0.32.0 — 2026-09-24
The generated modules pass their own --!strict line. The wire format does not change.
Fixed
- Every generated module declares
--!strict, and none had been type-checked. The test schemas’ output carried about 190 strict errors, which a game’s editor showed in a file its owner cannot edit. They came from a few repeated lines:- a
pcallof a writer that returns nothing, destructured into two locals; - a polled event’s iterator ending in a bare
return; - an
Instancecompared with nil where its type does not allow nil; - an exported enum’s
Readreturning a value thatpcallhad widened tostring.
- a
- An empty
FutureLibraryorPromiseLibraryemittedrequire(), which fails to load. An empty path now counts as no path. - A Future or Promise library is required only by a module that invokes through it. The side that answers such a call never uses it, so the require was an unused import there.
Language
- A type named after a built-in Luau type (
number,string,boolean,buffer,thread,any,unknown,never) is refused,E3005. It was exported asexport type number = number, which Luau does not allow. - A top-level type named after a Roblox type the module uses is refused,
E3005. That coversPlayer,Instance,RemoteEvent,UnreliableRemoteEvent,CFrame,Vector3,Color3,DateTime,BrickColor, and any class the schema names inInstance(...).type Player = Instance(Player)exported a type that referred to itself and shadowed everyPlayerparameter in the module. A type inside ascopeis exported with the scope’s name in front, so it is not affected.
Tests
- The type gate analyses
test/Golden, the output of every test schema, as a third contour. test/HalfFloats.luaureads all 65536 f16 bit patterns and checks each against an IEEE decoder written independently. It also writes every value midway between two neighbours and checks it lands on one of them. Putting back the 0.29 carry bug or the 0.31 signed-zero bug makes it fail.
0.31.0 — 2026-09-24
Property-based tests for every serialiser, and what they found. The wire format does not change: modules from 0.27.0 onward still talk to each other.
Fixed
- A 257th declaration on a channel was sent as the first. Each channel numbers its declarations
with one byte, and nothing refused the 257th, so the receiver decoded it as a different event. It
is now a compile error,
E3030. Imports count toward the limit, and declarations a profile leaves out do not. f16lost the sign of zero: -0 arrived as +0. It is now written as0x8000, which an older reader still decodes as 0.- Under
WriteValidations, a fixed-length array longer than its length was cut short on send instead of refused. WithoutWriteValidationsit is still cut short, as an exact-length string is. - A function or event whose data is an empty type pack,
Data: (), read an undeclared global in its reader: nil at runtime, but a type error in the--!strictgenerated module.
Tests
- Every exported type of the test schema is drawn at random, 100 times, into two builds: one with
WriteValidationson and one with the defaults. Draws lean on the edges: a range’s own bounds, lengths of 255 and 256, f16 subnormals, NaN and -0. - Each draw must be written into the size the compiler’s analysis allows, read back as itself, and re-written to the same bytes. The encoding must also be impossible to decode one byte short.
- Corrupted bytes must either fail to decode, or decode to a value the schema admits and the sender would write.
- A value the receiver would not accept, such as a length past its bound or a float that needs
rounding, may be refused. What is written anyway must be read to its last byte, and under
WriteValidationsmust arrive as what was sent. - The tests catch each of three earlier serialiser bugs when it is put back into the compiler: the
0.28.0 length prefix that wrapped, the 0.29.0 f16 carry, and the 0.29.0 holes in optional arrays.
BLINKBLOX_SEEDreplays or varies the draws.
0.30.0 — 2026-09-24
Tooling for the programs that read the compiler’s output: editor tasks, CI steps and coding assistants. The wire format does not change.
Added
--checkruns the whole compile and writes nothing: no modules, no output directories, no prompt. The diagnostics and exit code are those of a real compile. It combines with--watch.--jsonprints the result as one JSON document on standard output and nothing else: every diagnostic with its code, name, file, line, column and byte offset, its labels and its notes, plus the paths written. A failure that is not a diagnostic, such as a missing schema file, is reported in the document too. It cannot be combined with--watch.
Fixed
- A diagnostic named the schema by its bare file name, and one inside an import by the string the
importwrote, relative to whichever file wrote it. Both now name the file by its path, normalised. - The lexer’s one diagnostic, an unexpected character, named the file
input.blinkwhatever it was called. - A schema without
ServerOutputorClientOutputwas reported with the compiler’s own file and line in front of the message.
Documentation
- The syntax highlighting knows
OrderedUnreliable,quat, attributes such as@profile, and decimal numbers. - The promise of an MCP server is withdrawn.
--check --jsongives an assistant what such a server would have.
0.29.0 — 2026-09-24
A runtime audit, made possible once 0.28.0 had split the runtime into files small enough to read whole. The wire format does not change.
Fixed
- A refused or failed invocation reply desynchronised everything batched after it. The caller read the success flag inside the payload’s block, so the cursor had already moved past a payload that was never sent, and a pcall around the read hid the error.
- An invocation is now identified by its id and its function. Ids go round the whole u8 before one is reused, and each call’s timer is cancelled when the call settles. Previously an earlier call’s timer could fail a later one, a late reply could resume the next call, and on the server a client could answer one function’s call with another function’s value type.
- A failed server fire left its Instance in the player’s batch, shifting every instance after it.
- A failed exported
Writelost the queued batch. - A queued event lost every value after a trailing nil.
- A listener that disconnected itself cost the next listener that event.
- A
Synclistener’s error was reported as the sender’s decode failure. OrderedUnreliableaccepted a stale packet across the wrap, starved a player left out of the server’s sends (the server now counts per player), and never freed what it kept.- An optional array’s holes closed up.
- f16 NaN decoded as -65600, and a subnormal lost its carry.
- A float range refused its own bounds once narrowed on receipt.
- A type-pack element named
Lengthshadowed the string writer’s local. - An enum with more than 256 values wrapped.
Hardened
- A polled event’s queue is capped on the server.
- A client can no longer put a line in the output, or a call into the game’s decode handler, for every packet it sends. Both are rate-bounded.
Language
- Repeated flags, values and variants are refused.
- An enum with no values or variants is refused (
E3029). - A
Burstbelow 1 is refused (E2003): an event spends one whole token, so such a bucket refused every event. - A type-pack element named after a name the generated
FireorInvokealready uses, such asPlayerorBuffer, is refused (E3005). - The TypeScript tag is quoted.
- A trailing comma is accepted in every list.
0.28.0 — 2026-09-24
The fork is renamed BlinkBlox. This changes what the tools print and what the release artifacts
are called, not what a game depends on. The remotes, _G._BLINK, the plugin’s Blink output folder,
ServerStorage.BLINK_CONFIGURATION_FILES and the .blink extension all keep their names, so builds
from either side of the rename still talk to each other.
Fixed
- A length prefix wrapped on send. With a lower bound of 0 and
WriteValidationsoff (the default), a 300-byte value instring(0..64)went out with a length of 44 and all 300 bytes behind it. The receiver then decoded the rest of the packet from the wrong offset. The same held for buffers, arrays, unbounded lengths and a map’s count. The upper bound is now checked on send, whatever the options say. Predicton a reliableManyevent dropped the event when no listener was bound, where the network would have queued it.- A field, flag or tag named after a Luau keyword produced a module that did not load.
- The Studio plugin’s editor never showed a warning, because the same parse set it and cleared it. It also printed every warning to Output on every keystroke.
- A map’s size diagnostic had nothing to underline.
- The plugin’s file search treated the query as a Lua pattern.
- Exact-bound errors read “to equal to”.
Changed
- Luau files are capped at 500 lines with no exceptions. The parser, the generator, the prefabs and the plugin editor were split to fit.
0.27.0 — 2026-09-24
Ideas taken from reading ByteNet-Max, Warp and satset. Recompile both modules (WIRE_VERSION 2).
Added
- An inbound byte budget per player. Set it with
option InboundBytesPerSecondandoption InboundBurst. Each server connection charges a per-player token bucket before it decodes anything. A packet costs its size, and never less than 128 bytes. The burst is a whole second, because after a hitch Roblox delivers the backlog at once, and a refused reliable packet takes every event in it along. Refusals go to the rate-limit handler withEventnil. AnInboundBurstbelowMaxPacketSize, or below 128, is refused (E2003): the largest packet could never be afforded. boolean[]is packed eight to a byte.CFrame<quat>encodes a rotation in 7 bytes instead of 12. It is opt-in, because it is lossy.
Fixed
- Color3 wrapped HDR channels: 2.0 arrived as 254/255. Channels are now clamped.
- The docs had described CFrame’s two components backwards since upstream.
Performance
- Each flush threw away the buffer it had just grown. Keeping it measured 35 to 52 percent faster on the flush path.
0.26.0 — 2026-09-22
Recompile both modules. The rate-limit handler now takes (Player, Event, Refused).
Fixed
- A channel the server receives nothing on (every event
From: Server, or no unreliable client event) never got the unknown-index guard. A client’s junk packet was read one byte at a time up toMaxEventsPerPacket, and nothing was reported. The channel now fails at the first byte, once, throughSetDecodeErrorHandlerwith the eventnil. - The rate-limit handler was spawned on every refusal. It now shares the warning’s schedule of once
a second per player per event, and receives
Refused, the number of refusals that call stands for.
0.25.0 — 2026-09-19
Recompile both modules if a schema has an open float range.
Added
@profile("dev" | "debug" | "test" | "release")keeps a declaration out of every build that did not ask for it. The default isrelease. Choose a profile with--profileon the CLI, or with aProfileattribute in the Studio plugin. New codes: E3025, E3026, E3027, E3028.
Fixed
- An open side of a float range was filled with the exact-integer limit (2^24 for
f32), sof32(0..)refused 2e7. Open sides are now unbounded. - A vector range has bounds of its own,
0..inf. A negative lower bound on the magnitude is an error. - The vector magnitude check is now tested. Lune has no global
Vector3, so no test had ever run it. lune run init -- file.blinkno longer treats--as the config path.
0.24.2 — 2026-09-18
A hardening patch. Generated output changes; the wire format and the schema signature do not.
Hardened
- The server’s queue for an event with no listener stops at 256 and drops the rest silently. An invocation past the cap is answered with a failure.
- Ranges refuse NaN.
x < Minpasses NaN, so the checks are now writtennot (x >= Min).
Added
SetDecodeErrorHandlertells the game which player sent a packet that failed to decode, and which event the packet claimed to be (upstream #102).
Fixed
- Invoking a player who had already left fails immediately instead of waiting out the timeout.
0.24.1 — 2026-09-17
Toolchain only (lefthook 2.1.14). The compiler is unchanged.
0.24.0 — 2026-09-17
No wire-format or signature change.
Fixed
-
A write that threw left its bytes behind:
- a reliable fire delivered a phantom event;
- an unreliable fire left its scratch buffer installed and dropped the pending reliable batch;
- an invocation stranded its slot.
Upstream has these open as #91 and #107.
-
A reply that could not be serialised answered nobody, and the caller waited out the full timeout. It now fails the call.
-
Two files importing each other recursed until the path outgrew the filesystem. This is now E3015, “Cyclic import”.
-
A type naming itself is reported as E3022, not as “Unknown reference”.
-
An unknown
Casingis caught at parse time (E2003). -
Component errors now have their own code (E3023), and so do duplicate options (E3024).
-
Poll: truewarns (W3017). -
The Studio plugin’s version had drifted five minor releases behind the compiler’s.
-
--versionworks, and--quietsilences the banner.
0.23.0 — 2026-09-11
The devforum release. Recompile both modules.
Added
- A schema signature on both modules. A client and a server built from different schemas refuse each other at startup instead of decoding one event as another.
Fromon functions, so the server can invoke a client.option InvocationTimeout(10 seconds by default). Invocations had no timeout, and a lost call leaked a slot.- Named type-pack elements:
Data: (chefId: f64, dish: string). - A thread pool for Async dispatch, kept because it measured faster.
Hardened
- The client’s decode loop is guarded the way the server’s already was.
- Map keys that can never be looked up (tables) are refused at compile time.
Changed
SyncValidationsays that it discards the rest of the packet, which it always did silently.- A second
.Onon aSingleevent warns, because the first listener’s disconnect stops working.
Studio plugin
- The editor no longer rebuilds one frame per line on every keystroke (39 ms at line 800, now constant).
- It no longer paints the document twice.
- It no longer crashes past 2000 lines.
- It no longer deletes other contents of the output folder.
- It remembers the output location.
0.22.0 — 2026-09-11
Compatible with 0.21.0 on the wire.
Hardened
- A decoded length is checked before the read and the allocation it authorises.
- Every variable-length read is bounded by the bytes left in the packet.
- Decode loops are bounded by what the packet paid for:
unknownchecks the value it takes;- an element that costs nothing to decode is refused where no bound was written (E3021);
- a count is checked before
table.create.
0.21.0 — 2026-09-10
A wire-format release. Recompile both modules.
Added
- Booleans and optional flags share a bitfield. Seventeen booleans went from 17 bytes to 3.
- Lengths are encoded relative to their minimum.
u8[300..400]spends one byte on its length instead of two. Type: OrderedUnreliablediscards an unreliable packet that arrives after a newer one.
0.20.0 — 2026-09-10
A security release.
Added
- Per-player, per-event rate limits. Use
RateandBurston each event, oroption DefaultRatefor all of them, andoption RequireRatesto make a missing rate an error.SetRateLimitHandlerreceives refusals. Nobody is kicked automatically. - Unreliable size analysis. An unreliable event that can never fit is refused (E3018). One that
might not fit is warned about (W3019). A runtime check catches the rest. The limit is set with
option MaxUnreliableSize, 900 by default.
Hardened
- Remote arguments are type-checked before they are read.
- Decoding stops when the player leaves.
- Both remotes are class-checked when the module is required.
- Invocation slots are recycled from a 32-slot bitset.
- Types carrying an
Instanceorunknowncannot be exported.
Fixed
- Numeric options never lexed.
- Only the first seventeen
optionstatements were read. Color3(1..2)silently dropped its range, andunknown?parsed.- The diagnostics renderer crashed on a blank line.
Upgrading
- Schemas that used to compile may now be refused.
- The concurrent invocation ceiling is 32, down from 256.
0.19.0 — 2026-09-10
The first release of the fork, continuing from upstream v0.18.8.
Hardened
- Inbound packets are bounded:
MaxPacketSize(8192),MaxEventsPerPacket(64) andMaxInstancesPerPacket(256);- an unknown event id stops the parse;
- a truncated packet is contained.
- A player who leaves mid-call no longer lingers in the replication map.
Fixed
- Instance classes containing a digit parse.
- Edit-mode stubs keep the shape of the real API.
- The CLI works unattended.
TypesOutputresolves against the output path.
Added
option Predictdelivers an event to local listeners without a remote.
Full release notes, with the measurements behind each change, are on GitHub Releases.
