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 stacking is not tidiness, it is the only way the thing runs at all. Visibility is a question about pairs, and pairs grow quadratically:
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:
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 -stud cells and only testing the occupied neighbours takes the per-step work from
where 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:
with everything solid in the world. is four points: the target’s head and torso, and both again at where the lookahead puts it. 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 is not a constant. It is the observer’s own round-trip time, plus one server step, plus 50 ms of slack, capped:
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 . 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.
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
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.






