← All projects
Game

SHADE

A round-based social deduction game for Roblox. Hidden killers, a server that decides what each client may know, and a discussion round where everyone lies. In development.

Challenge

Social deduction only works if information is scarce. On Roblox the platform replicates almost everything to everyone by default, which is the exact opposite of what the genre needs.

Solution

Build the game around a server that decides what each client is allowed to know — visibility, chat, clue state and role identity — and treat every feature as a question about who is permitted to see it.

  • Roblox
  • Luau
  • Social deduction
  • Game design
The SHADE playtime rewards panel showing timed coin and gem drops
Lobby, map vote and ready-up1/2
SHADE media

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:

The SHADE match state machine: lobby, intermission, role assignment, then a repeating round of action, discussion, vote and reveal, exiting to match over when the parity check passes. Lobby Intermission Roles dealt min players 15 s 10 s ROUND LOOP · 190 s Action120 s Discussion30 s Vote30 s Reveal10 s no win condition parity met Match over
Durations are the live server values, not a design sketch.

A round is fixed-length, so the maths anyone actually cares about — how long a match runs — falls out of the loop count:

Tround=120action+30discussion+30vote+10reveal=190sTmatch(r)=25+190r+10T_{\text{round}} = \underbrace{120}_{\text{action}} + \underbrace{30}_{\text{discussion}} + \underbrace{30}_{\text{vote}} + \underbrace{10}_{\text{reveal}} = 190\,\mathrm{s} \qquad T_{\text{match}}(r) = 25 + 190r + 10

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 rr 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:

murderers win    {pA:evil(p)π(p)}e    {pA:¬evil(p)π(p)}g\text{murderers win} \iff \underbrace{\bigl|\{\,p \in A : \text{evil}(p) \wedge \pi(p)\,\}\bigr|}_{e} \;\ge\; \underbrace{\bigl|\{\,p \in A : \neg\text{evil}(p) \wedge \pi(p)\,\}\bigr|}_{g}

with AA the players still alive and π(p)\pi(p) the character’s CountsForParity flag. That flag is the entire mechanism behind the Clown, who wins personally by being voted out:

win(p)={voted out(p)if cond(p)="VotedOut"team(p) winsotherwiseπ(Clown)=\text{win}(p) = \begin{cases} \text{voted out}(p) & \text{if } \mathrm{cond}(p) = \texttt{"VotedOut"} \\[2pt] \text{team}(p) \text{ wins} & \text{otherwise} \end{cases} \qquad \pi(\text{Clown}) = \bot

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:

  1. Action — free movement, 120 s. Killers stalk and pick targets; investigators search the map for physical clues and evidence.
  2. 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.
  3. Vote — a public vote, 30 s. A tie or a majority to skip ends the round with nobody removed.
  4. 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.

What it shipped

  • Round loop on one state machine: action, discussion, vote, reveal — 190 s
  • Secret roles with asymmetric abilities and their own win conditions
  • Clue investigation, evidence logs and wiretap-style tracking tools
  • Match chat isolation, so the dead cannot help the living
  • Lobby with map voting, ready-up, loadouts, shop and playtime rewards
  • Server-authoritative movement, with per-viewer visibility culling
  • NPCs filling low-population lobbies, sensing through the same honest perception rules as players — in progress

More projects