Net
local Net = require("@game/ReplicatedStorage/Twill/Net")local Types = Net.Types
local buy = Net.Declare("BuyItem", { Types.String(32) }, { Types.Boolean })
Net.Handle(buy, function(player, itemId) return grant(player, itemId)end, { Rate = 4, Reject = function() return false end })Only declaration lives in ReplicatedStorage, because only declaration runs on
both sides. Metering, screening, and every player’s current allowance stay in the
server half, which is never replicated.
The wire format is Twill’s own. Nothing describing a type is ever sent: both sides compiled the same declaration, so what travels is the value.
Declaring
Section titled “Declaring”Declaring the same name twice hands back the same remote. Two scripts that both need a remote each declare it, neither has to know the other exists, and load order does not matter.
The types must agree. A clashing redeclaration is refused:
'BuyItem' was already declared as (String) and cannot be redeclared as (NumberVarU)The comparison follows nesting and ignores the order keys were written in, so two spellings of the same struct are the same declaration.
Types are passed as arrays so one guard covers the arguments and the reply together. An array cannot carry a type pack.
The order the checks run in
Section titled “The order the checks run in”Cheapest first, so a caller flooding one remote is turned away before their arguments are read.
- Weight. The whole message is weighed before a single call is opened, instances included.
- Still here. A player who has left is dropped without a word.
- Declared and served. A call naming a remote nothing serves goes no further.
MinimumRank. Refused before spending any allowance.Rate. Always applied, whether or not it was asked for.Schema. Each rule against the argument in the same position.Validate. The caller’s own test, last, because only they know what it costs.
Steps 1 to 5 run before the arguments are decoded. Each call carries the length of its own body, so a refused one is stepped over rather than read. That is also why a corrupt call costs only itself: every call behind it in the same message still arrives.
A call refused at steps 1 to 5 was never read, so there are no arguments to hand
to Reject. A remote that replies answers such a call without it, and Ask
returns nothing.
Logging once per refusal would make the log the flood. Refusals are reported at most once every five seconds per player and remote, with a count of how many went unreported.
Firing before the network is up
Section titled “Firing before the network is up”The server hands out the numbers, so it is ready as soon as it has declared. A client becomes ready once that numbering reaches it, a moment later.
Declaring never waits. A call made before the number arrives is held and sent once it does, up to 64 calls per remote, after which the oldest is dropped with a line naming the remote.
Net.OnReady(function() -- everything declared here knows its numberend)An unreliable call is not held. A name declared on a client that the server never declares is reported once, and calls on it are dropped locally.
What a hostile client cannot do
Section titled “What a hostile client cannot do”The decoder on a player’s machine and the decoder on the server are the same code, so everything a peer sends is read under the same rules on both sides.
- A length that reaches past the message it arrived in is refused before anything is allocated for it.
- A corrupt call costs its own frame. The calls behind it still arrive.
- A call naming a remote nobody declared, or nobody serves, is stepped over unread.
- An answer to a question the server never asked reaches nothing.
- A message claiming more calls than one message carries is cut off.
- A payload larger than a message may be is dropped before it is read.
Measuring traffic
Section titled “Measuring traffic”Studio reports no network traffic during a playtest, so bandwidth has to be read
on a live server. Each side counts what it sends, receives and refuses, and
Net.GetStats reads the count back. Counting costs a few additions per call, so
it stays on.
Net.ResetStats()task.wait(60)
local stats = Net.GetStats()local kbps = stats.Received.Bytes * 8 / 1000 / stats.SecondsThe byte counts are the payload Twill builds. The engine adds framing of its own
to every message, which no script can see, so the real figure is somewhat
higher. Answers to Ask are counted under (replies). A call is counted
against its remote only once that remote is known to be declared, so a client
naming made up remotes adds to the totals and never to the list.
Every refusal is counted under one reason:
| Reason | Meaning |
|---|---|
Budget | The sender went over the byte budget. |
Malformed | The message was not a payload, was in another format, or held too many calls. |
Unknown | The call named a remote nobody declared. |
Unserved | The remote has no handler. |
Rank | The sender is below MinimumRank. |
Rate | The sender went over the remote’s rate. |
Schema | An argument broke its Schema rule. |
Validate | Validate refused the call or raised. |
Error | The handler raised. |
Deadline | The handler took longer than the reply deadline. |
Unreadable | The call could not be decoded. |
Unwritable | A value could not be encoded, so the call was never sent. |
Oversize | An unreliable call was larger than one unreliable message. |
The admin console shows the same count with twill traffic.
Net.Declare
Section titled “Net.Declare”[Server] | [Client]
Declares a remote, or hands back the matching one already declared.
function Net.Declare(name: string, args: { any }?, response: { any }?): RemoteParameters
| Name | Type | Description |
|---|---|---|
name | string | The remote’s name, shared by both sides. Must not be empty. |
args | { any }? | The argument types, in order. No arguments when left out. |
response | { any }? | The reply types. Passing this lets a client Ask, which then requires a Reject on the server. |
Returns
Remote - The handle to fire, or to hand to Handle.
Throws when the name was already declared with different types, and when an argument names a player.
Net.DeclareUnreliable
Section titled “Net.DeclareUnreliable”[Server] | [Client]
Declares a remote whose calls may be dropped rather than delayed.
function Net.DeclareUnreliable(name: string, args: { any }?): RemoteThere is no response parameter, so a reply cannot be attached. Use it for state
a newer message supersedes: aim direction, cosmetic effects, footsteps. Never for
anything transactional.
A call larger than one unreliable send carries is dropped with a line naming the remote, never truncated and never dropped in silence.
Net.Get
Section titled “Net.Get”[Server] | [Client]
Returns a remote somebody already declared.
function Net.Get(name: string): RemoteThrows when nothing has declared that name. Use it rather than a second Declare
when a misspelled name should fail loudly instead of creating a second remote.
Net.List
Section titled “Net.List”[Server] | [Client]
Reports every remote declared so far, mapped to its number and type signature.
function Net.List(): { [string]: string }This is the audit of what has been declared. Read it once everything has finished declaring.
Net.IsReady
Section titled “Net.IsReady”[Server] | [Client]
Reports whether this side knows the number for every remote it declared.
function Net.IsReady(): booleanNet.OnReady
Section titled “Net.OnReady”[Server] | [Client]
Runs a callback once this side can send, at once when it already can.
function Net.OnReady(callback: () -> (), bag: Scope.Bag?)Pass a Scope bag and the wait is given up with it.
Net.AwaitReady
Section titled “Net.AwaitReady”[Server] | [Client]
Waits until this side can send, giving up rather than never.
function Net.AwaitReady(timeout: number?): booleanReturns
boolean - true when the network came up in time. Yields. The default wait is
30 seconds.
Net.GetStats
Section titled “Net.GetStats”[Server] | [Client]
Returns what this side sent, received and refused since the count began.
function Net.GetStats(): StatsReturns
Stats - A copy, safe to keep or change:
| Field | Type | Description |
|---|---|---|
Seconds | number | How long the count has run. |
Sent | Traffic | Messages, Calls, Bytes and Instances this side sent. |
Received | Traffic | The same, for what arrived. |
Refused | { [string]: number } | Refusals by reason. |
Remotes | { [string]: RemoteStats } | Per remote name: Sent and Received, each with Calls and Bytes, and Refused by reason. |
Net.ResetStats
Section titled “Net.ResetStats”[Server] | [Client]
Starts every count over from now.
function Net.ResetStats()Reset before a window of play to measure that window alone.
Net.Handle
Section titled “Net.Handle”[Server]
Serves a remote, metered and screened before the handler runs.
function Net.Handle(remote: Remote, handler: (player: Player, ...any) -> ...any, options: Options?)Parameters
| Name | Type | Description |
|---|---|---|
remote | Remote | The remote to serve. |
handler | (player: Player, ...any) -> ...any | Receives the calling player first, then the arguments. A remote that replies returns its reply from here. |
options | Options? | See Options. |
Throws when the remote already has a handler, when a remote that replies is given
no Reject, when the rate is not above zero, and when an unreliable remote is
given a Reject.
A handler that raises is treated as a refusal: the error is reported, and a
remote that replies answers with its Reject rather than leaving the caller
waiting. A handler that never returns is answered for after 10 seconds, so an
Ask cannot hang on a handler that hangs.
Net.IsHandled
Section titled “Net.IsHandled”[Server]
Reports whether a remote already has a handler.
function Net.IsHandled(remote: Remote): booleanLets a module leave a remote alone rather than claiming it and being refused.
The remote handle
Section titled “The remote handle”Remote:Fire
Section titled “Remote:Fire”[Client]
Sends a call to the server.
function Remote:Fire(...: any): booleanReturns
boolean - false when the call could not be written, and so will never
arrive.
Remote:FireClient
Section titled “Remote:FireClient”[Server]
Sends a call to one player, to several, to everyone, or to everyone but one.
function Remote:FireClient(player: Player, ...: any): booleanfunction Remote:FireClients(players: { Player }, ...: any)function Remote:FireAllClients(...: any)function Remote:FireAllExcept(except: Player, ...: any)Calls made in the same frame leave together as one message. FireClient answers
false when the call could not be written, such as a value Types.Any cannot
carry, and so will never arrive.
Remote:Ask
Section titled “Remote:Ask”[Client]
Asks the server, and always ends: with the answer, with the Reject, or with
nothing when the wait runs out.
function Remote:Ask(...: any): ...anyYields. Throws when the remote was not declared with a reply, and when this side is already waiting on 256 answers.
When the server has not numbered the remote yet, Ask waits for that first, for
up to the same timeout, rather than giving up at once.
Ask is client to server only. The server never waits on an answer from a
client, so there is no server thread a client’s message can reach. A game that
needs to ask a client something composes two events and its own correlation id,
in game code, where the fact that the answer is untrusted is visible.
The number identifying a question is the asker’s own and is looked up only in the asker’s own table, so nothing arriving from the far side can reach a thread waiting on an answer.
Remote:Connect
Section titled “Remote:Connect”[Client]
function Remote:Connect(callback: (...any) -> (), bag: any?): Connectionfunction Remote:Once(callback: (...any) -> (), bag: any?): ConnectionThrows on the server. A remote is served there with Handle, because a listener
would have no metering, and metering is not optional.
The connection carries Disconnect and Destroy, so a bag holds it like
anything else. Listeners run from a copy of the list, so giving one up from
inside another is safe.
Remote:Wait
Section titled “Remote:Wait”[Client]
Waits for the next call on this remote.
function Remote:Wait(timeout: number?): ...anyReturns
...any - The call’s arguments, or nothing when the wait ran out. Yields. The
default wait is 10 seconds.
Throws on the server, for the same reason as Connect.
Every argument is declared with a token from Net.Types. A token says what a
value is, so the wire never has to.
Numbers
Section titled “Numbers”| Token | Bytes | Range |
|---|---|---|
NumberU8 NumberU16 NumberU32 | 1, 2, 4 | whole, not negative |
NumberI8 NumberI16 NumberI32 | 1, 2, 4 | whole, either sign |
NumberVarU | 1-5 | whole, not negative, small ones cost less |
NumberVarI | 1-5 | whole, either sign, small ones cost less |
NumberF16 NumberF32 NumberF64 | 2, 4, 8 | fractional |
NumberVarU and NumberVarI cost one byte for values under 128 and grow only as
the value does, which removes the width choice that overflow bugs come from.
They stop at five bytes. NumberVarU reaches 34359738367, and NumberVarI
reaches from -17179869184 to 17179869183. A value past either end is refused at
the sender. Above that range, carry a BigNumber as
text.
The whole number tokens refuse a fraction at the sender, the same way they refuse a value past either end, rather than cutting it.
NumberF16 is a real half, including the very small values a naive
implementation drops to zero, both infinities, and a NaN that stays a NaN.
Text and bytes
Section titled “Text and bytes”| Token | Notes |
|---|---|
String | Types.String(maximum) sets the ceiling. 65536 bytes when unset. |
Buffer | The same, for a buffer. |
Calls are gathered into messages of up to 60 KB. A single call larger than that still travels, as a message of its own, so the ceiling is the field’s alone.
The ceiling is enforced on the sender, naming the field:
BuyItem argument 1 is 300 bytes, past its ceiling of 32Every string carries its length, so any byte is safe to send, including a zero byte.
Positions and rotations
Section titled “Positions and rotations”| Token | Bytes | Keeps |
|---|---|---|
Vector2F16 Vector2F32 | 4, 8 | |
Vector3F16 Vector3F32 | 6, 12 | |
Vector3Fixed(scale) | 6 | whole steps of the declared scale |
CFrame | 48 | everything, including skew and reflection |
CFrameRot | 16 | rigid transforms only |
CFrameRotF16 | 10 | rigid, at lower positional precision |
CFrameRot and CFrameRotF16 check on the way out. A transform that scales,
skews, or mirrors is refused rather than silently flattened:
a CFrameRot is not a plain rotation, so it needs the full CFrame typeEverything else
Section titled “Everything else”Boolean, Nil, Color3 (three bytes), Color3F32, BrickColor, UDim,
UDim2, Rect, Region3, NumberRange, TweenInfo, DateTime,
NumberSequence, ColorSequence.
Compound
Section titled “Compound”| Constructor | Meaning |
|---|---|
Types.Array(element, maximum?) | A run of one repeated element |
Types.Struct({ Field = token }) | Fixed named fields, whose names never reach the wire |
Types.Map(key, value, maximum?) | Keys and values whose count is only known when sent |
Types.Optional(token) | A value that may be absent, one byte to say which |
Types.Union(a, b, ...) | One of a closed set of shapes |
Types.Enum(Enum.KeyCode) | One item of one enum, sent as its place in it |
Types.Static({ "alpha", "beta" }) | One of a closed set of constants |
Types.Any | A value of a shape nobody declared |
An array of booleans, of one fixed-width number type, or of strings takes a path that sends the whole run at once. A thousand booleans is 125 bytes.
A struct sends only its values. Both sides know the names and the order already.
Union and Static are closed at declaration. A tag naming a member the union
was not declared with is refused on arrival, and a constant nobody listed is
refused on the way out.
A union picks its member from the value it is handed, so its members have to be tellable apart by that value alone. Two that are not are refused at the declaration:
BuyItem argument 1 names 2 members that all look like table when sent,and nothing says which one a value meantMembers of the same kind at different widths are fine as long as the widest is
named, since the widest carries what the others could. Union(NumberU8, NumberF64) keeps its double, and Union(Vector3F32, Vector3F16) keeps its
precision. Union(NumberVarU, NumberF32) names no such member and is refused.
Types.Any
Section titled “Types.Any”For a payload whose shape nobody can declare. Every value carries a tag, so it costs more than a declared type. It is bounded rather than open ended, and the bounds depend on the direction a value travels:
| Ceiling | Client to server | Server to client |
|---|---|---|
| Depth | 16 | 64 |
| Parts in one call | 4096 | 1048576 |
| Entries in one table | 4096 | 65535 |
| Bytes in one string or buffer | 65536 | 1048576 |
A client is held to the narrow bounds on both ends, because the server reading it faces a peer it cannot trust. The server is held to the wide ones, because a client has nothing to fear from the server, and replicated state routinely outgrows what one call from a player should carry.
A table that reaches itself is refused rather than encoded. A value no format carries is refused with the path to it:
value.Inventory[3].onClick is a function, which Any cannot carryPass Types.Any({ OnUnencodable = "skip" }) to leave such a field out instead.
Instances and players
Section titled “Instances and players”Types.Instance sends a reference beside the payload. Types.Instance("Model")
makes the receiver check the class as well.
A decoded instance may be nil. The engine delivers nothing for an instance that
is not replicated to that side or has streamed out.
An instance from a client is the client’s choice. The decoder guarantees the
shape: that it exists, that it is an instance, that it is the class named. It
does not guarantee that the caller may touch it. Ownership and reach belong in
Validate.
Types.Player may not appear in an argument list:
'Trade' argument 1 names a player, which only the server may say;the caller is given to the handler alreadyA player named on the wire is a player the sender chose. The caller arrives as
the handler’s first argument, from the engine, and cannot be forged. For a
server-to-client message about somebody else, use Types.Instance("Player").
Options
Section titled “Options”export type Options = { Rate: number?, MinimumRank: number?, Schema: { Rewrite }?, Validate: ((player: Player, ...any) -> boolean)?, Reject: ((player: Player, ...any) -> ...any)?,}Calls per second, per player, per remote. Always applied, whether or not it is passed, so a rate limit cannot be forgotten. Left out, a remote is metered at 20 calls a second.
Below one it is a cooldown. Rate = 0.25 permits one call every four seconds.
The burst never falls below a single token, so the first call is allowed however
slow the refill. A rate of zero or less is refused at Handle.
A separate budget weighs everything a player sends, across every remote, before any of their calls are opened. Batching many calls into one message costs what those calls weigh rather than what one message costs.
Instances travel beside a payload rather than inside it, so they are weighed separately and charged a flat rate each.
Metering lives in Limit, which creates a player’s standing
the moment it is needed and forgets it when they leave. There is no window in
which a caller is unmetered.
MinimumRank
Section titled “MinimumRank”Refuses anyone whose Authorization rank falls
short. Checked on the server, before the caller spends any allowance.
Schema
Section titled “Schema”A list of Schema rules applied positionally to the
arguments, for shapes and ranges a wire type cannot express: a string between 1
and 40 characters, a number in a range, a table of a particular shape.
The wire type already guarantees the argument types. A rule that says only a type
name repeats what the declaration promised. Leave an entry nil to skip an
argument.
Validate
Section titled “Validate”Validate = function(player, itemId) return owns(player, itemId)endFor what neither types nor schemas can express: ownership, game state, cooldowns
tracked elsewhere. An Instance argument from a client belongs here, since the
decoder proved its shape and not the caller’s right to it.
Return exactly true to admit the call. Anything else refuses it, and a
validator that raises refuses it too.
Reject
Section titled “Reject”Reject = function(player, itemId) return false, "not allowed"endWhat to reply with when a call is refused by any check above, or when the handler raises or hangs.
A remote that replies must have a Reject. Without one a refused call leaves its
caller waiting, so Handle is refused instead:
'BuyItem' replies to the caller, so it needs a Reject optionA remote that does not reply may leave it out. A refused call there is dropped.
Limits
Section titled “Limits”| Limit | Value |
|---|---|
| Remotes per game | 65535 |
| Calls per message | 200 |
| Reliable message size | 60 KB |
| Unreliable message size | 880 bytes |
| String and buffer ceiling, unset | 65536 bytes |
| Default rate, per player per remote | 20 per second |
| Byte budget, per player | 8000 per second, 24000 burst |
| Charged per message, on top of its bytes | 40 |
| Questions waiting at once | 256 |
| Calls held before the network is up | 64 per remote |
Ask and Wait default timeout | 10 seconds |
| Reply deadline on the server | 10 seconds |
AwaitReady default timeout | 30 seconds |
| Client send interval | 1/60 second |
| Refusal reports | 1 per 5 seconds, per player and remote |
| Reports of a call that could not be written | 1 per 5 seconds, per remote |
Types.Any bounds | See Types.Any |