← All projects
Networking

Dynamic Culling

A server-side anti-wallhack for Roblox. The server works out who you can actually see and strips everyone else out of the packet before it leaves.

Challenge

Wallhacks work because the Roblox server tells every client where every player is, whether or not they can see them. You cannot patch a cheat that only reads data you already handed over.

Solution

Move the decision to the server. It runs line-of-sight per viewer per tick and removes everyone you cannot see from the snapshot, so the cheat has nothing to draw a box around — the character is not in that client's workspace at all.

  • Roblox
  • Luau
  • Networking
  • Anti-cheat
  • Chickynoid
Roblox test map with green line-of-sight rays reaching every player through the geometry
The same Roblox map with most rays gone, only the players in direct line of sight left
Diagram of thirty player markers joined by 435 red lines, one per pair
The same player markers bucketed into a sparse grid, with a checks-per-tick counter reading zero
PVS on and off, side by side1/5
Dynamic Culling media

The problem is replication, not the cheat

Every wallhack for Roblox works the same way. The server replicates all player positions to all clients, the client renders the ones in front of the camera, and the cheat renders the rest. Client-side anti-cheat can only ever ask the cheat nicely not to look at data it already has.

So this does not try to detect anything. It changes what leaves the server.

What the server decides

Each tick, for each viewer, the system answers one question: can this player see that player right now? The answer comes from a few checks stacked in increasing cost order.

The per-viewer visibility pipeline: broad-phase grid, then line-of-sight raycasts, then the reveal hold, producing the snapshot sent to that client. Failing any check drops the target so it never leaves the server. ONE VIEWER · ONE 20 Hz STEP Broad phase25-stud cells Line of sight≤4 origins · ≤4 points · +v∆t Reveal hold150 ms Snapshotthis client only fails a check dropped — never leaves the server
The cheap test runs first, and the only thing that survives all three is a name in that client's visibility list.

The stacking is not tidiness, it is the only way the thing runs at all. Visibility is a question about pairs, and pairs grow quadratically:

(n2)  =  n(n1)2\binom{n}{2} \;=\; \frac{n(n-1)}{2}

Thirty players is 435 pairs. Visibility runs inside Chickynoid’s 20 Hz server step, so that is 8 700 pair tests a second — and a pair test is not one ray. A pair that can see each other usually costs one, because the first ray returns nothing and the loop breaks. A pair with a wall between them costs every ray before the system will agree there is no way through:

435×12×20  =  104400  raycasts per second435 \times 12 \times 20 \;=\; 104\,400 \;\text{raycasts per second}

which is the worst case, before anyone has fired a shot. So the broad phase exists to make sure almost none of those pairs are ever formed. Bucketing everyone into cc-stud cells and only testing the occupied neighbours takes the per-step work from

O(n2)    O(nkˉ)\mathcal{O}(n^2) \;\longrightarrow\; \mathcal{O}(n \cdot \bar{k})

where kˉ\bar{k} is the mean occupancy of the cells around a viewer — a number that grows with how tightly people crowd, not with how many people are in the server.

  • Spatial grid — a broad-phase pass that discards anyone outside the culling radius before a single ray is cast.

  • Multi-origin raycasts — a pair is visible the moment any one ray gets through, so the test is a search over origins and target points rather than a single centre-to-centre line:

    vis(s,t)    oOs,  pPt  :  opW=\operatorname{vis}(s,t) \iff \exists\, o \in O_s,\; p \in P_t \;:\; \overline{op} \cap \mathcal{W} = \varnothing

    with W\mathcal{W} everything solid in the world. PtP_t is four points: the target’s head and torso, and both again at where the lookahead puts it. OsO_s depends on what the observer is doing, and the two cases are exclusive. Moving, it is the head, the torso, and a point two studs further along the velocity — the peek origin, which is what catches someone leaning out of cover at speed. Standing still, that origin is meaningless, so it is dropped for a pair two studs to either side, perpendicular to the target. That is the case a single ray gets wrong most often: a player parked at a corner with their body behind the wall, who can see you perfectly well.

  • Lookahead sized to the connection — both ends are extrapolated forward before the test, but Δt\Delta t is not a constant. It is the observer’s own round-trip time, plus one server step, plus 50 ms of slack, capped:

    Δt=min ⁣(ping+120s+50ms,    250ms)\Delta t = \min\!\left(\text{ping} + \tfrac{1}{20}\,\mathrm{s} + 50\,\mathrm{ms}, \;\; 250\,\mathrm{ms}\right)

    A 40 ms player gets 140 ms of lead and a 200 ms player gets the full 250, because what is being compensated for is how stale this particular client’s copy of the world already is. Velocity is clamped to 60 studs/s first, which is what stops a launched or glitched player predicting halfway across the map and revealing everyone en route.

  • Reveal hold — once revealed, a player stays revealed until tseen+150mst_{\text{seen}} + 150\,\mathrm{ms}. Without it, anyone standing on the edge of cover strobes in and out. CS2 solves the same problem with a fade.

  • Players occlude players — the raycast filter is an allow-list, not a block-list: terrain, the collision root, and every player hitbox except the two endpoints. A body between you and a target stops the ray the same way a wall does, and neither end of the test can occlude itself.

