Transformers

A transformer decides which pooled RemoteEvent a carrier's traffic goes out on. Every carrier already has one, so most games never make one. This page is about the two times it is worth doing.

What a transformer actually is

Pigeon does not make a RemoteEvent per channel. It keeps a small pool of them and hashes each transformer onto one. So a transformer is a very small object. It holds an id string, a destroyed flag and a list of teardown functions, and that is all.

PartWhat it is for
uuidHashed to pick which pooled remote the traffic rides on.
destroyedfalse until you destroy it, then true forever.
Teardown listEverything to shut off when the transformer is destroyed.

You make one with Pigeon.Transformer.

Pigeon.Transformer(uuid: string?) -> Transformer
ParameterTypeWhat it is
uuidstring?The identity to adopt. Leave it out for a fresh GUID.

Returns the new transformer.

local combat = Pigeon.Transformer("Combat")
print(combat.uuid)      --> "Combat"
print(combat.destroyed) --> false

-- Leave the id out and you get a fresh GUID.
local anon = Pigeon.Transformer()
print(anon.uuid)        --> "{4A2C...}"

Pass the same uuid on both machines when you build a transformer on each side. The uuid is what decides the remote, so two different strings are two different lanes.

Keep these two straight, because they are easy to mix up:

What decides what

carrier name which channel the message belongs to
transformer uuid which remote the message leaves on

You already have one

When you call Pigeon.new("Shop") and pass no options, the carrier builds a transformer for itself and uses the channel name as its uuid. You do not have to do anything.

local shop = Pigeon.new("Shop")
print(shop._transformer.uuid) --> "Shop"

That is why the server and the client end up on the same remote without agreeing on anything but the name. Both sides made a transformer called "Shop", and the same string always hashes to the same bucket. See The Ref Pool for how that hashing works.

Two reasons to make one yourself

1. Hold a group of channels to one remote

The size of the remote pool is worked out from how many transformers exist, not how many channels. Three carriers with default transformers count as three. Put all three on one transformer and they count as one, and all three ride the same remote.

This is worth doing when you have a cluster of chatty channels that logically belong together, and you would rather they shared a lane than spread out.

2. Turn a group of channels off in one call

Every carrier registers its own cleanup on its transformer when it is built. So destroying a transformer drops every carrier riding on it, along with every listener those carriers had bound.

That is the real reason most people reach for one. A round ends, a minigame closes, a system gets disabled, and you want all of its channels gone without keeping a list of them by hand.

The cluster pattern

Make one transformer, hand it to each carrier through options, and destroy it when the group is done.

Server
local combat = Pigeon.Transformer("Combat")

local damage  = Pigeon.new("Damage",  { Transformer = combat })
local status  = Pigeon.new("Status",  { Transformer = combat })
local effects = Pigeon.new("Effects", { Transformer = combat })

damage:On("Hit", function(player, target, amount)
	applyDamage(player, target, amount)
end)

status:On("Cleanse", function(player)
	clearStatus(player)
end)

effects:Broadcast("Play", "explosion", Vector3.new(0, 10, 0))

-- Round over. One call drops all three channels and every listener on them.
combat:Destroy()

After that call, all three carriers are dropped. Sending on them does nothing at all. They do not throw.

Careful

Passing options makes the carrier fresh and uncached. Pigeon.new("Damage") somewhere else gives you a different carrier on its own transformer, and the two share the Damage channel: a packet goes to every carrier that bound its event with On or When, so a handler registered on both fires twice. Hold on to the reference you got here, and keep the receiving side down to one carrier per name. See Carriers.

Ownership: who destroys the transformer

The rule is short. A transformer a carrier made for itself has no other owner, so it goes when the carrier goes. One you passed in belongs to you, so Pigeon leaves it alone.

How you built the carrierWho owns the transformerWhat carrier:Destroy() does to it
Pigeon.new("Shop") The carrier Destroys it as well.
Pigeon.new("Shop", { Timeout = 5 }) The carrier Destroys it as well. No transformer was passed, so it still made its own.
Pigeon.new("Shop", { Transformer = t }) You Leaves it alone.
-- Owned by the carrier.
local shop = Pigeon.new("Shop")
shop:Destroy()
print(shop._transformer.destroyed) --> true

-- Owned by you.
local mine = Pigeon.Transformer("Mine")
local other = Pigeon.new("Other", { Transformer = mine })
other:Destroy()
print(mine.destroyed) --> false

