Pigeon | Next gen networking module

Roblox gives you RemoteEvents and nothing on top of them, so most games end up making one per feature. Forty features means forty Instances you create, name, parent, find and wait for. Then you write the startup fix, the access checks and the cleanup by hand, once per feature.

Pigeon takes that job off you. You pick a name on the server, pick the same name on the client, and the two are talking. No RemoteEvent is made, named, found or waited for.

GitHub: GitHub - thekingofspace/Pigeon · GitHub

Docs: https://thekingofspace.github.io/Pigeon/

Getting started: https://thekingofspace.github.io/Pigeon/guide/getting-started.html


The whole idea


-- Server

local Pigeon = require(game.ReplicatedStorage.Pigeon)

local shop = Pigeon.new("Shop")

shop:On("Buy", function(player, itemId)

return giveItem(player, itemId)

end)

shop:Broadcast("StockChanged", getStock())


-- Client

local Pigeon = require(game.ReplicatedStorage:WaitForChild("Pigeon"))

local shop = Pigeon.new("Shop")

shop:On("StockChanged", updateShopUi)

shop:Init()

print(shop:Call("Buy", "sword"))

The string "Shop" is the only thing the two sides have to agree on. That is the entire setup.


What you get

  • Names, not Instances. You never make a RemoteEvent. You name a channel and send on it.

  • A shared pool of remotes. Every channel hashes onto a small pool. It is one slot per 16 groups, never more than 32 slots, and each slot has a reliable remote plus an unreliable twin. So the ceiling is 64 remotes whether you have five channels or five hundred.

  • No startup race. Anything the server sends to a client before that client is ready is held for it, up to 64 packets for 30 seconds, and arrives in the order it was held.

  • Channels you can lock. Put a guard on a channel and players who do not pass it cannot send on it or hear anything on it.

  • Rooms. Named lists of players on a channel, so you can send to a party or a team in one call.

  • Middleware. See, rewrite or block every packet going in or out of a channel.

  • Tables that replicate. Write to a table on the server and every client holding it sees the write. No sync code.

  • Cleanup in one call. One Destroy drops the channel, its listeners, its rooms, its guard and its middleware. Group channels together and one call drops the lot.


Usage

Listening and sending

On adds a listener. Off removes one. The event name is yours to pick and it only means anything inside that channel.


-- Server

local chat = Pigeon.new("Chat")

chat:On("Say", function(player, message)

chat:Broadcast("Said", player.Name, message)

end)


-- Client

local chat = Pigeon.new("Chat")

chat:On("Said", function(name, message)

print(name .. ": " .. message)

end)

chat:Init()

chat:Emit("Say", "hello")

On the server a listener gets the sending player first. On the client there is only one sender, so there is no player argument.

Call Init on the client once your listeners are in place. On the server it does nothing, so shared code can call it without checking which machine it is on.

Sending to some players and not others

Three ways to send from the server. Pick by who you mean.


local shop = Pigeon.new("Shop")

shop:Broadcast("StockChanged", stock) -- everyone

shop:BroadcastTo({ player }, "Coins", 50) -- these players

shop:BroadcastExcept({ cheater }, "Announce", "hi") -- everyone but these

Asking for something back

The reply is whatever your listener returns. There is no reply function to call and nothing to hold on to.


-- Server

local shop = Pigeon.new("Shop")

shop:On("Buy", function(player, itemId)

if not canAfford(player, itemId) then

return false, "too expensive"

end

giveItem(player, itemId)

return true, itemId

end)


-- Client

local ok, detail = shop:Call("Buy", "sword")

The server can ask a client too, with CallTo(player, event, ...). There is also Ping() on the client, which returns the round trip in seconds.

Rooms

A room is a named list of players on one carrier. Rooms are server side only, and the client has no idea they exist.


local party = Pigeon.new("Party")

party:CreateRoom("party_1", { leader })

party:JoinRoom("party_1", friend)

party:SendToRoom("party_1", "Message", "we are going in")

party:LeaveRoom("party_1", friend)

party:DestroyRoom("party_1")

Locking a channel

Install a guard and the channel shuts for everyone. A client gets in by calling Handshake, which runs your guard on the server. Until they pass, they cannot send on the channel and they are filtered out of every broadcast on it.


-- Server

local admin = Pigeon.new("Admin")

admin:UseHandshake(function(player, token)

return isAdmin(player) and token == secret

end)


-- Client

local admin = Pigeon.new("Admin")

if admin:Handshake(myToken) then

admin:Init()

admin:Emit("Kick", target)

end

You can also see who is in with Approved() and throw someone back out with Revoke(player).

Tables that replicate

The server writes. The clients read. There is nothing to compare and nothing to send.


