The match is one state machine
Everything else hangs off MatchService. There is no second authority on what
phase it is, which is what makes “who is allowed to know this right now” a
question with one answer instead of four:
A round is fixed-length, so the maths anyone actually cares about — how long a match runs — falls out of the loop count:
Elimination is not guaranteed each round: a tie, or a majority voting to skip, ends the round with nobody removed. That matters more than it sounds, because it means has no upper bound from the vote alone — the parity check is the only thing that ends a match.
Death does not eject you. An eliminated
player becomes a ghost — ForceField
material, tinted blue, at half transparency — with a spectator
camera that
cycles living
players. A disconnect inside three minutes gets a prompt to rejoin
the same match, in the same state. The rule underneath all of it is that a dead
player is a viewer, never a participant: the visibility system and the
chat
isolation both key off the same flag, and chat drops on the tick you die rather
than at the next phase change.
The ghost keeps its collision, which looks like the wrong call and is not. The
obvious version — CanCollide = false, parts moved into a Ghosts collision
group — is what silently broke it. That group is registered non-collidable with
Default, the
map floor is in Default, so the ghost fell through the world,
hit the fallen-parts floor, died, and Roblox auto-respawned it as a plain
un-styled rig. The styled body vanished about 2.5 s in and came back opaque and
Plastic, while the murderer’s passive logged a fresh start for each silent
respawn.
It went unnoticed for a long time because ghosts only existed for rare join-in-progress cases. Making a ghost the default state on death turned a rare cosmetic bug into a guaranteed death-and-respawn loop. Living players can now bump into a ghost, which is a small price for a ghost that reliably exists over one that reliably dies.
The arithmetic a round is decided by
Composition is rolled per match from ranges rather than fixed per lobby size,
so two lobbies of the same size do not play identically. The table is keyed on
the highest matching minimum, and Innocent is deliberately absent from it —
it is the remainder, which means a mistyped row can under-fill the specials but
can never produce a match with nobody to protect:
-- Filled in priority order. Truncation must drop a HELPER, never the murderer:
-- a lobby with no murderer is a match that cannot end.
FlickerComposition.Priority = {
SlotType.Murderer,
SlotType.Investigator,
SlotType.EvilHelper,
SlotType.GoodHelper,
}
-- Highest matching MinPlayers row wins. Ranges are inclusive.
FlickerComposition.Rows = {
{ MinPlayers = 1, [SlotType.Murderer] = {1, 1} },
{ MinPlayers = 4, [SlotType.Murderer] = {1, 1}, [SlotType.Investigator] = {1, 1} },
{ MinPlayers = 6, [SlotType.Murderer] = {1, 1}, [SlotType.Investigator] = {1, 1},
[SlotType.EvilHelper] = {0, 1}, [SlotType.GoodHelper] = {0, 1} },
{ MinPlayers = 9, [SlotType.Murderer] = {1, 1}, [SlotType.Investigator] = {1, 1},
[SlotType.EvilHelper] = {1, 2}, [SlotType.GoodHelper] = {1, 2} },
{ MinPlayers = 12, [SlotType.Murderer] = {1, 2}, [SlotType.Investigator] = {1, 1},
[SlotType.EvilHelper] = {1, 2}, [SlotType.GoodHelper] = {1, 2} },
}
The priority order is doing quiet work. A lobby can be too small to seat everything the row asks for, and when the list is truncated it has to be a helper that goes — a match dealt no murderer is a match that cannot end.
Helper counts roll independently and are then clamped to a gap of one:
local evil = counts[SlotType.EvilHelper]
local good = counts[SlotType.GoodHelper]
while math.abs(evil - good) > 1 do
if evil > good then
evil -= 1
else
good -= 1
end
end
Fully independent rolls produce two evil helpers against zero good ones, which reads as broken rather than as variety. A hard mirror is worse, because anyone who counts one side instantly knows the other — which is information leaking out of the composition itself, in a game whose whole subject is information. The clamp only ever subtracts, so it cannot push a count above what the row allowed.
Two fields on the character carry the whole of it. Both default, so the ten existing characters needed no edits at all:
-- What winning means for this character.
-- "Team" win with your side (default)
-- "VotedOut" you win personally the moment you are voted off, whatever
-- else happens in the match
WinCondition: string,
-- Whether this character counts toward the evil/good parity that decides the
-- TEAM outcome. Clown sits in the EvilHelper slot but must not make the
-- murderers win merely by being alive, so it sets this false.
CountsForParity: boolean,
The match ends on parity, not on a body count:
with the players still alive and the character’s CountsForParity
flag. That flag is the entire mechanism behind the Clown, who wins personally
by being voted out:
Excluding one character from the count does all the work. A living Clown neither keeps the good side alive nor helps the murderers close it out — it is simply not in the arithmetic, so it can never become an accidental shield.
One place a death is recorded
Parity arithmetic is only as good as the list it counts, and there are three separate places in the codebase that set a participant’s health to zero. Hooking elimination at each of them works right up until somebody adds a fourth:
-- Arms one Died handler per participant. Hooked HERE rather than at each
-- `Health = 0` site, because the fourth site somebody adds later is the one
-- that would silently go unrecorded.
function MatchService.ArmDeathWatch(participants: { Instance })
MatchService.ClearDeathWatch()
for _, participant in ipairs(participants) do
local character = if participant:IsA("Player") then participant.Character else participant
local humanoid = character and character:FindFirstChildOfClass("Humanoid")
if humanoid then
table.insert(deathConnections, humanoid.Died:Connect(function()
-- EliminatePlayer is idempotent, so a vote-out marking the player and
-- this firing half a second later stays one elimination, one payout.
MatchService.EliminatePlayer(participant)
end))
end
end
end
Subscribing to the engine’s own Died signal makes the recording a consequence
of dying rather than something each caller has to remember. Idempotence is what
makes that safe: a vote-out marks the player, the humanoid dies a moment later,
and the two collapse into one elimination and one payout.
A related fix went the other way. Death used to cut the action phase short the instant anybody died — which meant the body was never found, it was announced. That quietly deleted the murderer’s only real skill, which is not being near the corpse when it turns up. The round now runs its full clock, and only a living player walking into a body and reporting it ends the phase early.
What it is
SHADE is a round-based multiplayer social deduction game in the lineage of
Flicker and Murder Mystery 2. Players are assigned secret
roles at the start of
a match and dropped into a shared map. Some of them are killers. Everyone else
is trying to work out who, using what they saw and what other people claim they
saw.
A round cycles through four phases:
- Action — free movement, 120 s. Killers stalk and pick targets; investigators search the map for physical clues and evidence.
- Discussion — open chat, accusations, alibis, 30 s. Players are seated for it, so the phase reads as a meeting rather than as everyone standing still.
- Vote — a public vote, 30 s. A tie or a majority to skip ends the round with nobody removed.
- Reveal — 10 s, then straight back into the action phase.
Repeat until the parity check ends it.
Everything is a visibility question
The design constraint that shapes the whole codebase: on Roblox, the default is
that every
client knows everything. For a genre built on incomplete
information, every feature has to start by asking who is allowed to know this.
That question kept producing subsystems large enough to stand on their own:
- Movement and position became
Dynamic Culling — per-viewer line-of-sight
culling on the
server, so a wallhack has nothing to read. - Chat became match chat isolation, so eliminated players cannot feed information to the living.
- Clues and evidence became a server-owned log with per-role access, rather than a client-side UI reading shared state.
- Filling empty lobbies became Smart NPCs — bots that sense the world through the same honest perception rules players are subject to, so they cannot accidentally play better than a human by knowing more.
None of those started as portfolio projects. They started as “this specific thing leaks, and the game does not work if it leaks”.
Filling an empty lobby
Social deduction needs a quorum. Below about six players the roles stop being interesting, and a new game is below six players almost all of the time — which is the problem that turned into Smart NPCs, the largest subsystem here and its own project page.
The constraint it inherits from SHADE is the one that matters: a bot senses the world through the same honest perception rules a player is subject to. It gets a sight cone, occlusion raycasts and a hearing radius, not a read of the participant table. A bot that quietly knew more would not play better, it would play wrong — the entire game is an information game, and an omniscient participant is not a harder opponent so much as a broken one.
BotDirector.IsBot(model) is the authoritative answer to whether a participant
is a bot, and everything else keys off it: bots are Models rather than
Players, so GetAlivePlayers returns participants, and the win conditions
count them exactly as they count people.
Still in progress. Bots hold a conversation, path through interiors, use abilities and vote, but they are not yet good enough at deduction to carry a round on their own — a lobby of bots plays a recognisable but shallow game. The work left is on the Smart NPCs page.
Outside the round
A social deduction match is short, so the surrounding loop matters as much as the round itself. The lobby carries map voting, ready-up, character and ability loadouts, a shop, and timed playtime rewards — the screenshot above is that panel mid-session.
Status
In development. The round loop, roles, clue systems, lobby and progression are in; balance, content and the art pass are ongoing.
The next phase-level feature is a blackout: a timed segment inside the action phase where vision becomes role-dependent, so what anyone can honestly claim about where people were has a hole in it. It is specified but not built — the state machine above is the shipped one.