It goes the other way too. Destroying a transformer you own drops every carrier on it, whether or not those carriers own anything themselves.

Track and the untrack handle

transformer:Track(disconnect: () -> ()) -> () -> ()

Track registers a function to run when the transformer is destroyed. This is the mechanism the whole group teardown is built on. Carriers use it for their own cleanup, and you can use it for anything else that should die with the group.

ParameterTypeWhat it is
disconnect() -> ()Runs once when the transformer is destroyed.

Returns a handle. Calling the handle removes your teardown again.

local combat = Pigeon.Transformer("Combat")

local connection = workspace.Arena.Touched:Connect(onTouch)

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

-- Later, if you disconnect it yourself first, drop the teardown too.
connection:Disconnect()
untrack()

Note

Call the handle whenever you unsubscribe early. If you do not, the teardown list grows by one entry for every subscription you ever made, and it holds those functions in memory until the transformer is destroyed.

Tracking on a destroyed transformer

If the transformer is already destroyed, your function runs straight away and the handle you get back does nothing. There is no error and no queue.

local t = Pigeon.Transformer()
t:Destroy()

t:Track(function()
	print("this runs right now, not later")
end)

That is on purpose. A registration that arrives late can never leave a live connection behind. It also explains a carrier built on a dead transformer: the carrier tracks its own cleanup during construction, that cleanup fires immediately, and you get back a carrier that is already inert.

Destroying, in detail

transformer:Destroy() -> ()

Takes no parameters and returns nothing. In order, it:

  • Marks itself destroyed, so nothing can recurse back into it.
  • Runs every tracked teardown once, over a snapshot of the list, so a teardown that untracks itself does not make the loop skip an entry.
  • Clears the list.
  • Stops counting toward the remote pool size, so the published pool size can go back down.
local combat = Pigeon.Transformer("Combat")
local damage = Pigeon.new("Damage", { Transformer = combat })

combat:Destroy()
print(combat.destroyed) --> true
print(damage._dropped)  --> true, the carrier went with it

combat:Destroy()        --> nothing happens the second time

If one teardown errors, Pigeon warns and carries on with the next one. A single broken cleanup function cannot leave the rest connected.

The two sides do not need the same transformer

This surprises people, so it is worth being blunt about. Routing is by channel name. A transformer only decides which remote a packet leaves on.

Every event a carrier sends travels under a name built from the channel and the event, joined by a NUL byte:

-- carrier named "Damage", event "Hit"
"Damage" .. "\0" .. "Hit"

The receiving machine dispatches on that name, across every remote it is bound to. So the server can put Damage on a cluster transformer while the client just calls Pigeon.new("Damage"), and the two still talk.

Server
local combat = Pigeon.Transformer("Combat")
local damage = Pigeon.new("Damage", { Transformer = combat })

damage:Broadcast("Hit", "Goblin", 25)
Client
-- No transformer here at all. Same channel name, so it still arrives.
local damage = Pigeon.new("Damage")

damage:On("Hit", function(target, amount)
	showHitMarker(target, amount)
end)

damage:Init()

Requests work across a mismatch too. A reply goes back on whichever remote the request came in on, not on the local transformer's remote, so the round trip closes either way.

Sharing a transformer does not merge channels

Two carriers on one transformer do not hear each other. The channel name is baked into the wire name, so a "Hit" event on Damage and a "Hit" event on Status are different names even when both go out on the same remote.

Client
local combat = Pigeon.Transformer("Combat")
local damage = Pigeon.new("Damage", { Transformer = combat })
local status = Pigeon.new("Status", { Transformer = combat })

damage:On("Hit", print)  -- only ever sees Damage/Hit
status:On("Hit", print)  -- only ever sees Status/Hit

-- Each channel opens on its own, even though they share a remote.
damage:Init()
status:Init()

Getting the remote directly

If you ever want the actual RemoteEvent a transformer resolves to, there is Pigeon.GetRef. Carriers do this for you, so this is mostly for debugging.

local combat = Pigeon.Transformer("Combat")

local remote = Pigeon.GetRef(combat)
print(remote.Name) --> whichever "PigeonRef_N" this uuid hashes to

Note

On the client this yields until the server has created that remote and it has replicated in. On the server it returns immediately.

What to read next