-- Server

local staged = Pigeon.StagedTable({ coins = 0, level = 1 })

local stats = Pigeon.new("Stats")

stats:CaptureTable("stats", staged)

staged:GetTable().coins = 50


-- Client

local stats = Pigeon.new("Stats")

stats:Init()

local mine = stats:RequestTable("stats")

print(mine:GetTable().coins) --> 50

Nested writes are sent as a path, so view.stats.health = 60 sends that one field and not the whole table.

Middleware

Two chains per channel, one for packets coming in and one for packets going out. Return nothing to let a packet through, return new values to change it, return false to block it.


local shop = Pigeon.new("Shop")

shop:UseIncoming(function(direction, event, args, player)

if event == "Buy" and isBanned(player) then

return false

end

end)

Cheap sends you can afford to lose

Pass one option and every fire and forget send on that channel takes the unreliable lane. Good for positions and effects. Requests and replies still go the reliable way.


local positions = Pigeon.new("Positions", { Unreliable = true })

positions:Emit("Move", cframe)

Turning it off


shop:Destroy()

That drops the listeners, the rooms, the guard, the middleware and every subscription the channel made. Calling it twice is fine, and a dropped channel goes quiet instead of throwing at you.

To drop a whole feature at once, put its channels on one group and destroy the group.


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 })

combat:Destroy() -- all three are gone


Installing

Pigeon is one folder of Luau files with no dependencies. Copy src/Pigeon into ReplicatedStorage and require it from a Script or a LocalScript.

With Rojo:


"ReplicatedStorage": {

"Pigeon": { "$path": "src/Pigeon" }

}

Full instructions: https://thekingofspace.github.io/Pigeon/guide/installation.html


Two things to watch out for

I would rather list these than have you find them yourself.

  1. Do not send nil in the middle of an argument list. Everything from that point on is lost, because Roblox strips the key that holds the count. Send false or a string instead.

  2. Do not hold on to a nested view on the client. Read down from the top view each time, or you can end up reading a stale one.

Both are written up here with the full reasoning: https://thekingofspace.github.io/Pigeon/guide/known-issues.html


Credits

Pigeon is heavily inspired by roblox-sockets by OMouta, which brought the socket style of named events, rooms and middleware to Roblox. Go and look at it.

Pigeon takes that shape and adds the shared remote pool, the per channel readiness and buffering, handshake locked channels, staged tables and group teardown.

4 Likes

Wow, another library that lies to you about being optimized?
People should’ve figured it out by now that no library can ever be trusted.
Only simple RemoteEvent + buffer logic with (depending on your architecture) opcodes is the only correct way to optimize networking.

Outsourcing optimization to a library is lying to yourself.

This isn’t C++.

Using libraries in Luau is arrogantly lying to yourself.

2 Likes

I guess you misunderstood why resources exists in the Devforum. Optimization here doesnt mean rewriting the underlying functions (This is Luau not C++ there you said it but you dont know whats that about, you cant touch the internals), instead optimization here means how the approach is done. Libraries here are just like vehicle drivers(resources) in real-life, the car(which Roblox’s system) are there, you cant rewrite its internals but instead, base on how your selected driver uses it will determine performance(perfect maneuver, gas efficiency, etc.) just like how libraries in Roblox handles things(correct usage, leaking, moving parts, etc.). So if you want to drive your car instead of making someone drives it for you then its your call. But importantly, the library here is indeed not really that optimized(bunch of remote instances just for sharding is not the right approach as it loses deterministic queue and bloats the data model, the implementation is synchronous too meaning attaching multiple listener to one carrier will result in stalling if one of those listeners yields)

Absolutely don’t agree
Middleware libraries, or “generalized” libraries, are not needed in Luau at all
All it does is outsource logic to a void where you get terrible results but get blindfolded into thinking they are somehow fine
What is the point of a library that turns 2 lines of code into two thousand lines of guessing?
If you need buffers, use buffers
Don’t use middleware and don’t lie to yourself
Buffers are incredibly easy to understand anyway and are the only optimal way to structure networking on Roblox.
Probably will make a tutorial post because Roblox developers must be liberated from bloatware chains. :raised_fist:

1 Like

The reason for the yielding is because you’re meant to only have one thing attached to any on event. You technically can add more, but the idea is it’s one per one.

You would use a lower level signal library if you want to distribute things across a bunch of instances this is just meant for communication one on one

Ignore him hes a renowned troll

5 Likes

Update 0.1.1

  • Made meta data smaller.
  • Improved packet delivery
  • Added When for when using call
  • made On call’s now async (coroutines)

Can you show where exactly OP or this resource’s documentation actually makes this claim? Nowhere do they claim that this does anything beyond provide a better DX when writing networking logic. I believe the only person lying here is you.