Anyone who fails is removed from that client’s snapshot entirely.

Top-down view of one line-of-sight test. A ray straight from the viewer to the target is stopped by a wall. A second origin two studs further along the viewer's velocity clears the corner and the pair is reported visible. When the viewer is standing still that peek origin is replaced by a pair of origins two studs to either side, perpendicular to the target. Every origin is tested against two target positions, where the target is now and where the lookahead puts it, at two heights each. TOP-DOWN · ONE PAIR · ONE STEP wall blocked viewer side-step origin stationary only peek origin · +2 studs along v target predicted 2 positions × 2 heights = 4 points
The centre-to-centre ray is the one a naive implementation casts, and it is the one that gets a player at a corner wrongly culled. Everything else here exists to be the ray that gets through.

The broad phase is the first thing that runs, and it is deliberately dull — integer division into cells, one table insert per player, no rays:

-- Bucket every player into a gridSize-stud cell.
for _, playerRecord in pairs(server.playerRecords) do
    if playerRecord.chickynoid then
        local pos = playerRecord.chickynoid.simulation.state.pos

        local gridKey = Vector3.new(pos.X, 0, pos.Z) // gridSize
        local tab = grid[gridKey]
        if tab == nil then tab = {}; grid[gridKey] = tab end
        table.insert(tab, playerRecord)
        playerRecord.gridKey = gridKey
    end
end

How much of that grid gets walked is steps = radius // gridSize cells in each direction, and the shipped default is a 2000-stud radius over 25-stud cells. That is 80, so the scan is 161 × 161 — 25 921 cell lookups per viewer per step, almost all of them landing on nothing. Each one is a hash miss and costs approximately zero, which is exactly why it survived as a default for so long. It is still the wrong default, and the installer’s README says so: turn the radius down to what the map actually needs. Two to four hundred studs is normal, and at 400 the same scan is 17 × 17.

Per viewer, the lookahead is worked out once and reused for every target in range. This is the block that decides how far ahead of reality the test runs:

local myPing = (playerRecord.player and playerRecord.player:GetNetworkPing() or 0) * 1000
local serverStep = 1 / server.config.serverHz

local clampedMyVel = if mySpeed > 60 then myVel / mySpeed * 60 else myVel
local lookahead = math.min(myPing + (serverStep * 1000) + 50, 250) / 1000
local predictedMyBase = myPos + clampedMyVel * lookahead

-- Origin 3 is the peek. It only exists while the observer is actually moving;
-- standing still, two perpendicular origins are used instead.
local hasOrigin3 = mySpeed > 1
local origin1 = predictedMyBase + V3_HEAD
local origin2 = predictedMyBase + V3_TORSO
local origin3 = if hasOrigin3 then predictedMyBase + V3_HEAD + clampedMyVel.Unit * 2 else nil

The filter is the one thing rebuilt per pair rather than per viewer, because each test has to exclude its own two endpoints — otherwise every player is occluded by their own hitbox and nobody can see anyone:

