Coming from ByteNet
ByteNet defines packets in Luau, at runtime: a module calls defineNamespace and definePacket, and
the library builds its serialisers when the game starts. BlinkBlox compiles a schema ahead of time
into a server module and a client module. So the move has two halves: the definitions become a
schema, and the calls go to the generated modules.
The first half is mechanical. blinkblox --from bytenet runs your definitions file against a
stand-in for ByteNet that records every packet instead of sending anything, and writes them out as a
schema.
Two things ByteNet’s definitions do not hold, and the draft asks you for: which side sends each packet, and how often a client may.
This page was written against ByteNet 0.4.3.
The short version
Section titled “The short version”- Install
blinkblox. See Installation. - Record the definitions:
blinkblox Packets.luau --from bytenet. It writesPackets.blinkbeside the file and prints what you have to decide. - Work through the
TODO(convert)marks: the direction of each event, aRatefor each one a client fires, an upper bound for each length a client sends, and the two output paths. - Compile it:
blinkblox Packets. Both libraries can run in one game, each on its own remotes, so packets can move one at a time. - Change the call sites (below), or wrap the modules in the bridge and change them later.
- Install the server’s handlers. See Securing the server.
Recording the definitions
Section titled “Recording the definitions”blinkblox Packets.luau --from bytenetHow the file reaches ByteNet does not matter. A require whose path, alias or Instance ends in
ByteNet – require(ReplicatedStorage.Packages.ByteNet), require("@pkg/bytenet") – gets the
stand-in. A require of a module that sits beside the file is run too, so constants and helpers the
definitions take from a sibling module work. Any other require gets a placeholder, and is named in
the printed list: whatever the definitions took from it is missing from the draft.
The draft is written beside the file under the same name, with .blink for an extension, and is not
replaced without --yes. With --check nothing is written and the draft goes to standard output.
For these definitions:
local ReplicatedStorage = game:GetService("ReplicatedStorage")local ByteNet = require(ReplicatedStorage.Packages.ByteNet)
return ByteNet.defineNamespace("Combat", function() return { Hit = ByteNet.definePacket({ value = ByteNet.struct({ target = ByteNet.inst, damage = ByteNet.uint8, note = ByteNet.string, }), }), Health = ByteNet.definePacket({ value = ByteNet.uint8, reliabilityType = "unreliable", }), }end)it writes:
-- Converted from ByteNet 0.4.3 definitions by BlinkBlox. A draft: read it before you use it.-- Each TODO(convert) marks what ByteNet's definitions have no way to say. The guide explains them:-- https://xopoiii.github.io/BlinkBlox/guides/coming-from-bytenet/---- Once every event a client fires has a Rate, turn this on, so that none is added without one:-- option RequireRates = true
scope Combat { event Health { -- TODO(convert): ByteNet does not say which side sends this; say it here From: Client, Type: Unreliable, Call: ManyAsync, -- TODO(convert): Rate: ?, Burst: ? Data: u8 }
-- TODO(convert): a client sends this; bound note (string length) event Hit { -- TODO(convert): ByteNet does not say which side sends this; say it here From: Client, Type: Reliable, Call: ManyAsync, -- TODO(convert): Rate: ?, Burst: ? Data: struct { damage: u8, note: string, target: Instance } }}and prints:
Draft written to Packets.blink.Before it is used: - 2 event(s) whose direction you must state: Combat.Health, Combat.Hit - 2 event(s) a client fires with no Rate: Combat.Health, Combat.Hit - 1 length(s) or value(s) a client sends with no bound; each is marked - Add option ServerOutput and option ClientOutput: where the two modules are written. - Set option RequireRates = true once every event a client fires has a Rate. - On the server, set SetRateLimitHandler, SetDecodeErrorHandler, SetPacketDropHandler and SetListenerErrorHandler: they are how the game hears of a refusal. - Check MaxPacketSize, MaxEventsPerPacket and InboundBytesPerSecond against what the game sends.The draft always compiles, and what it could not know is a comment, never a guess.
Stating the direction
Section titled “Stating the direction”A ByteNet packet has no direction: the client may send it and the server may sendTo it. A
BlinkBlox event has one, because the two directions are not alike – what a client sends is checked,
bounded and rate limited, and what the server sends is not.
Until you say otherwise the draft writes From: Client, the direction that gets the checks. For each
event, state the side that sends it, and delete the Rate mark from the ones the server sends:
option ServerOutput = "src/server/Network.luau"option ClientOutput = "src/shared/Network.luau"option RequireRates = true
scope Combat { event Health { From: Server, Type: Unreliable, Call: ManyAsync, Data: u8 }
event Hit { From: Client, Type: Reliable, Call: ManyAsync, Rate: 10, Data: struct { damage: u8, note: string(..64), target: Instance } }}A packet the game sends both ways becomes two events, one for each direction: HitRequest from the
client and HitConfirmed from the server, say. They are different messages already; ByteNet let
them share a name.
Rate and Burst are the events a second accepted from
one player and how many may arrive at once. Bounds are written on the type: string(..64),
u8[..16]. See types.
What converts, and what does not
Section titled “What converts, and what does not”| ByteNet | BlinkBlox | |
|---|---|---|
defineNamespace("Combat", ...) |
scope Combat { .. } |
|
definePacket({ value = .. }) |
event Name { .. } |
Named by its key in the namespace. |
reliabilityType = "unreliable" |
Type: Unreliable |
Reliable otherwise. |
uint8, uint16, uint32 |
u8, u16, u32 |
|
int8, int16, int32 |
i8, i16, i32 |
|
float32, float64 |
f32, f64 |
|
bool, string, buff |
boolean, string, buffer |
|
vec3, vec2, cframe |
vector, Vector2, CFrame |
|
inst, unknown |
Instance, unknown |
unknown is not validated. |
nothing |
an event with no Data |
|
struct({ .. }) |
struct { .. } |
|
array(T), optional(T), map(K, V) |
T[], T?, map { [K]: V } |
Three things come out differently from how they were written:
- Fields and packets are in alphabetical order. A Luau table keeps no order, so the order in the file is not there to record. Nothing depends on it: the schema’s own wire format is new either way.
- A struct used by more than one packet is declared once, as
Shape1,Shape2and so on. A function that builds a struct gives it no name the converter can see, so choose one. - Every event is
Call: ManyAsync: ByteNet calls each of a packet’s listeners on a thread of its own, and a packet may have several. A listener that never yields can be madeManySync.
A name the stand-in does not know – one a later ByteNet added – stops the command and says so, instead of leaving a packet out.
The call sites
Section titled “The call sites”| ByteNet | BlinkBlox | |
|---|---|---|
| Client to server | Packet.send(Data) |
Net.Event.Fire(Data) |
| Server to one player | Packet.sendTo(Data, Player) |
Net.Event.Fire(Player, Data) |
| Server to everyone | Packet.sendToAll(Data) |
Net.Event.FireAll(Data) |
| Server to a list | Packet.sendToList(Data, Players) |
Net.Event.FireList(Players, Data) |
| Server to all but one | Packet.sendToAllExcept(Data, Player) |
Net.Event.FireExcept(Player, Data) |
| Listen on the server | Packet.listen(function(Data, Player) |
Net.Event.On(function(Player, Data) |
| Listen on the client | Packet.listen(function(Data) |
Net.Event.On(function(Data) |
The player moves to the front everywhere: it is the first argument of a send and of a server’s
listener. On also returns a function that disconnects the listener, which listen does not.
The whole generated API is in the reference.
A bridge for the call sites
Section titled “A bridge for the call sites”To move the definitions first and the call sites later, wrap each module once. The wrapper answers to ByteNet’s names and puts the arguments in BlinkBlox’s order:
--- A BlinkBlox module under ByteNet's names and argument order. Temporary: delete it once the call--- sites use Fire, FireAll and On themselves.local function IsEvent(Value) return Value.Fire ~= nil or Value.On ~= nilend
local function ByteNetNames(Net, IsServer) local Packets = {} for Name, Value in Net do if type(Value) ~= "table" then continue elseif not IsEvent(Value) then -- A scope, which was a namespace. Packets[Name] = ByteNetNames(Value, IsServer) continue end
local Packet = {} if IsServer and Value.Fire then function Packet.sendTo(Data, Player) Value.Fire(Player, Data) end function Packet.sendToList(Data, Players) Value.FireList(Players, Data) end function Packet.sendToAllExcept(Data, Except) Value.FireExcept(Except, Data) end Packet.sendToAll = Value.FireAll elseif Value.Fire then Packet.send = Value.Fire end
if Value.On then function Packet.listen(Callback) if not IsServer then return Value.On(Callback) end
return Value.On(function(Player, Data) Callback(Data, Player) end) end end
Packets[Name] = Packet end
return Packetsend
return ByteNetNameslocal Packets = require(ReplicatedStorage.ByteNetNames)(require(ServerScriptService.Network), true)
Packets.Combat.Hit.listen(function(Data, Player) -- unchangedend)Pass true on the server and false on the client. An event has only the members of its direction,
so a call the schema no longer allows – the server sending an event declared From: Client – is a
missing function here, where under ByteNet it was sent.
The wrapper is untyped, so the call sites behind it lose their type checking until they move.
Moving one packet at a time
Section titled “Moving one packet at a time”ByteNet and BlinkBlox each create their own remotes, so both can run in the same game:
- Record the definitions and keep only the packets you are moving in the draft.
- Delete those packets from the ByteNet definitions.
- Point their call sites at the new modules.
- Repeat until no packet is left, then remove ByteNet.
BlinkBlox’s own server and client modules have to ship together: a client refuses to run against a server compiled from a different schema. See wire compatibility.
What a running game will notice
Section titled “What a running game will notice”- A client that fires too fast is refused, and the server is told through
SetRateLimitHandler. Choose rates an honest client never reaches. - A packet the server refuses is reported, with the player who sent it: through
SetPacketDropHandlerwhen it is refused before it is read, and throughSetDecodeErrorHandlerwhen an event in it does not decode. - A listener that errors is reported through
SetListenerErrorHandler. - An event has one direction. Sending it the other way is no longer possible.
Securing the server is the checklist for all of it, and common pitfalls lists what tends to go wrong.