3 Likes

Thank you :sob::sob::sob:

The only optimization I made is using limited number of events, and lower metadata & header size via buffers as compared to socket (still a amazing library)

Version 0.1.2

  • Added strict typing.
  • Made docs more clear on what is going on.

I agree buffers is one of those things that gives you a lot of control, but do you really want to construct buffers manually? you will eventually end up making functions to make it easier on yourself and eventually those functions will pile up and you get library… So I don’t know if I could agree with you that much even I am personally against this trend of using libraries like web devs do. Yet I do believe libraries really useful in teams than one or two scripters, libraries make it easier to have established methods of doing things. We can’t avoid libraries they’re literally collection of functions that make our lives easier, its like shortcuts and you will eventually create your own libraries like it or not. For learning purposes I am against using libraries that made by other people unless you’re using them to understand them rather than skip learning. In production level libraries must have as no one would want to pay 18 hours worth of pay to a scripter when that scripter could just outsourced it and spent 1 hour to make system customer wanted. Good example for this is my usage of 2d partictle library to save time for my customer as I am not gonna charge him hundreds of dollars when I can charge him smaller amount, and I believe customer also would like idea of paying less.

No directly related I used buffers for the metadata to avoid the pitfall of other networking libraries that pass their metadata like flags and events as tuples which can be a bit heavy.

RPC round trip, 50 sequential calls (ms)
               min       p50       p95       max       avg
  Pigeon      48.864    50.003    51.051    51.305    49.995
  Socket      50.115    64.895    66.901    67.524    59.646

Client -> server burst, 250 messages
  Pigeon    250/250 in    4.517ms  (    55349 msg/s)
  Socket    250/250 in    2.504ms  (    99828 msg/s)

Server -> client burst, 250 messages
  Pigeon    250/250 in    4.254ms  (    58763 msg/s)
  Socket    250/250 in    2.378ms  (   105139 msg/s)

Wire volume, idle traffic subtracted (client-side Stats counters)
  -- RPC stage, 50 calls --
  Pigeon   up     7.56 KB (  154.8 B/msg)   down     0.00 KB (    0.0 B/msg)
  Socket   up    10.04 KB (  205.6 B/msg)   down     0.00 KB (    0.0 B/msg)
  -- uplink stage, 250 messages --
  Pigeon   up    41.42 KB (  169.7 B/msg)   down     0.00 KB (    0.0 B/msg)
  Socket   up    36.80 KB (  150.7 B/msg)   down     0.00 KB (    0.0 B/msg)
  -- downlink stage, 250 messages --
  Pigeon   up     0.50 KB (    2.0 B/msg)   down     0.00 KB (    0.0 B/msg)
  Socket   up     0.21 KB (    0.9 B/msg)   down     0.00 KB (    0.0 B/msg)

Measured packet sizes per remote event (PacketSizeCounter)
    via      event                      count     total        min      avg    max
  client -> server, weighed on the server (includes the 5 RPC warmups)
    BenchSocket Bench/Blast                    1       169 B      169    169.0    169
    BenchSocket Bench/Up                     250     41500 B      166    166.0    166
    BenchSocket Bench/UpReset                  1        29 B       29     29.0     29
    BenchSocket CallRequest:Bench/Echo        55     11550 B      210    210.0    210
    BenchSocket CallRequest:Bench/UpReport     1        81 B       81     81.0     81
    Pigeon   BenchPigeon                    1       188 B      188    188.0    188
    Pigeon   BenchPigeon                   55     10010 B      182    182.0    182
    Pigeon   BenchPigeon                    1        55 B       55     55.0     55
    Pigeon   BenchPigeon                  250     46250 B      185    185.0    185
    Pigeon   BenchPigeon                    1        51 B       51     51.0     51
    Pigeon   BenchPigeon                    1        46 B       46     46.0     46
  server -> client, weighed on this client
    BenchSocket Bench/Down                   250     40750 B      163    163.0    163
    BenchSocket CallResponse                  56     10767 B       97    192.3    194
    Pigeon   <rpc reply>                   57     10180 B       40    178.6   1615
    Pigeon   BenchPigeon                  250     45500 B      182    182.0    182

Current benchmarks against the base socket module

here is the code that ran

SERVER

