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.
| Part | What it is for |
|---|---|
uuid | Hashed to pick which pooled remote the traffic rides on. |
destroyed | false until you destroy it, then true forever. |
| Teardown list | Everything to shut off when the transformer is destroyed. |
You make one with Pigeon.Transformer.
Pigeon.Transformer(uuid: string?) -> Transformer
| Parameter | Type | What it is |
|---|---|---|
uuid | string? | 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
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.
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 carrier | Who owns the transformer | What 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.
| Parameter | Type | What 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.
local combat = Pigeon.Transformer("Combat")
local damage = Pigeon.new("Damage", { Transformer = combat })
damage:Broadcast("Hit", "Goblin", 25)
-- 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.
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
- The Ref Pool for how a uuid turns into a remote.
- Cleanup for shutting a system down properly.
- Transformer API for the exact signatures.