Events
Events are BlinkBlox’s version of Roblox’s RemoteEvent and UnreliableRemoteEvent: one side sends,
the other listens, and nobody waits for an answer. They are the main way client and server talk. When
the sender needs a reply, use a function instead.
Declaring an event
Section titled “Declaring an event”Events are declared with the event keyword and a block of fields.
event MyEvent { From: Server, Type: Reliable, Call: SingleAsync, Data: f64}From, Type and Call are required; Data, Rate, Burst, Priority, Stream and Stamp are
optional.
Stream makes the event a stream, which the server sends on a schedule
rather than on Fire. Stamp makes a client’s event carry when it was sent, as the listener’s
SentAt.
The side that fires the event: Server or Client. The other side listens to it.
The reliability of the event.
Reliable– guaranteed to arrive, in the order it was sent relative to every other reliable event and function call. Reliable traffic is batched: each side collects it into one buffer (the server keeps one per player) and sends the batch once a frame. SeeManualReplication.Unreliable– may be lost, and may arrive out of order. EachFireis sent at once, in a packet of its own, and its buffer, plus 6 bytes for each Instance beside it, may be at most 980 bytes by default, which is what fits under Roblox’s 1000-byte limit once the call’s arguments are encoded. BlinkBlox enforces that at compile time and again on send – seeMaxUnreliableSize.OrderedUnreliable– asUnreliable, plus a two-byte sequence number: a packet that arrives after a newer one is discarded rather than delivered late. Delivery is still not guaranteed and packets can still be lost – what is guaranteed is that your listener never sees an older state after a newer one.
The listening API generated on the receiving side.
Call |
Listeners | May yield | Runs on |
|---|---|---|---|
SingleSync |
one | no | the decode thread |
ManySync |
many | no | the decode thread |
SingleAsync |
one | yes | a thread of its own |
ManyAsync |
many | yes | a thread of its own |
Polling |
none – you iterate with Iter |
– | your loop |
Call is required on every event, including on the side that only fires it, where it has no effect.
It is not part of the schema signature, so changing it
does not break a client built before the change.
An Async listener is dispatched through a small thread pool rather than a fresh task.spawn per
event: a thread is reused as long as the listener before it neither yielded nor errored.
Deprecated, and worth reading even if you have never typed it – it may be in a schema you inherited.
Poll: true is the 0.x way of asking for polling. Call is still required beside it, and its value is
then discarded: an event declared Call: SingleSync, Poll: true compiles to Polling, not to
SingleSync. It went undocumented and unwarned for long enough that a schema using it had no way of
learning there was a current spelling.
It still works and now warns (W3017). Replace it with Call: Polling and delete the Poll line.
Rate, Burst
Section titled “Rate, Burst”Limits how often one player may send this event. Both fields are optional, and both are ignored on an
event the server sends – only inbound traffic is limited, and setting either there warns (W3020).
Rate is the sustained number of events per second accepted from a single player. It may be any
number above zero, including a fraction: Rate: 0.5 is one event every two seconds. Burst is the
bucket capacity: how many may arrive at once before the rate applies. It defaults to Rate rounded
up, and may not be below 1: each event spends one whole token, so a smaller bucket would refuse every
event however long the client waited. A Burst with no Rate – and no
DefaultRate to supply one – is refused,
since nothing would refill it.
Over any stretch of t seconds a player gets at most Burst + Rate × t events through, and never
more than Burst at once. With the default Burst, that is up to twice Rate in a single second,
and Rate per second over longer spans. Refused events cost the player nothing.
event Purchase { From: Client, Type: Reliable, Call: SingleAsync, Rate: 5, Burst: 10, Data: u16}A client that paces itself to these numbers – a cooldown of 1 / Rate seconds, a queue of Burst
– reads them from option ExportRates rather than
keeping a copy:
task.wait(1 / Net.Rates.Purchase.Rate)Each player’s bucket starts full. Refused events are dropped before they reach your listener, and
reported at most once per player per event per second. To do something else about them – log to
your analytics, flag the account – pass a handler to SetRateLimitHandler. With one installed the
handler owns the report and the server prints nothing; without one, each report is a warning. It is
called on the same schedule as the warning would be, and Refused is how many refusals that call stands for: the one that made
it, and those counted since the last. A function’s refusals arrive here too, with its name – from
Rate and, separately, from Concurrency. Event is
nil when what was refused was whole packets over the
inbound budget rather than one
event, and no packet-drop handler is installed
to take those instead:
local Net = require(path.to.Server)
Net.SetRateLimitHandler(function(Player: Player, Event: string?, Refused: number) Analytics:Record(Player, Event, Refused)end)The handler runs on a thread of its own, so one that yields or throws does not touch the packet being
decoded. The client module has a SetRateLimitHandler too, which does nothing – it exists so code
shared between both sides can call it without branching.
The limit is checked after the event is deserialised and before it is dispatched. Payloads are
variable length, so an event cannot be skipped without reading it: the cursor would be left mid-event
and the rest of the packet would decode as garbage. What the limit saves is the listener and the game
logic behind it. Bounding the decoding itself is what
MaxEventsPerPacket
does.
A refused function call is still answered – with a failure, so the
caller’s Invoke raises promptly rather than hanging.
See DefaultRate and RequireRates for
applying a limit across a whole schema, and for making a missing one a compile error.
Priority
Section titled “Priority”Low or Normal, the default. A low event is one the server may leave out of a send to a player whose
outbound budget is spent: chatter,
cosmetic effects, the state of something far away.
option OutboundBytesPerSecond = 20000
event Footstep { From: Server, Type: Unreliable, Call: SingleSync, Priority: Low, Data: vector}Only what the server sends unreliably can be low – an unreliable or ordered event From: Server, or a
stream. A send left out is lost, and a reliable event promises that it
is not (E3035). Without option OutboundBytesPerSecond there is no budget and a low event is sent
like any other, which is reported as a warning (W3035).
A low FireAll goes player by player rather than in one FireAllClients, since some players may be
left out of it. A low stream held per player waits for that player
instead: nothing is sent or counted as sent, and once the budget refills the player gets the state the
stream holds by then.
The data the event carries: any type, written inline or by name. Omit the field when the event carries nothing.
Type Packs
Section titled “Type Packs”Several values are sent with a type pack (commonly called a tuple): a list of
types separated by commas, inside parentheses. A pack can only be written
directly in a Data or Return field. For example, a pack of different
number types:
event MyTypePackEvent { From: Server, Type: Reliable, Call: SingleAsync, Data: (u8, u16, u32)}Elements may be named. The name is what the parameter is called in the generated code, so a listener
reads function(chefId, dish) rather than function(Value1, Value2):
event CookRequest { From: Client, Type: Reliable, Call: SingleAsync, Data: (chefId: f64, dish: string)}Naming is optional and may be partial: (u8, named: string, u16) is a valid pack, and the elements
left unnamed keep their positional Value1, Value3.
A name becomes a parameter of the generated Fire and Invoke, so it has to be usable as one: end,
local and anything that is not an identifier are refused at compile time (E3005), as are two
elements sharing a name, and the names those functions already use themselves – Player, Buffer,
Instances, Load and the like; the compiler says which. That is stricter than a
struct field, which becomes a table key and may be any string.
Inside the module the elements travel under positional names, so a name can never collide with the
serialisers’ own.
Usage in Luau
Section titled “Usage in Luau”The generated module holds one table per event, named after it (inside a scope, under the scope’s table). Which members it has depends on the side:
| Sending side | Listening side | |
|---|---|---|
From: Server |
server: Fire, FireAll, FireList, FireExcept |
client: On (or Iter), Predict |
From: Client |
client: Fire |
server: On (or Iter), Predict |
Predict exists only with option Predict. The names follow
Casing.
Firing an Event
Section titled “Firing an Event”local Net = require(path.to.Client)
Net.CookRequest.Fire(42, "Soup")local Net = require(path.to.Server)
Net.MyEvent.Fire(Player, 5) -- one playerNet.MyEvent.FireAll(5) -- every playerNet.MyEvent.FireList({ A, B }, 5) -- the players in a listNet.MyEvent.FireExcept(Player, 5) -- every player but oneNet.MyTypePackEvent.FireAll(2^8 - 1, 2^16 - 1, 2^32 - 1)A value that fails to serialise – see WriteValidations
– makes Fire throw, and whatever it had written is taken back out of the batch, so the events queued
around it are unaffected.
Listening to an Event
Section titled “Listening to an Event”Net.MyEvent.On(function(Value) -- ...end)
Net.MyTypePackEvent.On(function(Foo, Bar, FooBar) -- ...end)Net.CookRequest.On(function(Player, chefId, dish) -- ...end)On returns a function that disconnects the listener:
local Disconnect = Net.MyEvent.On(Listener)Disconnect()A listener of a Many event may disconnect itself, or another listener, from inside a dispatch
without the others missing that event. A listener disconnected during a dispatch is not called for
the rest of it, as with RBXScriptSignal, and one connected during a dispatch waits for the next event.
Events that arrive before a listener
Section titled “Events that arrive before a listener”A reliable event that arrives while nothing is listening is queued, and the queue is handed to the
listener the moment one connects – so a listener connected a frame late still sees what was sent
before it. The listener is called for each queued event the way it is for one arriving later: a
Sync listener that throws on one of them reports its error on a thread of its own, and On still
connects it, returns its disconnect, and hands the rest of the queue over. An unreliable event with no
listener is dropped.
The queue holds 256 events. On the server it is filled by clients, so once it is full further events are dropped without a word: an event the game declares but never listens to cannot be used to grow server memory or fill the output. On the client it stops at 256 too, and warns once when it fills, because there it usually means a listener somebody forgot. Before 1.0.0 the client’s queue grew for as long as the server sent and warned on every event past 256.
Packets that cannot be decoded
Section titled “Packets that cannot be decoded”A packet that fails to decode – truncated, carrying an index no event has, or failing a range – is abandoned at the event that failed, since nothing after that point can be trusted. Events decoded before it in the same packet have already been delivered. The server says nothing about it by default, so that a client sending garbage every frame cannot flood the output. To log it, flag the account or kick, install a handler:
Net.SetDecodeErrorHandler(function(Player: Player, Event: string?, Failure: string) Analytics:Record(Player, Event, Failure)end)Event is the event the packet claimed to carry, or nil if its index named no event at all – or
arrived on a remote this side receives nothing on. On the client the handler takes (Event, Failure),
and without one the client warns, naming both: from the server, a packet that cannot be decoded is a
bug rather than an attack – usually a Sync listener that yielded, or a client built from a different
schema.
On the server the handler is called at most once a second for each player, channel and event, like the rate-limit handler: a client sending a bad packet every frame costs the game one call a second, not one per packet. The server’s own warnings about what a client sent – a malformed packet, an oversized one, too many instances, too many events – keep the same schedule, per player. Either way the handler runs on a thread of its own.
An error thrown by a Sync listener is not a decode failure. It is raised as an error of its own, the
way an Async listener’s is, and the rest of the packet is still delivered.
Listeners that throw
Section titled “Listeners that throw”A listener is game code, but on the server the arguments that make it throw are a client’s, and a modified client can make one throw on every packet it sends. Raised on its own, the error names neither the player nor the event. To hear both, install a handler on the server:
Net.SetListenerErrorHandler(function(Player: Player, Event: string, Failure: string, Count: number) Analytics:Record(Player, Event, Failure, Count)end)It hears every server listener: Sync and Async, Single and Many, an event replayed from the
queue when .On connects, and a function’s – whose caller is still answered with a failure. It is
called at most once a second per player and event, on a thread of its own: Failure is the error
the call is about, and Count the errors since the last call, that one included. With a handler
installed the server prints none of these errors; without one they surface exactly as before. The
client module has the member too, accepted and ignored: a client’s listeners run what the trusted
server sent, so their errors stay in the output.
Iterating an Event (Polling)
Section titled “Iterating an Event (Polling)”An event declared Call: Polling – or any event, under
option UsePolling – has no listeners. Arriving events wait
in a queue, and Iter drains it:
event Input { From: Client, Type: Unreliable, Call: Polling, Data: (Direction: vector, Jump: boolean)}RunService.Heartbeat:Connect(function() for Index, Player, Direction, Jump in Net.Input.Iter() do -- ... endend)-- For an event sent by the server, the same loop without the Player.for Index, Value in Net.MyEvent.Iter() do -- ...endEach call to Iter returns an iterator that removes what it yields, so a second loop in the same frame
sees only what arrived since. Next is the same function under its old name, deprecated since 0.14.1 and
still generated beside Iter so older code keeps working; use Iter.
On the server a polled event’s queue is filled by clients, so it stops taking events once it holds 256
for each player the server can hold (256 * Players.MaxPlayers). A game that iterates its polled
events every frame never comes near that; one that leaves an event unpolled no longer lets clients grow
it without end.