--!strict
-------------------------------------------------------------------- SERVICES
const Players = game:GetService("Players")
const ReplicatedStorage = game:GetService("ReplicatedStorage")
-------------------------------------------------------------------- MODULES
const Pigeon = require(ReplicatedStorage.Shared.Pigeon)
const Sockets = require(ReplicatedStorage.Shared.Sockets)
const PacketSizeCounter = require(ReplicatedStorage.Shared.PacketSizeCounter)
const Wire = require(ReplicatedStorage.Shared.Pigeon.Wire)
-------------------------------------------------------------------- TYPES
export type Uplink = {
    Count:number,
    First:number,
    Last:number
}
export type PacketRow = {
    Transport:string,
    Event:string,
    Count:number,
    Bytes:number,
    Min:number,
    Max:number
}
export type PacketLedger = {[string]:PacketRow}
-------------------------------------------------------------------- VARS
const PigeonBench = Pigeon.new("BenchPigeon")
const SocketBench = Sockets.new("BenchSocket")
const PigeonUplinks:{[Player]:Uplink} = {}
const SocketUplinks:{[Player]:Uplink} = {}
const Packets:PacketLedger = {}
-------------------------------------------------------------------- PACKET METER
const Watched:{[Instance]:boolean} = {}
const function MeasurePacket(RunContext:"Client" | "Server", Packed:{n:number, [number]:any}):number
    local Dense = true
    for Index = 1, Packed.n do
        if Packed[Index] == nil then
            Dense = false
            break
        end
    end

    if Dense then
        return PacketSizeCounter.GetPacketSize({
            RunContext = RunContext,
            RemoteType = "RemoteEvent",
            PacketData = Packed
        })
    end

    local Total = PacketSizeCounter.BaseRemoteOverhead
    if RunContext == "Client" then
        Total += PacketSizeCounter.ClientToServerOverhead
    end
    for Index = 1, Packed.n do
        Total += PacketSizeCounter.GetDataByteSize(Packed[Index])
    end
    return Total
end
const function TransportOf(Remote:Instance):string
    if Remote.Name:sub(1, 9) == "PigeonRef" then
        return "Pigeon"
    end
    return Remote.Name
end
const function EventOf(Packed:{n:number, [number]:any}):string
    const First = Packed[1]

    if type(First) == "string" then
        if First == "CallRequest" and type(Packed[2]) == "string" then
            return `CallRequest:{Packed[2]}`
        end
        return First
    end

    if typeof(First) == "buffer" then
        const Ok, Kind, Event = pcall(Wire.unpack, First)
        if not Ok then
            return "<pigeon>"
        end
        if type(Event) == "string" and Event ~= "" then
            return Event
        end
        if Kind == Wire.RESPOND then
            return "<rpc reply>"
        end
        if Kind == Wire.AUTH then
            return "<auth>"
        end
        return "<pigeon>"
    end

    return "<unknown>"
end
const function RecordPacket(Remote:Instance, Packed:{n:number, [number]:any})
    const Transport = TransportOf(Remote)
    const Event = EventOf(Packed)
    const Key = `{Transport}|{Event}`
    const Bytes = MeasurePacket("Client", Packed)

    local Row = Packets[Key]
    if not Row then
        Row = {
            Transport = Transport,
            Event = Event,
            Count = 0,
            Bytes = 0,
            Min = math.huge,
            Max = 0
        }
        Packets[Key] = Row
    end

    Row.Count += 1
    Row.Bytes += Bytes
    Row.Min = math.min(Row.Min, Bytes)
    Row.Max = math.max(Row.Max, Bytes)
end
const function WatchRemote(Descendant:Instance)
    if Watched[Descendant] then
        return
    end

    if not (Descendant:IsA("RemoteEvent") or Descendant:IsA("UnreliableRemoteEvent")) then
        return
    end
    Watched[Descendant] = true

    const Remote = Descendant :: RemoteEvent
    Remote.OnServerEvent:Connect(function(_Player:Player, ...:any)
        RecordPacket(Descendant, table.pack(...))
    end)
end
ReplicatedStorage.DescendantAdded:Connect(WatchRemote)
for _, Descendant in ipairs(ReplicatedStorage:GetDescendants()) do
    WatchRemote(Descendant)
end
-------------------------------------------------------------------- HELPERS
const function FreshUplink():Uplink
    return {
        Count = 0,
        First = 0,
        Last = 0
    }
end
const function Record(Uplinks:{[Player]:Uplink}, Player:Player)
    local Link = Uplinks[Player]
    if not Link then
        Link = FreshUplink()
        Uplinks[Player] = Link
    end

    local Now = os.clock()
    if Link.Count == 0 then
        Link.First = Now
    end
    Link.Count += 1
    Link.Last = Now
end
const function Report(Uplinks:{[Player]:Uplink}, Player:Player):(number, number)
    local Link = Uplinks[Player]
    if not Link or Link.Count == 0 then
        return 0, 0
    end
    return Link.Count, (Link.Last - Link.First) * 1000
