Remotes

Write a signature in a remote's .instance file, and shot check checks what crosses the network and every Fire and handler against it.

The line

A remote's .instance file holds its class (Files). Write the signature after it:

Wallet/WalletBoard.instance

RemoteEvent (board : Board)

Wallet/WalletGift.instance

RemoteFunction (to : number, amount : number) : GiftReply
  • The parameters are what follows the Player Roblox adds: FireClient(player, board) sends board, and the server's handler gets (player, to, amount). Every parameter has a name and a type.
  • The types resolve like the folder's own files: its system's Types.shot (its types and its imports), Roblox types and string unions. A trailing optional (note : string?) may be left out of a call.
  • Only a RemoteFunction has a return type (: GiftReply): what the server's OnServerInvoke returns.
  • RemoteEvent, UnreliableRemoteEvent and RemoteFunction take a signature. Compiled, the file is still just its class: a signature changes no Luau.
  • A remote without a signature is checked as before (nothing here applies to it).

A whole example

Wallet/Types.shot

public type Gift = { From : string, Amount : number }

public type Board = {
    Total : number,
    ByReason : {[string] : number},
    Gifts : {Gift},
}

public type GiftReply = { Success : boolean, Reason : string? }

Wallet/WalletServer.shot

types script.Parent.Types

local wallet = script.Parent
local boardRemote = wallet.WalletBoard :: RemoteEvent
local giftRemote = wallet.WalletGift :: RemoteFunction

local coins : {[Player] : number} = {}
local gifts : {Gift} = {}

local function boardFor(player : Player) : Board
    local mine : {Gift} = {}
    for _, gift in gifts do
        if gift.Amount > 0 then
            table.insert(mine, gift)
        end
    end
    return { Total = coins[player] or 0, ByReason = { Prize = 5 }, Gifts = mine }
end

local function give(player : Player, to : any, amount : any) : GiftReply
    if type(amount) ~= "number" or amount <= 0 then
        return { Success = false, Reason = "Bad amount" }
    end
    table.insert(gifts, { From = player.Name, Amount = amount })
    boardRemote:FireClient(player, boardFor(player))
    return { Success = true, Reason = nil }
end

server module WalletServer = {
    function Start()
        giftRemote.OnServerInvoke = give
    end
}

Wallet/WalletClient.shot

types script.Parent.Types

local wallet = script.Parent
local boardRemote = wallet.WalletBoard :: RemoteEvent
local giftRemote = wallet.WalletGift :: RemoteFunction

client module WalletClient = {
    Board : Board? = nil

    function Start()
        boardRemote.OnClientEvent:Connect(function(board : Board)
            module.Board = board
        end)
    end

    function Give(to : number, amount : number) : GiftReply
        return giftRemote:InvokeServer(to, amount)
    end
}

The server's handler takes to : any and amount : any and checks them: a client can send anything, so any is always allowed in a handler.

What crosses the network

shot check walks every parameter's type and the return type, through named types, fields, lists, maps and unions, and reports each part that doesn't arrive as it was sent, at the field's declaration:

  • a number-keyed map {[number] : T}: its keys arrive as strings ("1002"), so a lookup by number on the other side reads nil. Key it by tostring(id) ({[string] : T}). A list {T} is fine.
  • a list that can hold nil {T?}: it arrives cut at the first nil.
  • a table with a list part and string keys ({ [number] : T, Name : string }): only the list part arrives.
  • a function: it can't cross (it arrives as nil, or the send errors).
  • a class instance: it arrives as a plain table, its methods and metatable lost.

Change ByReason in Board to ByUser : {[number] : number} and shot check says:

Wallet/Types.shot:5   Board.ByUser : {[number] : number} goes through WalletBoard: Roblox sends number keys as strings ("1002"), so a lookup by number on the other side reads nil; key it by tostring(id) ({[string] : number})

shot run logs the same thing at run time ("table key 1002 (number) became a string", "array argument has a gap"); check finds it before anything runs.

Holes

A list the payload sends, filled by index inside an if, has a hole where the if skipped an index, and Roblox sends a list cut at its first hole. Write the loop in boardFor as

