Skip to content

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 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.

Cheapest first, so a caller flooding one remote is turned away before their arguments are read.

  1. Weight. The whole message is weighed before a single call is opened, instances included.
  2. Still here. A player who has left is dropped without a word.
  3. Declared and served. A call naming a remote nothing serves goes no further.
  4. MinimumRank. Refused before spending any allowance.
  5. Rate. Always applied, whether or not it was asked for.
  6. Schema. Each rule against the argument in the same position.
  7. 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.

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 number
end)

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.

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.

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.Seconds

The 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:

ReasonMeaning
BudgetThe sender went over the byte budget.
MalformedThe message was not a payload, was in another format, or held too many calls.
UnknownThe call named a remote nobody declared.
UnservedThe remote has no handler.
RankThe sender is below MinimumRank.
RateThe sender went over the remote’s rate.
SchemaAn argument broke its Schema rule.
ValidateValidate refused the call or raised.
ErrorThe handler raised.
DeadlineThe handler took longer than the reply deadline.
UnreadableThe call could not be decoded.
UnwritableA value could not be encoded, so the call was never sent.
OversizeAn unreliable call was larger than one unreliable message.

The admin console shows the same count with twill traffic.

[Server] | [Client]

Declares a remote, or hands back the matching one already declared.

function Net.Declare(name: string, args: { any }?, response: { any }?): Remote

Parameters

NameTypeDescription
namestringThe 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.

[Server] | [Client]

Declares a remote whose calls may be dropped rather than delayed.

function Net.DeclareUnreliable(name: string, args: { any }?): Remote

There 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.

[Server] | [Client]

Returns a remote somebody already declared.

function Net.Get(name: string): Remote

Throws 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.

[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.

[Server] | [Client]

Reports whether this side knows the number for every remote it declared.

function Net.IsReady(): boolean

[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.

[Server] | [Client]

Waits until this side can send, giving up rather than never.

function Net.AwaitReady(timeout: number?): boolean

Returns

boolean - true when the network came up in time. Yields. The default wait is 30 seconds.

[Server] | [Client]

Returns what this side sent, received and refused since the count began.

function Net.GetStats(): Stats

Returns

Stats - A copy, safe to keep or change:

FieldTypeDescription
SecondsnumberHow long the count has run.
SentTrafficMessages, Calls, Bytes and Instances this side sent.
ReceivedTrafficThe 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.

[Server] | [Client]

Starts every count over from now.

function Net.ResetStats()

Reset before a window of play to measure that window alone.

[Server]

Serves a remote, metered and screened before the handler runs.

function Net.Handle(remote: Remote, handler: (player: Player, ...any) -> ...any, options: Options?)

Parameters

NameTypeDescription
remoteRemoteThe remote to serve.
handler(player: Player, ...any) -> ...anyReceives the calling player first, then the arguments. A remote that replies returns its reply from here.
optionsOptions?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.

[Server]

Reports whether a remote already has a handler.

function Net.IsHandled(remote: Remote): boolean

Lets a module leave a remote alone rather than claiming it and being refused.

[Client]

Sends a call to the server.

function Remote:Fire(...: any): boolean

Returns

boolean - false when the call could not be written, and so will never arrive.

[Server]

Sends a call to one player, to several, to everyone, or to everyone but one.

function Remote:FireClient(player: Player, ...: any): boolean
function 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.

[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): ...any

Yields. 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.

[Client]

function Remote:Connect(callback: (...any) -> (), bag: any?): Connection
function Remote:Once(callback: (...any) -> (), bag: any?): Connection

Throws 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.

[Client]

Waits for the next call on this remote.

function Remote:Wait(timeout: number?): ...any

Returns

...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.

TokenBytesRange
NumberU8 NumberU16 NumberU321, 2, 4whole, not negative
NumberI8 NumberI16 NumberI321, 2, 4whole, either sign
NumberVarU1-5whole, not negative, small ones cost less
NumberVarI1-5whole, either sign, small ones cost less
NumberF16 NumberF32 NumberF642, 4, 8fractional

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.

TokenNotes
StringTypes.String(maximum) sets the ceiling. 65536 bytes when unset.
BufferThe 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 32

Every string carries its length, so any byte is safe to send, including a zero byte.

TokenBytesKeeps
Vector2F16 Vector2F324, 8
Vector3F16 Vector3F326, 12
Vector3Fixed(scale)6whole steps of the declared scale
CFrame48everything, including skew and reflection
CFrameRot16rigid transforms only
CFrameRotF1610rigid, 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 type

Boolean, Nil, Color3 (three bytes), Color3F32, BrickColor, UDim, UDim2, Rect, Region3, NumberRange, TweenInfo, DateTime, NumberSequence, ColorSequence.

ConstructorMeaning
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.AnyA 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 meant

Members 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.

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:

CeilingClient to serverServer to client
Depth1664
Parts in one call40961048576
Entries in one table409665535
Bytes in one string or buffer655361048576

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 carry

Pass Types.Any({ OnUnencodable = "skip" }) to leave such a field out instead.

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 already

A 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").

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.

Refuses anyone whose Authorization rank falls short. Checked on the server, before the caller spends any allowance.

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 = function(player, itemId)
return owns(player, itemId)
end

For 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 = function(player, itemId)
return false, "not allowed"
end

What 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 option

A remote that does not reply may leave it out. A refused call there is dropped.

LimitValue
Remotes per game65535
Calls per message200
Reliable message size60 KB
Unreliable message size880 bytes
String and buffer ceiling, unset65536 bytes
Default rate, per player per remote20 per second
Byte budget, per player8000 per second, 24000 burst
Charged per message, on top of its bytes40
Questions waiting at once256
Calls held before the network is up64 per remote
Ask and Wait default timeout10 seconds
Reply deadline on the server10 seconds
AwaitReady default timeout30 seconds
Client send interval1/60 second
Refusal reports1 per 5 seconds, per player and remote
Reports of a call that could not be written1 per 5 seconds, per remote
Types.Any boundsSee Types.Any