end
-------------------------------------------------------------------- PIGEON SIDE
PigeonBench:When("Bench/Echo", function(_Player:Player, Payload:any)
    return Payload
end)
PigeonBench:On("Bench/Up", function(Player:Player, _Index:number, _Payload:any)
    Record(PigeonUplinks, Player)
end)
PigeonBench:On("Bench/UpReset", function(Player:Player)
    PigeonUplinks[Player] = FreshUplink()
end)
PigeonBench:When("Bench/UpReport", function(Player:Player)
    return Report(PigeonUplinks, Player)
end)
PigeonBench:When("Bench/PacketReport", function(_Player:Player)
    return Packets
end)
PigeonBench:On("Bench/PacketReset", function(_Player:Player)
    table.clear(Packets)
end)
PigeonBench:On("Bench/Blast", function(Player:Player, Count:number, Payload:any)
    for Index = 1, Count do
        PigeonBench:BroadcastTo(Player, "Bench/Down", Index, Payload)
    end
end)
-------------------------------------------------------------------- SOCKET SIDE
SocketBench:On("Bench/Echo", function(_Player:Player, Payload:any)
    return Payload
end)
SocketBench:On("Bench/Up", function(Player:Player, _Index:number, _Payload:any)
    Record(SocketUplinks, Player)
end)
SocketBench:On("Bench/UpReset", function(Player:Player)
    SocketUplinks[Player] = FreshUplink()
end)
SocketBench:On("Bench/UpReport", function(Player:Player)
    local Count, Elapsed = Report(SocketUplinks, Player)
    return {Count = Count, Elapsed = Elapsed}
end)
SocketBench:On("Bench/Blast", function(Player:Player, Count:number, Payload:any)
    for Index = 1, Count do
        SocketBench:EmitTo(Player, "Bench/Down", Index, Payload)
    end
end)
SocketBench:initialize()
-------------------------------------------------------------------- CLEANUP
Players.PlayerRemoving:Connect(function(Player:Player)
    PigeonUplinks[Player] = nil
    SocketUplinks[Player] = nil
end)
local Metered = 0
for _ in pairs(Watched) do
    Metered += 1
end
print(string.format(
    "[Bench] server ready - Pigeon and Socket handlers installed, metering %d remotes",
    Metered
))

Client

--!strict
-------------------------------------------------------------------- SERVICES
const ReplicatedStorage = game:GetService("ReplicatedStorage")
const RunService = game:GetService("RunService")
const Stats = game:GetService("Stats")
-------------------------------------------------------------------- MODULES
const Pigeon = require(ReplicatedStorage.Shared.Pigeon)
const Sockets = require(ReplicatedStorage.Shared.Sockets)
const PacketSizeCounter = require(ReplicatedStorage.Shared.PacketSizeCounter)
const Wire = require(ReplicatedStorage.Shared.Pigeon.Wire)
-------------------------------------------------------------------- CONFIG
const WARMUP = 5
const RPC_COUNT = 50
const BURST_COUNT = 250
const SETTLE = 1
const BASELINE_TIME = 3
const DRAIN_TIMEOUT = 10
const SOCKET_TIMEOUT = 5
const PAYLOAD = {
    Id = 1337,
    Name = "BenchmarkPayload",
    Position = {12.5, 64.0, -88.25},
    Flags = {Alive = true, Ready = false},
    Tags = {"alpha", "beta", "gamma"}
}
-------------------------------------------------------------------- TYPES
export type Sample = {
    Min:number,
    P50:number,
    P95:number,
    Max:number,
    Avg:number
}
export type Traffic = {
    Sent:number,
    Received:number,
    Duration:number
}
export type PacketRow = {
    Transport:string,
    Event:string,
    Count:number,
    Bytes:number,
    Min:number,
    Max:number
}
export type PacketLedger = {[string]:PacketRow}
-------------------------------------------------------------------- VARS
const PigeonBench = Pigeon.new("BenchPigeon"):Init()
const SocketBench = Sockets.new("BenchSocket")
const Downlink = {
    Count = 0,
    First = 0,
    Last = 0
}
const Idle = {
    Sent = 0,
    Received = 0
}
const Packets:PacketLedger = {}
-------------------------------------------------------------------- PACKET METER
const Watched:{[Instance]:boolean} = {}
const function MeasurePacket(RunContext:"Client" | "Server", Packed:{n:number, [number]:any}):number
    local Dense = true
    for Index = 1, Packed.n do
        if Packed[Index] == nil then
            Dense = false
            break
        end
    end

    if Dense then
        return PacketSizeCounter.GetPacketSize({
            RunContext = RunContext,
            RemoteType = "RemoteEvent",
            PacketData = Packed
        })
    end

    local Total = PacketSizeCounter.BaseRemoteOverhead
    if RunContext == "Client" then
        Total += PacketSizeCounter.ClientToServerOverhead
    end
    for Index = 1, Packed.n do
        Total += PacketSizeCounter.GetDataByteSize(Packed[Index])
    end
    return Total
