World Executor · Roblox VM

Guide: Watching Remotes

Seeing what a game sends to its server, replaying it, and reading the raw packet stream underneath.

Why bother

Almost everything a game does that matters happens over a RemoteEvent or a RemoteFunction. Watching that traffic tells you what arguments the server expects, which is the difference between guessing at a script and writing one that works.

There are three levels to this, from friendliest to rawest: hook __namecall, inspect the signal's connections directly, or hook the RakNet packet stream.

A namecall spy

The same hook from the hooking guide, tightened into something you would actually leave running.

spy.lua
local old
local logging = true

old = hookmetamethod(game, "__namecall", newcclosure(function(self, ...)
    local method = getnamecallmethod()

    if logging and not checkcaller() then
        if method == "FireServer" or method == "InvokeServer" then
            local args = {...}
            rconsoleprint(("[%s] %s\n"):format(method, self:GetFullName()))
            for i, v in ipairs(args) do
                rconsoleprint(("  [%d] %s = %s\n"):format(i, typeof(v), tostring(v)))
            end
        end
    end

    return old(self, ...)
end))

rconsolecreate()
rconsolesettitle("remote spy")

Log to rconsole, not print

A busy game will flood the editor console faster than you can read it. A dedicated rconsole window scrolls independently and survives the editor being covered.

Firing them yourself

Once you know the shape of a call you can make it yourself. For server-bound traffic that is just calling the remote. For *client*-bound signals — the ones the server fires at you — firesignal runs the connected handlers locally without any network involvement.

fire.lua
local remote = game:GetService("ReplicatedStorage"):WaitForChild("Buy")

-- Server-bound: a normal call.
remote:FireServer("sword", 1)

-- Client-bound: run whatever the game connected, locally.
local incoming = game:GetService("ReplicatedStorage"):WaitForChild("Reward")
firesignal(incoming.OnClientEvent, 500)

-- Or inspect the connections instead of firing blind.
for _, connection in ipairs(getconnections(incoming.OnClientEvent)) do
    print(connection.Function, connection.State)
end

getconnections gives you the handler functions themselves, which you can then feed to debug.getupvalues to read the state they close over. cansignalreplicate tells you whether a given signal can be pushed back across the network boundary with replicatesignal.

Going lower: RakNet

Under the remotes sits the actual packet stream. raknet.add_send_hook gives you every outgoing packet before it leaves, with its id, its payload and its reliability, and lets you block it.

raknet.lua
local hook = raknet.add_send_hook(function(packet)
    -- Log the id and size of everything leaving.
    print(("id=%d size=%d channel=%d"):format(packet.Id, packet.Size, packet.OrderingChannel))

    -- Drop a specific packet type entirely.
    if packet.Id == 83 then
        packet:Block()
    end
end)

-- ... later
raknet.remove_send_hook(hook)

Blocking packets breaks things

The client and server share state assumptions. Dropping the wrong packet gets you desynced, kicked, or worse. raknet.desync exists for the cases where that is what you want, deliberately and briefly.

Related reference

See Signals for firesignal, getconnections, replicatesignal and the connection proxy; Metatable for the namecall hook; RakNet for the packet-level surface; and Console for the rconsole output used above.