local filterList = table.create(#allHitboxInstances)
for _, inst in ipairs(allHitboxInstances) do
    if inst ~= obsHitbox and inst ~= tgtHitbox then
        table.insert(filterList, inst)
    end
end

local raycastParams = RaycastParams.new()
raycastParams.FilterType = Enum.RaycastFilterType.Include
raycastParams.FilterDescendantsInstances = filterList

That is a table and a RaycastParams allocated per pair per step, and it is the most obviously improvable thing in the file — the allow-list only changes when someone spawns or dies, so it could be built once and patched, rather than rebuilt eight thousand times a second.

The reveal hold sits on the far end of the test, and it is the reason nobody strobes on the edge of cover:

if hasLOS then
    myRevealed[targetId] = now + holdSeconds
    playerRecord.visibilityList[targetId] = otherPlayerRecord
elseif (myRevealed[targetId] or 0) > now then
    -- Seen recently enough to still count. One failed frame is a corner, not
    -- a disappearance.
    playerRecord.visibilityList[targetId] = otherPlayerRecord
end

The delta that points at a frame you never got

Culling the snapshot is the easy half. The half that actually costs you a weekend is what culling does to delta compression.

Chickynoid does not send a whole character every step. It sends the difference between the character now and the character in the last snapshot the client acknowledged, which is a large saving and rests on one assumption: the client has that older snapshot. Replicate everything to everyone and the assumption is free. Cull per viewer and it is simply false — the frame a client last confirmed is very likely a frame in which the player you are about to describe was behind a wall and therefore absent. Subtracting from something the client never received does not error. It decodes, into a position that was never real.

So the server keeps, per client, a record of what that client could see on each frame:

-- Store the current visibility table for the current server frame
for userId, playerRecord in self.playerRecords do
    playerRecord.visHistoryList[self.serverTotalFrames] = playerRecord.visibilityList

    -- Store two seconds tops
    local cutoff = self.serverTotalFrames - 120
    if (playerRecord.lastConfirmedSnapshotServerFrame ~= nil) then
        cutoff = math.max(playerRecord.lastConfirmedSnapshotServerFrame, cutoff)
    end

    for timeStamp, rec in playerRecord.visHistoryList do
        if (timeStamp < cutoff) then
            playerRecord.visHistoryList[timeStamp] = nil
        end
    end
end

The cutoff is the interesting line. Two seconds of history is the ceiling, but a client that has not confirmed anything in two seconds keeps its baseline anyway — math.max against lastConfirmedSnapshotServerFrame means the frame someone is still lagging against never gets collected out from under them.

The snapshot generator then asks one question per target before it deltas anything:

local comparisonVisList = playerRecord.visHistoryList[comparisonFrame]
if (comparisonVisList == nil) then
    comparisonVisList = {} -- Assume we couldn't see anything
end

-- if we could see them last time, look up our delta to them
local couldSeeThemLastTime = true
if (comparisonVisList[otherUserId] == nil) then
    couldSeeThemLastTime = false
end
Five consecutive server frames for one client and one other player, who is visible on frames 101 and 102, culled on 103 and 104, and visible again on 105. The client's last confirmed frame is 103. Because that frame's visibility list does not contain the player, there is no baseline on the client to delta against, so the player is serialised whole instead. ONE CLIENT · ONE OTHER PLAYER · FIVE SERVER FRAMES 101visible 102visible 103culled 104culled 105visible again client's last confirmed frame no entry for this player in frame 103 nothing on the client to subtract from Send wholenot a delta
Reappearing after cover costs a full character record rather than a delta. That is the price of the whole system, paid one player at a time, at the moment they walk back into view.

There is one more consequence, and it is the reason the master switch is not a single if around the module. The snapshot generator treats a missing list as an empty one:

local visList = playerRecord.visibilityList
if (visList == nil) then
    visList = {} -- No visibility data = reveal nothing (secure default)
end

That default is correct — a bug in the culling module makes the world empty rather than making it transparent, and an empty world is a bug report while a transparent one is a silent regression. But it means turning culling off cannot mean skipping the work. It has to mean doing the opposite work: walking every player and explicitly putting everyone in everyone’s list, which is what the enabled == false branch does before it returns.

The part people miss

Culling the Chickynoid snapshot is not sufficient on its own. Roblox still keeps a native character rig around, and a cheat that reads that instead gets the same information back. The anti-ESP module hides that leftover rig, which is the difference between the system working and the system looking like it works.

Hiding is a stronger word than it deserves, though, because transparency is not hiding. Transparency = 1 is a property, and a property is precisely the kind of thing a client-side exploit sets back to zero. So the rig is not only made invisible, it is moved somewhere the information is worthless:

for _, child in character:GetDescendants() do
    if child:IsA("BasePart") then
        child.Transparency = 1
        child.CanCollide = false
        if child.Name == "HumanoidRootPart" then
            child.Anchored = true
            child.CFrame = CFrame.new(math.random(-10000, 10000), 100000, math.random(-10000, 10000))
        end
    elseif child:IsA("Decal") or child:IsA("Texture") or child:IsA("Clothing") or child:IsA("Accessory") then
        child:Destroy()
    end
end

Anchored first, then thrown a hundred thousand studs into the sky. Restoring the transparency now buys an exploiter a box in the void, because position is server state and transparency is not. The random horizontal offsets are cosmetic — anywhere far enough away would do. Decals, clothing and accessories are destroyed outright rather than faded, since each is its own instance with its own visibility and auditing which of them honour a parent’s transparency is not a good use of anyone’s afternoon.

The weak point is the timing. The pass runs inside a task.defer, which waits one resumption cycle and not for the avatar to finish loading, so a slow accessory can in principle arrive after the sweep has already run. It has not bitten in testing. It is still the line to look at first if a hat ever shows up floating where a player is not.

The same caveat applies to anything the game replicates itself. Tracers, nametags and hit markers still go to everyone unless the same visibility check is applied to them. Sound is not culled either — footsteps still give people away, which is correct for gameplay and worth knowing about.

Debugging something invisible

The hardest part of a visibility system is that the bug is an absence. A player who should be there is not, and nothing in the log says why.

So the debug panel draws the state instead: the spatial grid, the culling radius, and every live line-of-sight ray with its result. A master switch toggles culling mid-game so both states can be compared on the same frame without restarting the session. Most of the tuning work — radius, hold duration, lookahead — happened in that panel.

What is not finished

Two things in the tree are unfinished, and both are unfinished in a way worth writing down.

The parallel path. Under the server folder sit four Actor instances, each holding a CullingWorker, with a CullingTrigger and a CullingResults BindableEvent to get work in and answers out. The workers are complete. They take a list of pair assignments, drop into the parallel scheduler, cast, come back, and fire the results across the actor boundary:

CullingTrigger.Event:Connect(function(workerIndex: number, assignments: {})
	if workerIndex ~= myIndex then
		return
	end

	-- Parallel phase
	task.desynchronize()

	local localResults = {}
	for _, assignment in ipairs(assignments) do
		-- ... cast assignment.origins against assignment.points, early-out
	end

	-- Synchronized phase
	task.synchronize()

	-- Fire results back to serial VM via BindableEvent (crosses Actor boundary)
	CullingResults:Fire(myIndex, localResults)
end)

Nothing fires CullingTrigger. The visibility pass does every raycast inline on the serial VM, and the comment above that loop says so in capitals. So this is dead code — four scripts, two events and a shared-state module that the game loads and never calls, and the installer’s README tells you it is inert and safe to delete.

It has not been deleted because the split is the right one and the worker was never the hard part. Fanning out means building and partitioning the pair list on the serial VM first, which is the same nested loop over grid cells that currently does the casting, and then paying a round trip through two events for each batch. The parallel version only wins if that scheduling costs less than the rays it takes off the main thread, and finding out is a measurement job that has not happened yet. Shipping the scaffolding and admitting it is unused is better than shipping a fan-out that is slower than the loop it replaced.

The dead cache. The broad phase also builds a table of predicted positions per player per step, and nothing reads it. The observer loop recomputes prediction from the record, because it needs the observer’s own ping to size the lookahead and the cached value used a flat 250 ms for everyone. So the cache was correct right up until the lookahead became per-viewer, at which point it quietly became an allocation that does nothing. That is the ordinary way dead code appears: not written dead, but outlived.

Built on Chickynoid

The character controller underneath is Chickynoid by MrChickenRocket, an open-source server-authoritative controller for Roblox. The copy shipped here is modified — ServerSnapshotGen and ServerModule both had to change for per-viewer snapshots to be possible at all.

Because it replaces the character controller outright, anything expecting a normal Humanoid needs rewriting, and it will refuse to install into a game that already runs stock Chickynoid.

What it shipped

  • Line-of-sight culling: a spatial grid, then up to four ray origins per viewer tested against four points on the target
  • Lookahead sized to the viewer's own ping, so nobody pops in as they round a corner on a bad connection
  • 150 ms reveal hold to stop strobing on the edge of cover
  • A visibility history per client, so a delta never references a frame that client was never sent
  • Anti-ESP module that hides the leftover native rig cheats read instead
  • In-game debug panel: draw the grid, the culling radius and live LOS rays
  • Master switch to compare on and off mid-game, no restart
  • One-paste installer that refuses to run into an existing Chickynoid

More projects