Types

Pigeon exports five Luau types. You only need them if you run strict mode or if you pass Pigeon objects between your own functions and want them annotated. This page shows each definition as it is written and how to use it.

Using them

The types hang off the module you already required. Luau keeps types and values in separate namespaces, so the same Pigeon name works for both.

--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Pigeon = require(ReplicatedStorage.Pigeon)

local shop: Pigeon.PigeonCarrier = Pigeon.new("Shop")

Careful

Do not reach through Pigeon.Types. That field is the boolean true at runtime, because the types file holds only type declarations and returns true. Write Pigeon.PigeonCarrier, not Pigeon.Types.PigeonCarrier.

TypeWhat it describes
Pigeon.TransformerA transformer.
Pigeon.StageTableA staged table.
Pigeon.PigeonCarrierA carrier.
Pigeon.MiddlewareFunctionA middleware function.
Pigeon.StageGuardA capture or release guard.

The types

Transformer

export type Transformer = {
	uuid: string,
	destroyed: boolean,
	_teardown: {() -> ()},
	Track: (self: Transformer, disconnect: () -> ()) -> (() -> ()),
	Destroy: (self: Transformer) -> ()
}

The object Pigeon.Transformer returns. uuid is the string that gets hashed to pick a remote, and destroyed flips to true once and stays there.

local combat: Pigeon.Transformer = Pigeon.Transformer("Combat")
local connection = workspace.ChildAdded:Connect(onPartAdded)

local untrack: () -> () = combat:Track(function()
	connection:Disconnect()
end)

_teardown is the list of functions Track collects. It is in the type but it is not for you. Use Track and the handle it hands back. See Transformer.

StageTable

export type StageTable = {
	GetTable: (self: StageTable) -> {[any]: any},

	UseCapture: (self: StageTable, Callback: (...any) -> ()) -> (),
	UseRelease: (self: StageTable, Callback: (...any) -> ()) -> (),
	Release: (self: StageTable) -> ()
}

The object Pigeon.StagedTable returns. Note the names differ by one letter: the function is StagedTable, the type is StageTable.

local state: Pigeon.StageTable = Pigeon.StagedTable({ round = 1 })

local view: {[any]: any} = state:GetTable()
view.round = 2

GetTable is typed as a plain {[any]: any}, so the checker will let you write anything into it. What you write is still checked at runtime, and a value that cannot travel as data errors on the spot. See StagedTable.

PigeonCarrier

export type PigeonCarrier = {
	Unreliable: boolean,

	--Server
	BroadcastTo: (self: PigeonCarrier, Players: {Player}, event: string, ...any) -> (),
	Broadcast: (self: PigeonCarrier, event: string, ...any) -> (),
	BroadcastExcept: (self: PigeonCarrier, Players: {Player}, event: string, ...any) -> (),
	CallTo: (self: PigeonCarrier, Target: Player, Event: string, ...any) -> ...any,
	SendToRoom: (self: PigeonCarrier, Room: string, event: string, ...any) -> (),
	CreateRoom: (self: PigeonCarrier, RoomID: string, Players: {Player}) -> (),
	DestroyRoom: (self: PigeonCarrier, RoomID: string) -> (),
	JoinRoom: (self: PigeonCarrier, RoomID: string, Players: {Player} | Player) -> (),
	LeaveRoom: (self: PigeonCarrier, RoomID: string, Players: {Player} | Player) -> (),

	CaptureTable: (self: PigeonCarrier, id: string, Table: StageTable) -> (),
	ReleaseTable: (self: PigeonCarrier, id: string) -> (),
	ForceTable: (self: PigeonCarrier, id: string, Table: StageTable, Targets: ({Player} | Player)?) -> (),

	UseHandshake: (self: PigeonCarrier, Callback: (player: Player, ...any) -> boolean) -> (),
	Approved: (self: PigeonCarrier, Targets: {Player}?) -> {Player},
	Revoke: (self: PigeonCarrier, Targets: {Player} | Player) -> number,

	--Client
	Emit: (self: PigeonCarrier, event: string, ...any) -> (),
	Call: (self: PigeonCarrier, event: string, ...any) -> ...any,
	RequestTable: (self: PigeonCarrier, id: string) -> StageTable,
	Ping: (self: PigeonCarrier) -> number,
	Handshake: (self: PigeonCarrier, ...any) -> boolean,
	Init: (self: PigeonCarrier) -> PigeonCarrier,

	--Shared
	On: (self: PigeonCarrier, event: string, Callback: (...any) -> ()) -> (),
	When: (self: PigeonCarrier, event: string, Callback: ((...any) -> ...any)?) -> (),
	Off: (self: PigeonCarrier, event: string, Callback: ((...any) -> ...any)?) -> (),

	UseIncoming: (self: PigeonCarrier, Callback: (...any) -> ()) -> (),
	UseOutgoing: (self: PigeonCarrier, Callback: (...any) -> ()) -> (),

	Destroy: (self: PigeonCarrier) -> ()
}

The object Pigeon.new returns. Every method takes self first, so you call them all with a colon.

Server
local shop: Pigeon.PigeonCarrier = Pigeon.new("Shop")

