Skip to content

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 @lune typedef 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, including LuauCodegenIntegerCompare = false, and its async-executor dependency 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-parser override 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-cli 0.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 @lune typedef 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 events 0.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 structs 3.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, and test/DecoderShapes.luau checks 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 DebugCodegenOptSize off: 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: Entities fired in 1.58 ms from 2.47 and Booleans decoded 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 (Booleans fired 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 Runtime rows are 0.11.0’s. 0.11.1 fixes luneblox setup, which put Enum and warn in .luaurc’s globals in every project: it now does so only where .vscode/settings.json turns the Roblox types off, and this project’s does not.
  • type-check.sh fails if luneblox setup changes .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 to globals would 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’s Enum a global. The suite passes unchanged, and BlinkBlox’s Runtime rows match 0.10.18’s, ABBA in turn: BooleansRandom fires 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.sh keeps .luaurc as it is written. LuneBlox 0.11.0’s setup adds Enum and warn to its globals, and luau-lsp types a name listed there as any over what the Roblox definitions say: local N: number = Enum.KeyCode.A went from an error to nothing. The plugin and the generated modules use Enum, 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, Rivals and 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, so Rivals leaves it out of Instances, and it runs Tiny, 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 – Entities fires 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, so Tiny costs it 1.01 bytes an event to BlinkBlox’s 2.00 and Booleans 127.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’s zstd column 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.
  • BatchUnreliable costs no time, measured. Its page said an event waits up to a frame for the flush. luneblox run Latency (new, in benchmark) fires unreliable events in Studio early and late in the frame and sends them at once, at a Heartbeat flush or at a task.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. generate keeps the raw results in tools/studio.json, and luneblox run generate -- --from tools/studio.json writes 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 ms now reflects; Benchmarks.md says so above the tables.

Fixed

  • Studio’s benchmark stopped at Warp. Its mode registered a client’s schemas in an __index that 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 in Fire and On now.

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 as JSONEncode writes them, utf8.graphemes – and errors that name their cause, among them stdio.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-editor that 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 as f32, 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 an AcousticAbsorption of 0.3, new(0.7, 0.3, 0.5) one of 1 – so none is left out. Measured in Studio, PhysicalProperties.new clamps 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 – Density 0 to 100, Friction 0 to 2, Elasticity 0 to 1, the weights 0 to 100, AcousticAbsorption 0 to 1 – or NaN is refused on arrival, naming the field. A part’s CustomPhysicalProperties is nil until set, so it goes as PhysicalProperties?.
  • NumberSequence, 25 to 241 bytes. A count, then each keypoint’s time, value and envelope as f32. Checked as a ColorSequence is – 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; Region3 and Ray, 24. A Region3 goes as its two corners, which is all Region3.new takes. The constructor rounds the centre and size it works out to f32, so a region arrives exactly when its corners are whole numbers and otherwise within one f32 step of its largest component; sending the centre and size instead differs as often.
  • --from quicknet, --from packet and --from warp convert the five where the draft said unknown. Only Font, EnumItem and Nil (and Packet’s Characters and Static types) are left without an equivalent.
  • benchmark/Runtime.luau -- --validations times BlinkBlox built with WriteValidations.

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.Name off the Instance. The message is built only once the timer runs out, and arming it makes one closure. Its words do not change.
  • WriteValidations checks a number, string, boolean or buffer with type(). typeof also asks a userdata for its own name, which a value of those types never is. The Roblox types keep typeof. 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.delay instead 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() under WriteValidations. 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 with typeof and 6.11 with type over eight rounds, inside the 1% the rounds of one build differ by.
  • The generated modules. benchmark/definitions/Definition.blink, compiled with option ManualReplication = true by 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. Runtime over 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, Region3 and Ray are types now, so a declaration of one of those names must be renamed (E2002), and a top-level type named NumberSequenceKeypoint is refused beside a NumberSequence (E3005). So are declarations named ExpireInvocation and ReleaseGonePlayer, the runtime’s two new functions. Handlers, their arguments and their Reasons 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 SetPacketDropHandler handler was installed, but a rate refusal printed Rate limited event "E" from X, N refused. whatever the game had installed, so a game that reported refusals through SetRateLimitHandler saw 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 its Concurrency ("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 logs Native 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 2 and the lint directives stay on all three.

Upgrading

  • A game that installs SetRateLimitHandler no longer sees the rate, Concurrency and 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 ExportRates adds a frozen Rates table to the server, client and types modules, and a readonly declaration to the TypeScript output: the rate limits the server enforces, keyed by the schema’s own names – Rates.Report.Burst is 4. A client that paces what it sends, or holds back as many as a burst, reads the numbers instead of repeating them. ExportLimits could not carry them: an event’s entry in Limits is its data’s bounds, a range itself where the data is one value.
    • An entry holds Rate, Burst, and a function’s Concurrency where it declares one. They are the enforced numbers, not only the written ones: Rate falls back to DefaultRate, and Burst to 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, Rates is a reserved top-level name (E3005); off, nothing is emitted and the name is free.

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 quicknet records QuickNet v0.4.0-beta definitions (commit edba9a2, the head of main the day it was released; its tag points at v0.3.5’s source). Data.Font and Data.Ray have no equivalent and become unknown, as Rect does, and so does Data.custom: the game’s own encoder and decoder are functions a schema cannot hold. Each is named in the checklist.
  • --from warp records Warp 1.1.0-pre8 schemas. Its new vector3f32 and vector2f32 become vector and Vector2; 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.luau lifts both as it lifts Packet’s and Warp’s limits. On one machine, side by side, QuickNet 0.4.0 fires Entities in 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 for SelfState to 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 build ran 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.yml has a verify job 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 with table.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-node 7.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-parser that has a known flaw. A selector could be made to cost quadratic time to parse (fixed in 7.1.6). docs/package.json overrides 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 called Name. It is Unanswered now. The warning’s text is 1.1.4’s.
  • _G.VERSION is read in exactly one module. src/Modules/Version.luau now 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 source 0.0.0, as the CLI does, where they printed (unbundled); no published figure is re-measured or changed.
  • pesde and wally, which publish a release, are pinned in rokit.toml beside the other tools, and the release procedure is written down in CLAUDE.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 Invoke nobody 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 DEBUG where Library.VERSION and the header of every module it generated said 0.0.0. All three now read one module, src/Modules/Version.luau, and say 0.0.0. A release build is unaffected: it carries its version as before.

Changed

  • The docs site’s http-cache-semantics is 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 as Value.Target:IsA("Humanoid") on a field of an any, which LuauSolverV2 reports 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 Tiny bench, 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 run says luneblox run: bump’s usage line, the benchmark’s “run luneblox run build --download first”, 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 the lune environment 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().Reliable left out the server’s broadcast batch. A reliable FireAll is written once and sent at the flush, in one FireAllClients or 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’s SentBytes pass its whole channel’s – 97 FireAlls of a 19-byte event to one player read 1843 bytes for the event and about 300 for Reliable and Unreliable together. The broadcast batch now counts its bytes and one packet for every player it is sent to: all of Players:GetPlayers() for a FireAllClients, 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 Reliable left out a batch cut into packets. A frame past MaxEventsPerPacket, MaxPacketSize or MaxInstancesPerPacket goes 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: Player stream’s included, counts its channel as it is sent.

Upgrading

  • A game that reads Reliable.Sent or Reliable.SentBytes will see them rise to what was sent. Event counts, the received side and every handler are as they were.

Tests

  • test/TrafficChannels.luau builds 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 under option 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 zap converts 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 a TODO(convert) comment where the answer belongs, never guessed: a Rate for 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 bytenet records 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 for game and script, 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, written From: Client until 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, sendToAll and listen and turns the arguments around: the player goes first in a schema’s calls.
  • --from packet records 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 of String and Buffer is a bound and is kept as string(..255); the Long ones are marked when a client sends them. A packet with a Response becomes a function, and since Packet lets either side call it, the draft asks which does. Rect, Region3, NumberSequence, EnumItem, Characters and the Static types have no equivalent, and each use is named.
  • A guide, Coming from Packet, with a wrapper that answers to :Fire, :FireClient, OnServerEvent:Connect and OnServerInvoke.
  • --from quicknet records QuickNet v0.3.5-beta definitions, the same way, and carries what the others cannot: QuickNet limits how often a client may fire, so SetRateLimit(Max, Time) becomes Rate: Max / Time and an event that sets none gets QuickNet’s own default, Rate: 60. One lifted with math.huge is marked for a rate like any other library’s. A uniform array is T[], a static array of one type T[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:Connect and :InvokeServer.
  • --from warp records Warp 1.1.0-pre7 schemas, the same way: each useSchema becomes 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 – in reg_namespaces, or by connecting to it – is kept with unknown for its data and said to be; a custom_datatype and 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, Fires and Connect do.
  • 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:Connect and :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 left Generators.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 event was 430 lines and scored 50 on cyclomatic complexity. selene’s high_cyclomatic_complexity now 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 into Generator/EventReceive.luau, the answering side of a function into Generator/FunctionAnswer.luau, the connections and the decode loop into Generator/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.Type built 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.Files supplies what an import names, and Options.Profile the 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 by luneblox run build into release/wally). A game still needs only the generated modules.
  • luneblox run bump rewrites the Wally pin in the docs too, and scripts/check-versions.sh fails 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 nil when 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 _G guard 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 are Archivable = 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, ColorSequence and TweenInfo. A ColorSequence is 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 a NumberRange that Max is not below Min, so a hostile packet is refused with a message rather than by the constructor.
  • Enum(Name), an item of a Roblox enum: its Value in a u16, looked up by Value on arrival – never by position in GetEnumItems(), which can differ between client and server during an engine rollout. The Value is checked on send whatever WriteValidations says. Enum is a type only directly before its name, so a declaration already called Enum still parses; E3037 for an Enum with no name.
  • option RemotesFolder = "Name" keeps the two remotes in a folder in ReplicatedStorage.
  • W3036 warns 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 .On on 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 the WriteValidations type 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.sh checks every golden under both solvers, and test/Consumers checks 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 Failure that begins “An Instance it carried did not arrive”. On the server that is SetDecodeErrorHandler, 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, ColorSequence and TweenInfo are now types, so a declaration of one of those names must be renamed (E2002). A top-level type named Enum or EnumItem is refused (E3005) only in a schema that uses Enum(Name) or TweenInfo, and one named ColorSequenceKeypoint only beside a ColorSequence.

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-rokit v0.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 is E2003.

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 MaxEventsPerPacket events, 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, Rivals and 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, so download.luau fetches 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: @native functions 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, @self and an init.luau’s ./ resolve as Luau resolves them, and each side has its own shared. 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. Runtime times every tool one after another in one process, and each inherited the heap the one before it left: BlinkBlox’s BooleansRandom decode measured 5.3 ms or 7.5 ms with nothing changed but what ran first. The sandbox leaves collectgarbage only "count", as Roblox does, so LuneBlox 0.10.9 added luau.collect() for it.
  • BooleansRandom and EntitiesRandom hold 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 the Server.luau and Client.luau in 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: Runtime three times, Rivals and Scenarios five, 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.
  • Runtime times 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.
  • .gitattributes keeps every text file LF in the working tree. Git for Windows checks files out with CRLF by default, and stylua --check then 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 a pcall(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 = 0 until 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, luneblox in rokit.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 sets DebugCodegenOptSize – the figures now match what a Roblox server runs.
  • The compiler run through pesde hands itself over to luneblox when it is installed, with the same arguments and output, and otherwise runs on the Lune pesde started, as before. BLINKBLOX_NO_LUNEBLOX=1 keeps 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 elseif after 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 OutboundBytesPerSecond and OutboundBurst, a per-player budget for what the server sends, and Priority: Low on 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. E3035 refuses a priority on anything else, and W3035 reports one in a schema with no budget.

Changed

  • A client sends at most option ClientFlushRate times 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 a StepReplication the 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 FireAll is written once into a broadcast batch and sent in one FireAllClients, 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 pcall a packet on receipt rather than one an event, and none on a client’s reliable Fire: 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, Allocate keeps 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 Fire is 18 to 40% faster.
  • A Fire carrying 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 BatchUnreliable gathers a side’s unreliable events into as few packets as MaxUnreliableSize allows, 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, MaxPacketSize or MaxInstancesPerPacket in 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.PushLines dropped the line before a trailing empty one instead of the empty one.

Upgrading

  • Rebuild both modules. Honest players no longer reach SetPacketDropHandler with Events or Oversized for 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 a From: Client event. The client’s Fire writes its estimate of the server’s clock (GetServerTimeNow, milliseconds modulo 65536) after the values, and the server’s listener receives SentAt in 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 named SentAt is E3034; UsePolling leaves a stamped event out, as it does a stream. See the new Stamps page.
  • Per: Player in a stream’s block. Set, Clear, Urgent and SetFast take the player first, and each player’s state runs the scheduler on its own – its clock, change check, keepalive and Fast – and is sent to that player alone. A player’s state is made on their first Set or SetFast and dropped when they leave; a Set for a player who has already left does nothing.

Changed

  • The stamp unwrap a stream’s client used lives in the new src/Templates/Stamp.luau and is shared with the server’s clamp, so a module with a stream carries StampClock, where the server’s half read Workspace. The emitted stream runtime also gains the per-player scheduler; a module whose streams are all broadcast never enters it.
  • An unreliable Fire to one player and a per-player stream’s send are one generator, Send.One.

Upgrading

  • Nothing changes until a schema opts in. Adding a Stamp is 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.rep on 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-byte string.byte and 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 calling TryConsume for 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

  • --verify compiles 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 with 0 when all match and 1 when 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 --watch it is refused.
    • Under --json the document gains a verify field, one { file, status, line } per file, with status "current", "stale" or "missing"; success is false when any is not current. The field is present only under --verify, and the format’s version stays 1.
  • SetListenerErrorHandler(Handler?) on the server module. A server listener that threw was caught – a Sync one 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, Sync or Async, Single or Many, reliable or unreliable, live, through Predict, or replayed from the queue when .On connects; 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.
    • Failure is the error, as a string. Count is 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 with Count 1.
    • 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 Sync listener’s error is raised on a thread of its own, an Async one’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, follows Casing (setListenerErrorHandler, set_listener_error_handler), and is a reserved top-level name (E3005).
  • 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 – Fire does nothing, listeners are never called, Invoke fails at once with The client is not connected to a server – and Connected false. When they come, Connected is true. Remotes that appear after the timeout are not picked up: the module is already cached, and the game reloads to try again. Connected exists 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 nor Heartbeat. Start() does all three, and a second call does nothing. Before it, On and the handler setters register as usual; a Fire is dropped, not queued, and the first one warns once (Something was sent before Start() was called, and was dropped.); an Invoke of a client fails at once instead of waiting out InvocationTimeout. The client module ignores the option.
  • Remotes on 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 (default false). With it on, the server and client modules export GetTrafficStats() and ResetTrafficStats(). 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 last ResetTrafficStats(), in a fresh table on every call. Events is keyed by the event’s path ("Inner.Poll" inside a scope); functions are counted in Reliable only. The fields follow Casing.
    • 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 – FireAll once per player in the game, also for an unreliable event sent through FireAllClients – 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_COUNTS and BLINK_TRAFFIC_STATS are reserved (E3005) whether the option is on or not, so turning it on never starts refusing a schema.
  • benchmark/Runtime.luau takes --traffic to 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.34 in a u16<0.1> sends 123 and reads 12.3 – the exact double the literal is, since a step whose reciprocal is whole is sent as round(v * 10) and read as n / 10. Every integer takes one (u8 to u32, i8 to i32, 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 under WriteValidations, 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 a number in 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, an f16 stopping at 65504 instead of becoming infinity. NaN is 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. saturate is not a keyword: a field may still be called that.
  • option ExportLimits adds a frozen Limits table to the server, client and types modules, and a readonly declaration 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.Max is 16. The structural words follow Casing. With the option on, Limits is a reserved top-level name (E3005); off, nothing is emitted.
  • E3032 refuses a scale on a float, a scale of zero or less, a repeated scale or saturate, and anything else in a number’s brackets.
  • The Studio plugin offers saturate inside a number’s brackets, and the docs’ grammar highlights it.
  • test/Numbers.luau and test/NumberLimits.luau check exact bytes and values; test/Oracle.luau and test/Generate.luau learnt both modifiers, so the property draws cover them.
  • Stream: { Rate, Fast?, Keepalive?, Epsilon? } on an event (language/streams). The server module gets Set(Value), Clear(), Urgent() and SetFast(Fast) in place of the Fire family; the client’s listener gets (Value?, ServerTime). The scheduler runs inside StepReplication, before the reliable flush, and sends the latest state to every player at Rate a second (or Fast after SetFast(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 Keepalive seconds, 1 by default;
    • Clear() sends nil once, if the clients saw a state, and the stream is silent until the next Set; 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 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 not From: Server and OrderedUnreliable, is polled, streams an optional, a type pack or a type that cannot be optional, or has a Fast no faster than Rate.
  • TypeScript declarations, edit-mode stubs and Casing (set_fast under Snake) for the four members; BLINK_STREAM is a reserved type name. The Studio plugin completes the Stream block’s fields, on its own lines or on one. Stream is a field like Rate, so highlighting needs nothing.
  • A guide, guides/streaming-state, replacing a hand-rolled send loop with a stream.
  • Where the features meet: under TrafficStats a stream’s scheduled sends are counted as a FireList to every player is, once per recipient, under the stream’s name in Events and on the unreliable channel; under ExportLimits a stream’s Limits entry 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, so Epsilon is in the schema’s units too; and under AutoStart = false the scheduler, which runs inside StepReplication, does not run before Start() connects the Heartbeat – a game that steps replication itself before then has its stream sends dropped, as a Fire would be. test/Crossings.luau checks each of these, and test/Sources/Crossing.blink puts them all in one schema in front of the goldens and the type gate.

Changed

  • An Async listener on the server, live or replayed from the queue, is called through RunListener, which calls it bare when no handler is installed. A replayed Sync one is guarded as on receipt, which 0.37.1 made true on both sides. An event, function, scope or type may not be named SetListenerErrorHandler, and a type-pack element may not be named ListenerFailed or RunListener.
  • A top-level event, function, scope or exported type named Remotes – in the spelling the casing gives it – is refused (E3005), as are Start under AutoStart = false and Connected under ClientConnectTimeout. 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> --verify instead, with the same --profile the 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_REMOTE and a wait of its own for the server: set option ClientConnectTimeout and require the client module directly, checking Connected where it matters. Anything that still needs a remote’s name reads Remotes.
  • Lazy requires of the server module: set option AutoStart = false, require it at the top like any module, and call Start() in the real game’s bootstrap only.
  • Guessing which event costs what out of DataSendKbps: set option TrafficStats = true and read GetTrafficStats() on the server to split it by event.
  • Numbers scaled by hand – tenths sent as x * 10, counts clamped with math.min(n, 255): declare them u16<0.1, saturate> or u8<saturate>(1..255) and send the raw values. A scale changes the schema signature and saturate does not, so both modules are rebuilt together, as after any schema change. saturate sends NaN as the value in range nearest zero, where math.clamp passed it through.
  • A comparison against a length cap copied out of the schema: set option ExportLimits = true and compare with the module’s Limits.
  • 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. With TrafficStats on, 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 Sync listener was unguarded. When On connected, 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 of On after it had been bound: the caller never got its disconnect, the rest of the queue was never delivered, and the next On was 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 Many event’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, as RBXScriptSignal does. 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):
    • Reason is "Malformed" (the remote’s arguments are not a buffer and a table), "Oversized" (over MaxPacketSize), "Budget" (over the inbound byte budget), "Instances" (over MaxInstancesPerPacket) or "Events" (MaxEventsPerPacket events decoded, the rest dropped).
    • Refused is 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.
    • Detail is the packet’s length in bytes for Oversized and Budget, its instance count for Instances, the events decoded for Events, and 0 for Malformed.
    • 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 Budget goes to it instead of to the rate-limit handler. Without one, nothing changes: the same warnings, word for word, and Budget still reaches SetRateLimitHandler with Event nil.
    • The client module has it too, accepted and ignored, as it has SetRateLimitHandler. It is stubbed in edit mode, follows Casing (setPacketDropHandler, set_packet_drop_handler), and is a reserved top-level name (E3005).
  • MaxInstancesPerPacket may 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: with MaxInstancesPerPacket = 0, a client-sent declaration that carries an Instance or unknown is refused, since none of its packets could arrive. Client-sent means the data of a From: Client event or function, and the return of a From: Server function, which the client sends back on the same remote. An offending declaration in an imported file is reported at its import.
  • The Studio plugin’s editor completes more than keywords and types. option is offered at the start of a line, followed by every option’s name and, where the language fixes it, its value. Inside an event or a function a 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. @profile is 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 nearest event or function. 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 SetRateLimitHandler or SetDecodeErrorHandler, so a roblox-ts project reached them through a cast. They are declared now, beside SetPacketDropHandler, in the module’s Casing.
  • --help said --compact prints the full message after the compacted one. It prints only the one line.
  • Under option Casing = Camel or Snake, 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 the Pascal spelling only. And a scope StepReplication passed even under Pascal, since scopes were never checked. Both are E3005 now; a name in some other casing’s spelling still compiles, since it collides with nothing.
  • A top-level type named Entry, Queue, BufferSave, Invocations or DropReason redefined a type alias the runtime declares for itself, and the generated module failed its own --!strict check. The runtime’s aliases are spelled BLINK_* now, and a type of one of those names is E3005.

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, into src/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 an f16 or f24 range, --json on the runs that print plain text, the names E3005 covers, and the release E3030 arrived in. What they had left out: Concurrency in 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.txt and llms-small.txt follow 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, and llms.txt carries 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 under WriteValidations checks 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.md has 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 Runtime times 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.DataSendKbps once 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

  • --help named the executable blinkblox.exe on 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

  • MaxUnreliableSize defaults to 980, up from 900, and counts 6 bytes for each Instance an event sends beside its buffer. benchmark/Probe.luau measured 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 --local compiles 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 own astro:head-inject directive, 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 with table.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 CFrame fields, about 200 strings or arrays, or about 66 vector<f16> or vector<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 MaxUnreliableSize bounds BlinkBlox’s buffer alone. The default stays 900, which leaves 100 bytes for the encoding, until benchmark/Probe.luau measures what the encoding really costs in Studio.

Benchmarks

  • benchmark/Runtime.luau times 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. BooleansRandom and EntitiesRandom now 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 or build.bat: lune run build replaces the batch file.
  • benchmark/Performance.luau had 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) and i24 (-8388608 to 8388607) take three bytes, low byte first. Without a range they wrap on send as u16 and i16 do.
  • f24 takes 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 where f16 keeps 11, and it rounds to nearest where f16 truncates. 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 where vector is 12, and CFrame<quat, f24> is 16. Its largest finite value is 4294950912; anything that rounds past it is infinity.
  • A range on f24 is checked on receipt against its bounds as they arrive, as f16 and f32 ranges are. Rounding can move a bound either way, and f24(0.1..0.7) accepts both.
  • Packet’s own f24 gets 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 u offers u8, u16, u24, u32.

Tests

  • test/Floats24.luau checks 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, i24 and f24. The oracle decides what f24 holds 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: N on a function caps how many of one player’s calls the server runs at once. Rate limits 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 SetRateLimitHandler and 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), and Concurrency on a From: Server function warns (W3020).

Tests

  • test/Composition.luau fires 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.luau sends 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.luau covers 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 pcall of a writer that returns nothing, destructured into two locals;
    • a polled event’s iterator ending in a bare return;
    • an Instance compared with nil where its type does not allow nil;
    • an exported enum’s Read returning a value that pcall had widened to string.
  • An empty FutureLibrary or PromiseLibrary emitted require(), 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 as export type number = number, which Luau does not allow.
  • A top-level type named after a Roblox type the module uses is refused, E3005. That covers Player, Instance, RemoteEvent, UnreliableRemoteEvent, CFrame, Vector3, Color3, DateTime, BrickColor, and any class the schema names in Instance(...). type Player = Instance(Player) exported a type that referred to itself and shadowed every Player parameter in the module. A type inside a scope is 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.luau reads 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.
  • f16 lost the sign of zero: -0 arrived as +0. It is now written as 0x8000, 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. Without WriteValidations it 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 --!strict generated module.

Tests

  • Every exported type of the test schema is drawn at random, 100 times, into two builds: one with WriteValidations on 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 WriteValidations must 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_SEED replays 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

  • --check runs 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.
  • --json prints 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 import wrote, 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.blink whatever it was called.
  • A schema without ServerOutput or ClientOutput was 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 --json gives 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 Write lost 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 Sync listener’s error was reported as the sender’s decode failure.
  • OrderedUnreliable accepted 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 Length shadowed 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 Burst below 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 Fire or Invoke already uses, such as Player or Buffer, 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 WriteValidations off (the default), a 300-byte value in string(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.
  • Predict on a reliable Many event 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 InboundBytesPerSecond and option 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 with Event nil. An InboundBurst below MaxPacketSize, 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 to MaxEventsPerPacket, and nothing was reported. The channel now fails at the first byte, once, through SetDecodeErrorHandler with the event nil.
  • 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 is release. Choose a profile with --profile on the CLI, or with a Profile attribute 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), so f32(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.blink no 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 < Min passes NaN, so the checks are now written not (x >= Min).

Added

  • SetDecodeErrorHandler tells 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 Casing is caught at parse time (E2003).

  • Component errors now have their own code (E3023), and so do duplicate options (E3024).

  • Poll: true warns (W3017).

  • The Studio plugin’s version had drifted five minor releases behind the compiler’s.

  • --version works, and --quiet silences 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.
  • From on 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

  • SyncValidation says that it discards the rest of the packet, which it always did silently.
  • A second .On on a Single event 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:
    • unknown checks 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: OrderedUnreliable discards 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 Rate and Burst on each event, or option DefaultRate for all of them, and option RequireRates to make a missing rate an error. SetRateLimitHandler receives 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 Instance or unknown cannot be exported.

Fixed

  • Numeric options never lexed.
  • Only the first seventeen option statements were read.
  • Color3(1..2) silently dropped its range, and unknown? 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) and MaxInstancesPerPacket (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.
  • TypesOutput resolves against the output path.

Added

  • option Predict delivers an event to local listeners without a remote.

Full release notes, with the measurements behind each change, are on GitHub Releases.