end
const function TransportOf(Remote:Instance):string
    if Remote.Name:sub(1, 9) == "PigeonRef" then
        return "Pigeon"
    end
    return Remote.Name
end
const function EventOf(Packed:{n:number, [number]:any}):string
    const First = Packed[1]

    if type(First) == "string" then
        -- Sockets tunnels RPCs as ("CallRequest", eventName, requestId, ...).
        if First == "CallRequest" and type(Packed[2]) == "string" then
            return `CallRequest:{Packed[2]}`
        end
        return First
    end

    if typeof(First) == "buffer" then
        const Ok, Kind, Event = pcall(Wire.unpack, First)
        if not Ok then
            return "<pigeon>"
        end
        if type(Event) == "string" and Event ~= "" then
            return Event
        end
        if Kind == Wire.RESPOND then
            return "<rpc reply>"
        end
        if Kind == Wire.AUTH then
            return "<auth>"
        end
        return "<pigeon>"
    end

    return "<unknown>"
end
const function RecordPacket(Remote:Instance, Packed:{n:number, [number]:any})
    const Transport = TransportOf(Remote)
    const Event = EventOf(Packed)
    const Key = `{Transport}|{Event}`
    const Bytes = MeasurePacket("Server", Packed)

    local Row = Packets[Key]
    if not Row then
        Row = {
            Transport = Transport,
            Event = Event,
            Count = 0,
            Bytes = 0,
            Min = math.huge,
            Max = 0
        }
        Packets[Key] = Row
    end

    Row.Count += 1
    Row.Bytes += Bytes
    Row.Min = math.min(Row.Min, Bytes)
    Row.Max = math.max(Row.Max, Bytes)
end
const function WatchRemote(Descendant:Instance)
    if Watched[Descendant] then
        return
    end
    if not (Descendant:IsA("RemoteEvent") or Descendant:IsA("UnreliableRemoteEvent")) then
        return
    end
    Watched[Descendant] = true

    const Remote = Descendant :: RemoteEvent
    Remote.OnClientEvent:Connect(function(...:any)
        RecordPacket(Descendant, table.pack(...))
    end)
end
ReplicatedStorage.DescendantAdded:Connect(WatchRemote)
for _, Descendant in ipairs(ReplicatedStorage:GetDescendants()) do
    WatchRemote(Descendant)
end
-------------------------------------------------------------------- TRAFFIC METER
const function OpenMeter():() -> Traffic
    local Sent = 0
    local Received = 0
    local Started = os.clock()

    local Connection = RunService.Heartbeat:Connect(function(Delta:number)
        Sent += Stats.DataSendKbps * Delta
        Received += Stats.DataReceiveKbps * Delta
    end)

    return function():Traffic
        Connection:Disconnect()
        return {
            Sent = Sent,
            Received = Received,
            Duration = os.clock() - Started
        }
    end
end
const function Net(Measured:Traffic):Traffic
    return {
        Sent = math.max(0, Measured.Sent - (Idle.Sent * Measured.Duration)),
        Received = math.max(0, Measured.Received - (Idle.Received * Measured.Duration)),
        Duration = Measured.Duration
    }
end
const function Baseline()
    local Close = OpenMeter()
    task.wait(BASELINE_TIME)
    local Measured = Close()

    Idle.Sent = Measured.Sent / Measured.Duration
    Idle.Received = Measured.Received / Measured.Duration
end
-------------------------------------------------------------------- HELPERS
const function Summarise(Samples:{number}):Sample
    table.sort(Samples)

    local Total = 0
    for _, Value in ipairs(Samples) do
        Total += Value
    end

    local Count = #Samples
    local Median = math.max(1, math.floor(Count * 0.50))
    local Tail = math.max(1, math.floor(Count * 0.95))

    return {
        Min = Samples[1],
        P50 = Samples[Median],
        P95 = Samples[Tail],
        Max = Samples[Count],
        Avg = Total / Count
    }
end
const function Row(Label:string, Result:Sample):string
    return string.format(
        "  %-8s %9.3f %9.3f %9.3f %9.3f %9.3f",
        Label, Result.Min, Result.P50, Result.P95, Result.Max, Result.Avg
    )
end
const function ResetDownlink()
    Downlink.Count = 0
    Downlink.First = 0
    Downlink.Last = 0
end
const function OnDown()
    local Now = os.clock()
    if Downlink.Count == 0 then
        Downlink.First = Now
    end
    Downlink.Count += 1
    Downlink.Last = Now
