Skip to content

v1.4.0 — Networking on Twill's own wire format

Back to all updates
v1.4.0August 20, 2026

Net has a built-in wire format, and a corrupt call costs only itself.

Declarations are the only call sites that change.

-- before
local Packet = require("@game/ReplicatedStorage/Twill/Packages/Packet")
Net.Declare("BuyItem", { Packet.String }, { Packet.Boolean8 })
-- after
local Types = Net.Types
Net.Declare("BuyItem", { Types.String(32) }, { Types.Boolean })

Handle, IsHandled, Get and List are unchanged. Twill.Packet and the bundled Packet package are removed.

Twill.Net has its own wire format, and every call carries the length of its own body. That field, four bytes per call, does three things:

  • A corrupt call stops at its own frame. The reader moves to where the body ends whether or not reading it worked, so the calls behind it still arrive.
  • A refused call is never decoded. Rate limiting skips a call it has already decided to drop.
  • A hostile length is bounded. It is checked against the frame it arrived in before anything is allocated.
1 = payload1 | 2 = refused | 3 = payload3
local Net = require("@game/ReplicatedStorage/Twill/Net")
local Types = Net.Types
local buy = Net.Declare("BuyItem", { Types.String(32) }, { Types.Boolean })
local aim = Net.DeclareUnreliable("Aim", { Types.Vector3F32 })

Both sides compile the same declaration, so no type information travels. A struct sends its values without field names, and a thousand booleans take 125 bytes.

Types.String(32) is a ceiling enforced at the sender:

BuyItem argument 1 is 300 bytes, past its ceiling of 32

Before, a string past 255 bytes wrapped its length prefix and corrupted everything written after it.

Clients cannot name players or reach server threads

Section titled “Clients cannot name players or reach server threads”

Types.Player is refused in an argument list. The caller already arrives as the handler’s first argument, from the engine:

'Trade' argument 1 names a player, which only the server may say;
the caller is given to the handler already

Only a client asks questions. The server never waits on a client, and the number that identifies a question is looked up only in the asker’s own table.

An Instance sent by a client is still the client’s choice. The decoder checks that it exists and is the class you named, but not that the caller may touch it. Check that in Validate.

The bytes of a whole message are weighed before it is opened, and each call is metered by name before its arguments are decoded. Batching many calls into one message costs what those calls weigh. A joining player is metered from their first call.

A remote declared on a client receives its number from the server a moment later. A call made before then is held and sent once the number arrives:

Net.OnReady(function()
-- every remote declared here has its number
end)

Replication waits on Net.OnReady instead of polling for up to 30 seconds.

  • Half floats round-trip very small values, both infinities, and NaN.
  • Types.CFrameRot refuses a CFrame that scales, skews or mirrors, instead of flattening it. Types.CFrame keeps all twelve components.
  • A constant outside a declared set is refused when sent, instead of arriving as nil.
  • A union tag naming an undeclared member is refused, instead of selecting an arbitrary decoder.
  • Net.List reports each remote’s number alongside its signature.

Every change in this release is listed in the changelog.