Remotes that say what they carry
ShotScript

Remotes that say what they carry

By ShotScript Team4 min

A RemoteEvent takes whatever you hand it, and some of it doesn't survive the trip. A table keyed by UserId arrives with its keys turned into strings. A list stops at its first nil. An object turns up as a plain table with its methods gone. Most of the time nothing tells you. The other side just reads nil, usually once real players are in the server.

Since 16 October a remote in a ShotScript project can carry a signature, and shot check holds everything that goes through it to that line.

One line in the .instance file

In a ShotScript project a remote is a small file in its system's folder, and the file holds its class. Our example is the lap board of a kart racing game. Laps/LapBoard.instance sends the board to every player after each lap, and Laps/LapRank.instance lets a player ask where they placed. The signature goes after the class:

RemoteEvent (board : Board)
RemoteFunction (track : string) : RankReply

The parameters are what comes after the Player that Roblox adds. FireAllClients(board) sends one value, and the server's handler for LapRank gets (player, track). Only a RemoteFunction has a return type, which is what OnServerInvoke hands back. The types are the system's own, from its Types.shot:

public type Lap = { Name : string, Seconds : number }

public type Board = {
	Best : {[string] : number},
	Top : {Lap},
}

public type RankReply = { Place : number, Reason : string? }

A signature adds nothing to the Luau the project compiles to, so the game is the same with or without one. The scripts reach both remotes with use LapBoard and use LapRank, the same line that reaches a module, and since 20 October the checks follow a remote reached that way. This is the server sending the board when someone finishes a lap:

function Finish(player : Player, seconds : number)
	local key = tostring(player.UserId)
	if not best[key] or seconds < best[key] then
		best[key] = seconds
	end
	table.insert(finished, { Name = player.Name, Seconds = seconds })
	LapBoard:FireAllClients(boardFor())
end

Keys that turn into strings

Best laps keyed by UserId is the obvious way to write it, so that's how we wrote it first, with Best : {[number] : number}. With the signature on LapBoard, shot check stops at the type itself:

LapBoard.instance with the signature RemoteEvent (board : Board), the Board type with Best keyed by number, and shot check's error at that line saying Roblox sends number keys as strings and to key it by tostring(id)

To see what that saves you from, we took the signature out, so the file just says RemoteEvent, and wrote a scenario. Alice finishes a lap in 41.3 seconds and Bo in 44.8, then Alice's client looks her up on the board it got. Check passed it with 0 errors. The run didn't.

shot check with no signature on LapBoard printing 0 errors, then shot run Rank printing table key 1001 (number) became a string, Alice's best on her board: nil, Top laps on her board: 2, and the scenario FAILED

The board did arrive, and both laps are in Top. Her best time is there too, under "1001" the string, so a lookup with her UserId finds nothing. In a live game that's a leaderboard showing a blank next to your name and nothing in the output. A scenario run notices keys changing as they cross, but only once a scenario sends one. The signature catches it while you're still writing the type.

With the key written as tostring(player.UserId) on both sides, the same scenario prints Alice's best on her board: 41.3 and passes.

Lists with holes

A list Roblox sends stops at its first nil. Make Top a list that can hold nil and check says so:

Kart/Laps/Types.shot:5   Board.Top : {Lap?} goes through LapBoard: a list arrives cut at the first nil, so the items after it are lost; send {Lap} without the empty ones, built with table.insert

The sneakier version has the right type and still makes holes. Fill the top laps by index inside an if, and every lap the if turns down leaves a gap:

The boardFor function filling top by index inside an if, and shot check's error at that line saying Roblox sends the list cut at the first hole and to build it with table.insert

This one passes a quick test, because every lap in it is under a minute. It goes wrong the first time a slow lap comes before a fast one. The list then stops at the gap, and the laps after it never reach the players. The board in our project uses table.insert, which leaves no gaps.

Sends and handlers that don't match

The other checks hold each Fire and each handler to the signature. One value too many on FireAllClients:

Kart/Laps/LapsServer.shot:43   LapBoard: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 Laps/LapBoard.instance

A client handler waiting for a second value that never comes:

Kart/Laps/LapsClient.shot:10   LapBoard.OnClientEvent's handler reads 2 values, but the signature (board : Board) sends 1: sentAt is always nil; drop it, or add the parameter in Laps/LapBoard.instance

An InvokeServer with the track left out:

Kart/Laps/LapsClient.shot:16   LapRank:InvokeServer sends 0 values, but track : string isn't optional: the handler gets nil for track; pass it, or make it optional (track : string?) in Laps/LapRank.instance

And the server's handler typed differently from the signature:

Kart/Laps/LapsServer.shot:47   LapRank.OnServerInvoke's handler takes track : number, but the signature sends track : string: write track : string (or any, checked before use), or change the signature in Laps/LapRank.instance

Our real handler takes track : any, and that's allowed on purpose. A client can send the server anything at all, so the handler looks at the value before it trusts it:

local function rank(player : Player, track : any) : RankReply
	if track ~= "Canyon" then
		return { Place = 0, Reason = "No such track" }
	end

What it leaves alone

A Fire is checked by how many values it sends, and a handler by the types it declares. The values themselves aren't followed into the payload, so a table built with no type on it gets through. Holes are only found when the list is filled and put into the payload in the same function. A remote reached from another system, or passed around as a value, isn't checked.

A remote with no signature is checked the way it always was, so you can add them one remote at a time, starting with the one that carries your leaderboard.