end
const function DrainDownlink(Expected:number):(number, number)
    local Deadline = os.clock() + DRAIN_TIMEOUT
    while Downlink.Count < Expected and os.clock() < Deadline do
        task.wait(0.05)
    end
    task.wait(0.25)
    return Downlink.Count, (Downlink.Last - Downlink.First) * 1000
end
-------------------------------------------------------------------- STAGES
const function RunRPC(Label:string, Call:(any) -> any):(Sample, Traffic)
    for _ = 1, WARMUP do
        Call(PAYLOAD)
    end

    local Close = OpenMeter()
    local Samples:{number} = {}
    for _ = 1, RPC_COUNT do
        local Started = os.clock()
        local Reply = Call(PAYLOAD)
        table.insert(Samples, (os.clock() - Started) * 1000)

        if Reply == nil then
            warn(string.format("[Bench] %s dropped an RPC reply", Label))
        end
    end
    return Summarise(Samples), Net(Close())
end
const function RunUplink(Label:string, Reset:() -> (), Send:(number) -> (), Fetch:() -> (number, number)):(number, number, Traffic)
    Reset()
    task.wait(SETTLE)

    local Close = OpenMeter()
    for Index = 1, BURST_COUNT do
        Send(Index)
    end
    task.wait(SETTLE)
    local Used = Net(Close())

    local Received, Elapsed = Fetch()
    if Received < BURST_COUNT then
        warn(string.format("[Bench] %s uplink lost %d of %d", Label, BURST_COUNT - Received, BURST_COUNT))
    end
    return Received, Elapsed, Used
end
const function RunDownlink(Label:string, Blast:(number) -> ()):(number, number, Traffic)
    ResetDownlink()
    task.wait(SETTLE)

    local Close = OpenMeter()
    Blast(BURST_COUNT)

    local Received, Elapsed = DrainDownlink(BURST_COUNT)
    local Used = Net(Close())

    if Received < BURST_COUNT then
        warn(string.format("[Bench] %s downlink lost %d of %d", Label, BURST_COUNT - Received, BURST_COUNT))
    end
    return Received, Elapsed, Used
end
-------------------------------------------------------------------- REPORTING
const function Throughput(Received:number, Elapsed:number):number
    if Elapsed <= 0 then
        return 0
    end
    return Received / (Elapsed / 1000)
end
const function BurstLine(Label:string, Received:number, Elapsed:number):string
    return string.format(
        "  %-8s %4d/%d in %8.3fms  (%9.0f msg/s)",
        Label, Received, BURST_COUNT, Elapsed, Throughput(Received, Elapsed)
    )
end
const function BytesPer(Kilobytes:number, Count:number):number
    if Count <= 0 then
        return 0
    end
    return (Kilobytes * 1024) / Count
end
const function TrafficLine(Label:string, Used:Traffic, Count:number):string
    return string.format(
        "  %-8s up %8.2f KB (%7.1f B/msg)   down %8.2f KB (%7.1f B/msg)",
        Label,
        Used.Sent, BytesPer(Used.Sent, Count),
        Used.Received, BytesPer(Used.Received, Count)
    )
end
const function PacketLines(Ledger:PacketLedger):{string}
    const Keys:{string} = {}
    for Key in pairs(Ledger) do
        table.insert(Keys, Key)
    end
    table.sort(Keys)

    if #Keys == 0 then
        return {"    (no packets seen)"}
    end

    const Lines:{string} = {}
    for _, Key in ipairs(Keys) do
        const Row = Ledger[Key]
        table.insert(Lines, string.format(
            "    %-8s %-26s %5d  %8d B   %6d %8.1f %6d",
            Row.Transport, Row.Event, Row.Count, Row.Bytes,
            Row.Min, Row.Bytes / Row.Count, Row.Max
        ))
    end
    return Lines