for index, gift in gifts do
    if gift.Amount > 0 then
        mine[index] = gift
    end
end

and shot check says:

Wallet/WalletServer.shot:14   mine[index] = fills mine, the {Gift} that goes into Gifts and through WalletBoard, by index inside an if: when the if skips an index, Roblox sends the list cut at the first hole and the items after it are lost; build it with table.insert(mine, ...)

It is checked for a local typed as a list of a type the payload sends in a list (local mine : {Gift} = {}), filled by index inside an if and put into a table (Gifts = mine) in the same function.

Typed fire and handle

In the remote's system (its folder and the folders under it), a local bound to it (local boardRemote = wallet.WalletBoard :: RemoteEvent, or script.Parent.WalletBoard used directly) is checked:

  • FireClient(player, ...), FireAllClients(...), FireServer(...) and InvokeServer(...) send as many values as the signature has parameters (trailing optional ones may be left out);
  • the function given to OnServerEvent:Connect, OnClientEvent:Connect or OnServerInvoke (a function literal, or a local function named there) reads no more values than the signature sends, each typed parameter has the signature's type or any, and an OnServerInvoke handler's return type is the signature's.

Write boardRemote:FireClient(player, boardFor(player), amount) and shot check says:

Wallet/WalletServer.shot:25   WalletBoard:FireClient sends 2 values, but its signature (board : Board) takes 1: the handler never reads the extra ones; send what the signature says, or add the parameter in Wallet/WalletBoard.instance

What it doesn't check

  • The types of the values sent: a call is checked by its count, a handler by its declared parameter types. A value built without a known type is not followed into the payload.
  • Holes in a list that reaches the payload in another function, through a field set afterwards, or filled by index outside an if (a loop over 1..n with no if fills every index).
  • A remote reached from another system, by a path built at run time, or passed around as a value.
  • A remote fired both ways (a client FireServer() asking for a resend of what the server sends) has one signature: make the parameters optional (payload : Board?).

Errors

Board.ByUser : {[number] : number} goes through WalletSnapshot: Roblox sends number keys as strings ("1002"), so a lookup by number on the other side reads nil; key it by tostring(id) ({[string] : number})
Board.Slots : {Gift?} goes through WalletSnapshot: a list arrives cut at the first nil, so the items after it are lost; send {Gift} without the empty ones, built with table.insert
Board.Mixed : {[number] : string, Name : string} goes through WalletSnapshot: a table with an array part and string keys arrives as its array part only (the string keys are lost); split it into a list field and a record field
Board.OnPaid : (number) -> () goes through WalletSnapshot: a function can't cross the network (it arrives as nil, or the send errors); send the data it would compute instead
Board.Bank : Bank goes through WalletSnapshot: a Bank arrives as a plain table, its methods and metatable lost; send a type of plain fields and rebuild the Bank on the other side
list[index] = fills list, the {Gift} that goes into Gifts and through WalletSnapshot, by index inside an if: when the if skips an index, Roblox sends the list cut at the first hole and the items after it are lost; build it with table.insert(list, ...)
WalletSnapshot:FireAllClients sends 2 values, but its signature (board : Board) takes 1: the handler never reads the extra ones; send what the signature says, or add the parameter in Wallet/WalletSnapshot.instance
WalletRequest:InvokeServer sends 0 values, but amount : number isn't optional: the handler gets nil for amount; pass it, or make it optional (amount : number?) in Wallet/WalletRequest.instance
WalletRequest.OnServerInvoke's handler takes amount : string, but the signature sends amount : number: write amount : number (or any, checked before use), or change the signature in Wallet/WalletRequest.instance
WalletSnapshot.OnClientEvent's handler reads 2 values, but the signature (board : Board) sends 1: extra is always nil; drop it, or add the parameter in Wallet/WalletSnapshot.instance
Broken's signature can't be read: every parameter is name : Type (amount); write RemoteEvent (name : Type, ...)
Ping is a RemoteEvent, which returns nothing: drop ": Reply" (only a RemoteFunction has a return type)
a signature goes on a RemoteEvent, UnreliableRemoteEvent or RemoteFunction, not a Folder: leave this file just Folder