Skip to content

Coming from Packet

Packet defines each packet with a call, Packet("Hit", Packet.Instance, Packet.NumberU8), and builds its serialiser when the game starts. BlinkBlox compiles a schema ahead of time into a server module and a client module. So the definitions become a schema, and the calls go to the generated modules.

blinkblox --from packet does the first half: it runs your definitions against a stand-in for Packet that records every packet instead of making a remote, and writes them out as a schema.

Packet’s definitions do not say which side sends a packet, or how often a client may. The draft asks you for both.

This page was written against Packet 1.7.0.

  1. Install blinkblox. See Installation.
  2. Record the definitions: blinkblox Packets.luau --from packet. It writes Packets.blink beside the file and prints what you have to decide.
  3. Work through the TODO(convert) marks: the direction of each event and function, a Rate for each one a client sends, an upper bound for each length a client sends, and the two output paths.
  4. Compile it: blinkblox Packets. Both libraries can run in one game, each on its own remotes, so packets can move one at a time.
  5. Change the call sites (below), or wrap the modules in the bridge and change them later.
  6. Install the server’s handlers. See Securing the server.
Terminal window
blinkblox Packets.luau --from packet

A require whose path, alias or Instance ends in Packet gets the stand-in, a module beside the file is run too, and any other require gets a placeholder and is named in the printed list. The draft is written beside the file with .blink for an extension, and is not replaced without --yes; --check prints it instead. The command line page has the details.

For these definitions:

Packets.luau
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Packet = require(ReplicatedStorage.Packet)
return {
Hit = Packet("Hit", Packet.Instance, Packet.NumberU8, Packet.StringLong),
Health = Packet("Health", Packet.NumberU8),
Price = Packet("Price", Packet.NumberU16):Response(Packet.NumberU32),
}

it writes:

Packets.blink
-- Converted from Packet 1.7.0 definitions by BlinkBlox. A draft: read it before you use it.
-- Each TODO(convert) marks what Packet's definitions have no way to say. The guide explains them:
-- https://xopoiii.github.io/BlinkBlox/guides/coming-from-packet/
--
-- Once every event a client fires has a Rate, turn this on, so that none is added without one:
-- option RequireRates = true
-- TODO(convert): a client sends this; bound #3 (string length)
event Hit {
-- TODO(convert): Packet does not say which side sends this; say it here
From: Client,
Type: Reliable,
Call: ManyAsync,
-- TODO(convert): Rate: ?, Burst: ?
Data: (Instance, u8, string)
}
event Health {
-- TODO(convert): Packet does not say which side sends this; say it here
From: Client,
Type: Reliable,
Call: ManyAsync,
-- TODO(convert): Rate: ?, Burst: ?
Data: u8
}
function Price {
-- TODO(convert): Packet does not say which side calls this; add From: Server if the server does
Yield: Coroutine,
-- TODO(convert): Rate: ?, Concurrency: ?
Data: u16,
Return: u32
}

and prints:

Draft written to Packets.blink.
Before it is used:
- 2 event(s) whose direction you must state: Hit, Health
- 1 function(s) whose caller you must state: Price
- 2 event(s) a client fires with no Rate: Hit, Health
- 1 function(s) a client calls with no Rate or Concurrency: Price
- 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. A packet’s events come out in the order the definitions made them.

A Packet packet goes either way: the client calls Fire, the server calls Fire or FireClient. A BlinkBlox event has one direction, because the two 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. State each one, and delete the Rate mark from what the server sends:

Packets.blink
option ServerOutput = "src/server/Network.luau"
option ClientOutput = "src/shared/Network.luau"
option RequireRates = true
event Hit {
From: Client,
Type: Reliable,
Call: ManyAsync,
Rate: 10,
Data: (Target: Instance, Damage: u8, Note: string(..64))
}
event Health {
From: Server,
Type: Reliable,
Call: ManyAsync,
Data: u8
}
function Price {
Yield: Coroutine,
Rate: 5,
Concurrency: 1,
Data: u16,
Return: u32
}

A packet the game sends both ways becomes two events, one for each direction. A packet with a Response is a function: the client calls the server unless it says From: Server.

The values of an event can be named, as Hit’s are above. Packet’s are known by position only.

Packet has one channel, so every event is Type: Reliable. An event that is sent every frame and only matters when fresh can now be Unreliable.