end
-------------------------------------------------------------------- RUN
const function RunBenchmark()
    task.wait(2)
    SocketBench:initialize()
    task.wait(SETTLE)

    print(string.format(
        "[Bench] measuring idle bandwidth for %ds ...",
        BASELINE_TIME
    ))
    Baseline()

    print(string.format(
        "[Bench] starting - %d RPCs, %d message bursts",
        RPC_COUNT, BURST_COUNT
    ))

    table.clear(Packets)
    PigeonBench:Emit("Bench/PacketReset")
    task.wait(SETTLE)

    ---------------------------------------------------------------- RPC
    local PigeonRPC, PigeonRPCNet = RunRPC("Pigeon", function(Payload:any):any
        return PigeonBench:Call("Bench/Echo", Payload)
    end)
    local SocketRPC, SocketRPCNet = RunRPC("Socket", function(Payload:any):any
        return SocketBench:Call("Bench/Echo", SOCKET_TIMEOUT, Payload)
    end)

    ---------------------------------------------------------------- UPLINK
    local PigeonUp, PigeonUpMs, PigeonUpNet = RunUplink("Pigeon", function()
        PigeonBench:Emit("Bench/UpReset")
    end, function(Index:number)
        PigeonBench:Emit("Bench/Up", Index, PAYLOAD)
    end, function():(number, number)
        local Count, Elapsed = PigeonBench:Call("Bench/UpReport")
        if type(Count) ~= "number" or type(Elapsed) ~= "number" then
            return 0, 0
        end
        return Count, Elapsed
    end)

    local SocketUp, SocketUpMs, SocketUpNet = RunUplink("Socket", function()
        SocketBench:Emit("Bench/UpReset")
    end, function(Index:number)
        SocketBench:Emit("Bench/Up", Index, PAYLOAD)
    end, function():(number, number)
        local Result = SocketBench:Call("Bench/UpReport", SOCKET_TIMEOUT)
        if type(Result) ~= "table" then
            return 0, 0
        end
        return Result.Count, Result.Elapsed
    end)

    ---------------------------------------------------------------- DOWNLINK
    PigeonBench:On("Bench/Down", OnDown)
    local PigeonDown, PigeonDownMs, PigeonDownNet = RunDownlink("Pigeon", function(Count:number)
        PigeonBench:Emit("Bench/Blast", Count, PAYLOAD)
    end)
    PigeonBench:Off("Bench/Down")

    SocketBench:On("Bench/Down", OnDown)
    local SocketDown, SocketDownMs, SocketDownNet = RunDownlink("Socket", function(Count:number)
        SocketBench:Emit("Bench/Blast", Count, PAYLOAD)
    end)
    SocketBench:Off("Bench/Down")

    ---------------------------------------------------------------- REPORT
    const DownPackets:PacketLedger = table.clone(Packets)
    local UpPackets = PigeonBench:Call("Bench/PacketReport")
    if type(UpPackets) ~= "table" then
        UpPackets = {}
    end

    local Lines = {
        string.format("RPC round trip, %d sequential calls (ms)", RPC_COUNT),
        "               min       p50       p95       max       avg",
        Row("Pigeon", PigeonRPC),
        Row("Socket", SocketRPC),
        "",
        string.format("Client -> server burst, %d messages", BURST_COUNT),
        BurstLine("Pigeon", PigeonUp, PigeonUpMs),
        BurstLine("Socket", SocketUp, SocketUpMs),
        "",
        string.format("Server -> client burst, %d messages", BURST_COUNT),
        BurstLine("Pigeon", PigeonDown, PigeonDownMs),
        BurstLine("Socket", SocketDown, SocketDownMs),
        "",
        "Wire volume, idle traffic subtracted (client-side Stats counters)",
        string.format("  -- RPC stage, %d calls --", RPC_COUNT),
        TrafficLine("Pigeon", PigeonRPCNet, RPC_COUNT),
        TrafficLine("Socket", SocketRPCNet, RPC_COUNT),
        string.format("  -- uplink stage, %d messages --", BURST_COUNT),
        TrafficLine("Pigeon", PigeonUpNet, BURST_COUNT),
        TrafficLine("Socket", SocketUpNet, BURST_COUNT),
        string.format("  -- downlink stage, %d messages --", BURST_COUNT),
        TrafficLine("Pigeon", PigeonDownNet, BURST_COUNT),
        TrafficLine("Socket", SocketDownNet, BURST_COUNT),
        "",
        "Measured packet sizes per remote event (PacketSizeCounter)",
        string.format(
            "    %-8s %-26s %5s  %8s     %6s %8s %6s",
            "via", "event", "count", "total", "min", "avg", "max"
        ),
        string.format("  client -> server, weighed on the server (includes the %d RPC warmups)", WARMUP),
    }
    const function Append(More:{string})
        table.move(More, 1, #More, #Lines + 1, Lines)
    end
    Append(PacketLines(UpPackets))
    table.insert(Lines, "  server -> client, weighed on this client")
    Append(PacketLines(DownPackets))
    table.insert(Lines, "")

    print(table.concat(Lines, "\n"))
end
task.spawn(RunBenchmark)

Pigeon v0.1.3

Changelog:

  • Added OnUpdate to staged tables, so the client can react when the server pushes a change
  • Staged tables now print their contents instead of userdata: 0x...
  • Fixed writes to nested tables while iterating not replicating
  • Made staged tables generic, so GetTable gives back your table’s shape
  • Shrunk packet headers by 43%
  • Event names are now capped at 255 bytes

Small updates coming soon for heartbeating meant for competitive games where ping factors in

1 Like