Scrapped. Leaving it up as a reference, not maintaining it.
The idea itself is fine, it's basically how Valorant does fog of war, but Roblox doesn't hand you the hooks to actually pull it off. The catch is that FogWar only means anything if you stop the engine from replicating characters in the first place, and there's just no way to un-replicate a character from one specific client in stock Roblox. So to get around that you'd go full custom replication, and the second you do, you're suddenly on the hook for everything default replication was quietly doing for you: animations, tools, accessories, ragdolls, network ownership, all of it. Streaming that yourself over an unreliable channel at tick rate, the animations especially, is way more work and way less reliable than it's worth for basically any real game. I found that out the annoying way.
It's still fine for server-driven stuff like NPCs or AI bots, or for a game that already runs its own character replication, since none of those leaned on the default path to begin with. For a normal game with default characters it does nothing useful, a client-side highlight just reads the real body straight out of Workspace and walks right through it. I tested exactly that and it walked right through it.
The original writeup is below if you want to see how it was meant to work.
Server-authoritative visibility replication for Roblox. The server only tells a client where an enemy is when that client can actually see them. If you can't see someone, your client never receives their position, so wallhacks and ESP have nothing to read.
This is the same idea Valorant's Fog of War uses. It is not a detection system. There is nothing to detect because the data never leaves the server.
FogWar does not magically stop Roblox from replicating characters. By default the engine streams every player's real character to every client, and no amount of server code changes that. For FogWar to mean anything you have to turn that off and drive enemies yourself:
- keep the real characters server-side only (own rig, or a folder the client never gets), so the engine has nothing to replicate
- give each client a "ghost" model per enemy that starts hidden
- let FogWar decide who is visible and move the ghosts
If you skip that step and leave default replication on, this does nothing. The whole point is that the client never holds the real transform in the first place.
Every tick, for each viewer, it raycasts from the viewer's eye to a few points on each nearby enemy (head, torso, feet, both shoulders). One clear point is enough to count as visible. It uses line of sight, not field of view, on purpose: players can flick 180 degrees in a frame, so culling by where someone is looking would pop enemies in and out and get people killed.
Two shortcuts skip the raycasts:
- anyone within
HearingRadiusis always revealed, since you'd hear them anyway - an optional relation callback can force teammates or spectators to always show
When you lose sight of someone their ghost freezes at the last spot you saw them for
LingerTime, then stops updating. The frozen pose is the last-seen one, not the live
one, so nobody can pull a live position through a wall during the grace window.
Broad phase is a spatial hash so a viewer only tests enemies in nearby cells, and there's a hard raycast budget per tick so a full server can't tank the frame.
wally:
[dependencies]
FogWar = "debugasync/fogwar@0.1.0"or drop the src folder into ReplicatedStorage as a folder named FogWar.
local FogWar = require(game.ReplicatedStorage.FogWar.Server)
FogWar.SetOccluders({ workspace.Map })
FogWar.Configure({
TickRate = 20,
MaxRange = 800,
HearingRadius = 22,
LingerTime = 0.35,
})
-- optional: keep friendlies visible
FogWar.SetRelation(function(viewer, targetId)
return SameTeam(viewer.UserId, targetId)
end)
FogWar.BindPlayers()
FogWar.Start(game.ReplicatedStorage.FogWarRelay)FogWarRelay should be an UnreliableRemoteEvent. Position updates are fine to drop,
and unreliable events skip the ordering and resend overhead.
If you have your own rigs instead of default characters, skip BindPlayers and call
FogWar.AddViewer / FogWar.AddTarget yourself. Target ids just have to be numbers,
which is why UserId is the default.
local FogWarClient = require(game.ReplicatedStorage.FogWar.Client)
-- bind each ghost model you spawned for an enemy
FogWarClient.BindGhost(enemyUserId, ghostModel)
FogWarClient.OnVisibility(function(id, visible)
local ghost = ghostsById[id]
ghost.Parent = visible and workspace or nil
end)
FogWarClient.Start(game.ReplicatedStorage.FogWarRelay)The client interpolates between the last two snapshots with a small delay so movement
stays smooth at 20 ticks. OnVisibility fires when a ghost should be shown or hidden,
you decide what that means (parent swap, transparency, disable, whatever).
| key | default | what it does |
|---|---|---|
| TickRate | 20 | solves per second |
| MaxRange | 800 | studs past which nobody is revealed |
| HearingRadius | 22 | always-reveal radius |
| LingerTime | 0.35 | seconds a ghost stays after losing sight |
| CellSize | 96 | spatial hash cell size in studs |
| MaxRaysPerStep | 2500 | raycast budget per tick |
| UseFieldOfView | false | also require the target be in front |
| FieldOfView | 100 | degrees, only if UseFieldOfView is on |
Hiding characters is only worth something if nothing else leaks position. Any remote you fire to all clients with a location in it (gunshot origins, hitmarkers, nametags, kill feed coordinates, positional sound) hands a cheater exactly what FogWar withheld. A smart cheater does not hook the client, they go find one of these.
IsVisible is the gate. Ask it before you send:
for _, player in Players:GetPlayers() do
if FogWar.IsVisible(player, shooter.UserId) then
GunshotRelay:FireClient(player, shooter.UserId, origin)
end
endNow the gunshot only reaches people who could already see the shooter. Do this for every channel that carries a position and the surface actually closes. It returns true while a target is live or inside the linger window, false otherwise.
- you need custom replication, see the top of this readme
- sample points assume a roughly humanoid rig around 5 studs tall. change the offsets in Server.luau if your rigs are a different size
- occlusion is whatever you pass to SetOccluders. thin or non-collidable cover that isn't in that list won't block sight
- there is no client prediction of your own character here, this is only about what you're allowed to see of others
MIT