Packet BlinkBlox
Packet("Name", A, B) event Name { Data: (A, B) } A name that is not an identifier has its other characters replaced with _.
:Response(C) function Name { Data, Return: C }
NumberU8 .. NumberU32, NumberS8 .. NumberS32 u8 .. u32, i8 .. i32 The 24-bit ones included.
NumberF16, NumberF24, NumberF32, NumberF64 f16, f24, f32, f64
String, Buffer string(..255), buffer(..255) Their one-byte length is a bound, and is kept.
StringLong, BufferLong string, buffer Marked when a client sends them.
Boolean8, Instance, Any boolean, Instance, unknown unknown is not validated.
Vector3S16, Vector3F24, Vector3F32 vector<i16>, vector<f24>, vector
Vector2S16, Vector2F24, Vector2F32 Vector2
CFrameF24U8, CFrameF32U8, CFrameF32U16 CFrame Its encodings are chosen in the schema.
Color3, BrickColor, UDim, UDim2, NumberRange, ColorSequence the same
NumberU4, Boolean1, BooleanNumber u8(..15)[2], boolean[8], a struct The tables Packet reads them into.
{ T } T[]
{ Key = T, .. } struct { Key: T, .. } Fields in alphabetical order, as Packet sorts them.
Nil, Rect, Region3, NumberSequence, EnumItem, Characters, Static1 .. Static3 unknown No equivalent; each is named in the list. A single enum is Enum(Material).
a table of several types with no names unknown A struct needs names.

A struct used by more than one packet is declared once, as Shape1, Shape2 and so on: a table has no name the converter can see, so choose one. A type name the stand-in does not know stops the command and says so.

Packet BlinkBlox
Client to server Packets.Hit:Fire(...) Net.Hit.Fire(...)
Server to one player Packets.Health:FireClient(Player, ...) Net.Health.Fire(Player, ...)
Server to everyone Packets.Health:Fire(...) Net.Health.FireAll(...)
Listen on the server Packets.Hit.OnServerEvent:Connect(Listener) Net.Hit.On(Listener)
Listen on the client Packets.Health.OnClientEvent:Connect(Listener) Net.Health.On(Listener)
Call Packets.Price:Fire(...) returns the response Net.Price.Invoke(...)
Answer Packets.Price.OnServerInvoke = Listener Net.Price.On(Listener)

Calls use a dot, not a colon, and On returns a function that disconnects the listener where Connect returns a connection. The server also gets FireList and FireExcept.

The whole generated API is in the reference.

To move the definitions first and the call sites later, wrap each module once:

PacketNames.luau
--- A BlinkBlox module under Packet's names. Temporary: delete it once the call sites use Fire,
--- FireAll, On and Invoke themselves.
local function Signal(On)
return {
Connect = function(_, Listener)
local Disconnect = On(Listener)
return { Disconnect = Disconnect }
end,
}
end
local function PacketNames(Net, IsServer)
local Packets = {}
for Name, Value in Net do
if type(Value) ~= "table" or not (Value.Fire or Value.On or Value.Invoke) then
continue
end
local Packet = {}
if Value.Invoke and IsServer then
function Packet:FireClient(Player, ...)
return Value.Invoke(Player, ...)
end
elseif Value.Invoke then
function Packet:Fire(...)
return Value.Invoke(...)
end
elseif Value.FireAll then
function Packet:Fire(...)
Value.FireAll(...)
end
function Packet:FireClient(Player, ...)
Value.Fire(Player, ...)
end
elseif Value.Fire then
function Packet:Fire(...)
Value.Fire(...)
end
end
if Value.On then
-- An event's listeners connect to the signal; a function's one listener is assigned.
Packet.OnServerEvent = Signal(Value.On)
Packet.OnClientEvent = Packet.OnServerEvent
setmetatable(Packet, {
__newindex = function(_, Key, Listener)
if Key == "OnServerInvoke" or Key == "OnClientInvoke" then
Value.On(Listener)
end
end,
})
end
Packets[Name] = Packet
end
return Packets
end
return PacketNames
local Packets = require(ReplicatedStorage.PacketNames)(require(ServerScriptService.Network), true)
Packets.Hit.OnServerEvent:Connect(function(Player, Target, Damage, Note)
-- unchanged
end)

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 is a missing method here, where under Packet it was sent. ResponseTimeout is not carried: a call’s timeout is the schema’s InvocationTimeout.

The wrapper is untyped, so the call sites behind it lose their type checking until they move.

Packet and BlinkBlox each create their own remotes, so both can run in the same game:

  1. Record the definitions and keep only the packets you are moving in the draft.
  2. Delete those packets from the Packet definitions.
  3. Point their call sites at the new modules.
  4. Repeat until no packet is left, then remove Packet.

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.

  • 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 SetPacketDropHandler when it is refused before it is read, and through SetDecodeErrorHandler when 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.