local function announce(carrier: Pigeon.PigeonCarrier, stock: {any})
	carrier:Broadcast("StockChanged", stock)
end

announce(shop, getStock())

One type covers both machines. The comments in the definition say which side each method belongs to. A server method called on the client errors with ERR_NOT_SERVER, and a client method called on the server errors with ERR_NOT_CLIENT. Two of them do not follow that rule: Approved has no side check at all, and Init is a no-op on the server so shared code can call it either way. The checker will not stop you, so the comments are the only warning you get. See Carrier.

On and When are the two ways to handle an event, and the type says which is which. An On callback returns (), because a listener's return value has nowhere to go. A When callback returns ...any, because a responder's return values are the reply to a Call or a CallTo. Both callbacks are optional in When and Off: passing nil to When clears the responder for that event.

Either way the arguments differ by machine. A server handler is handed the sending player first, a client handler is not.

Server
local shop = Pigeon.new("Shop")

shop:When("Buy", function(player: Player, itemId: string)
	return giveItem(player, itemId)
end)
Client
local shop = Pigeon.new("Shop")

shop:On("StockChanged", function(stock: {any})
	updateShopUi(stock)
end)

shop:Init()

MiddlewareFunction

export type MiddlewareFunction = (direction: string, event: string, args: {any}, player: Player?) -> (string?, {any}?)

The shape of a function you hand to UseIncoming or UseOutgoing. Middleware sees every packet the carrier handles and can rewrite it before it goes on.

ParameterTypeWhat it is
directionstringEither "incoming" or "outgoing".
eventstringThe event name as you wrote it, such as "Buy".
args{any}The arguments as an array.
playerPlayer?The other machine's player, on the server only.

event is the carrier local name, not the name that travels. The channel prefix is added after outgoing middleware has run and is already gone by the time incoming middleware runs, so you always see the plain name.

Both returns are optional, and each is used only if it is the right type. Return a string to rename the event. Return a table to replace the arguments. Return nothing and the packet passes through untouched.

Renaming does a different job in each direction. Outgoing, the new name is what travels, so the other machine has to listen for that name. Incoming, the new name picks which of your listeners runs, and the carrier hands middleware every packet on its channel, so the original name does not need a listener of its own.

local shop = Pigeon.new("Shop")

local log: Pigeon.MiddlewareFunction = function(direction, event, args, player)
	print(direction, event, #args, player)
	return nil, nil
end

-- Written as "Buy", it arrives on the far side as "Purchase".
local rename: Pigeon.MiddlewareFunction = function(_direction, event, args)
	if event == "Buy" then
		return "Purchase", args
	end
	return nil, nil
end

shop:UseIncoming(log)
shop:UseOutgoing(rename)

Note

player is not filled in as often as you might expect on the way out. Incoming on the server it is the player who sent the packet. Outgoing on the server two paths fill it in: CallTo, with the player being asked, and losing access, with the player being cleared out on the reserved __pigeon_table_revoke message it sends them. That second one covers Revoke and a handshake the guard turned down, since both evict the player the same way. The broadcast methods leave it nil. On the client it is always nil.

Middleware runs in the order you added it, and each one is handed what the previous one produced. If yours errors, Pigeon warns and moves on to the next with the packet unchanged. See Middleware.

StageGuard

export type StageGuard = (player: Player, id: string) -> boolean?

The shape of a function you hand to UseCapture or UseRelease on a staged table. It runs on the server when a client asks for that table or asks to stop receiving it.

ParameterTypeWhat it is
playerPlayerThe client asking.
idstringThe id the table was captured under.

Return false to block the attempt. Anything else allows it, including nil and returning nothing at all, so a guard that forgets to return is a guard that lets everyone through.

Server
local ownerOnly: Pigeon.StageGuard = function(player, id)
	return id == "profile_" .. player.UserId
end

local profiles = Pigeon.new("Profiles")

local function publish(owner: Player)
	local profile = Pigeon.StagedTable({ coins = 0 })
	profile:UseCapture(ownerOnly)
	profiles:CaptureTable("profile_" .. owner.UserId, profile)
end

Guards run in the order added and every one has to pass. The first false ends it. If a guard errors, the attempt is blocked, so a broken guard fails shut rather than open.

Capture guards only judge requests a client made. ForceTable is the server deciding, so it does not ask them. See Staged Tables.

What the list leaves out

These types describe the public surface, not the whole object. Some things that exist at runtime are not in them.

Missing fromWhat
PigeonCarrier Timeout, which is a real field set from the options table.
StageTable Snapshot, ApplySnapshot, ApplyPatches, CanCapture and CanRelease, which Pigeon uses itself.
All three objects The private fields, the ones starting with an underscore.

Transformer._teardown is the exception. It is in the type, but it is still internal.

Note

The callback parameters on UseIncoming, UseOutgoing, UseCapture and UseRelease are all typed loosely as (...any) -> () rather than as MiddlewareFunction and StageGuard. Annotate your function where you write it, the way the samples above do, and the checker still has something to work with.

Everything in the types is real. Nothing listed is missing at runtime, so if the checker accepts a call, that method exists.