· 7 min read
Keeping a Secret Role on the Server in a Real-Time SignalR Game
In a real-time multiplayer game, some information has to stay hidden from some players: who the spy is, what a card says, which tiles an opponent holds. Once that information reaches the browser it is no longer hidden, because anyone can open the developer tools and read it. In this post I explain how I kept the secret role on the server in Casus, a party game played on phones, how I send each player only their own view over SignalR, and how I tested it over real connections.
The game and its secrets
In Casus, 3 to 12 people join the same room from their own phones. When a round starts, everyone sees a secret card. The civilians all see the same place, for example "Hospital". The spy doesn't see the place; the spy's card only says they are the spy and shows the room's list of possible places. Players ask each other questions, then vote for the spy. A caught spy gets one last chance by guessing the place.
The whole game rests on two secrets:
- The place the civilians know must never reach the spy.
- Who the spy is must reach nobody until the round ends.
What was wrong in the old version?
I wrote the first version of Casus in the summer of 2025. When I started the rewrite and read the old code again, I found two separate leaks.
Leak one: the spy's identity was in the page source. When the game page opened, the server embedded the session state in the page as an object:
// shortened
ViewBag.GameSession = new {
gameSession?.Id,
gameSession?.StartedAt,
gameSession?.CurrentQuestion,
gameSession?.SpyParticipantId, // the spy's identity
gameSession?.Winner
};
let gameSession = @Html.Raw(Json.Serialize(ViewBag.GameSession));
That object was the same for every player. Anyone who opened the page source could read SpyParticipantId and know who the spy was. The interface didn't show the field, but not showing something is not the same as hiding it.
Leak two: the role announcement went to the whole room. The SignalR hub had a method for announcing roles:
public async Task PlayerRoleAssigned(string roomCode, string playerName, bool isSpy, string assignedWord)
{
await Clients.Group(roomCode).SendAsync("RoleAssigned", playerName, isSpy, assignedWord);
}
It sent the role and the secret word to the room's entire group, not to one player. And the hub had no authorization: anyone who knew the room code could call it from their own browser with any arguments. The client code didn't use this method, but leaving it open on the server was enough.
Both bugs share a root cause: the game state went out the same way to everyone, and the client decided what to hide.
The new design: everyone gets their own view
For the rewrite I flipped the rule: the client hides nothing, because nothing it should hide ever reaches it.
1. Every player has their own channel
In SignalR a connection can be added to groups. In the old version the group was the room code, so the whole room was one group. In the new version every player has a group of their own:
public static string PlayerGroup(string code, string playerId) => $"{code}:{playerId}";
If the same player has two tabs open, both join this group and get the same view. There is no message that goes to the room as a whole.
2. Identity comes from the session when the connection opens
Who the player is comes from the session cookie when the connection opens, not from a parameter the browser sends. If someone with no session, or who isn't a player in this room, tries to connect, the connection is closed immediately:
public override async Task OnConnectedAsync()
{
var http = Context.GetHttpContext()!;
var code = RoomCode.Normalize(http.Request.Query["room"]);
var session = await sessions.FindAsync(http);
if (session is null || !RoomCode.IsValid(code) || await rooms.ViewAsync(code, session.PlayerId) is null)
{
Context.Abort(); // no session, or not a player of this room
return;
}
Context.Items["player"] = session.PlayerId;
Context.Items["room"] = code;
await Groups.AddToGroupAsync(Context.ConnectionId, PlayerGroup(code, session.PlayerId));
// ...
}
Hub methods don't take a player id as a parameter. The id comes from Context.Items, so nobody can vote on someone else's behalf:
public Task<string?> Vote(string suspectId) =>
suspectId is { Length: > 0 and <= 40 } ? rooms.VoteAsync(PlayerId, Code, suspectId) : Task.FromResult<string?>("unknown_player");
The player only sends a move: start the round, vote, guess. The server decides whether the move is allowed: whose turn it is, whether voting has started, whether this player is the host.
3. The view is built on the server, per player
Whenever something changes in the room, the server builds a separate view for each player and sends it only to that player's group:
public Task RoomChangedAsync(Room room)
{
var now = clock.GetUtcNow();
return Task.WhenAll(room.Players
.Where(p => !p.Left)
.Select(p => hub.Clients.Group(GameHub.PlayerGroup(room.Code, p.Id))
.SendAsync("room", RoomView.For(room, p.Id, now))));
}
RoomView.For is where the decision about secret information is made. Here is the card part:
card = round!.Deal.IsSpy(playerId)
? new CardView(true, Place: null, places, round.Deal.SpyIds.Count)
: new CardView(false, round.Deal.Word, places, round.Deal.SpyIds.Count);
On the spy's card the place is null. Civilians see the place but not who the spy is; their card only says how many spies there are. The list of spies goes out to everyone only when the round ends, in the results view.
The list of places, on the other hand, goes to everyone. That is deliberate: the list is a room setting, it is the same for everyone, and it doesn't reveal who the spy is. During the last guess, the guess options go only to the accused player.
There is one more benefit to this design. Because the view is a C# record, every field that can reach a browser sits in one file, readable at a glance. Adding a new field means answering "who does this go to?" up front.
Testing over real connections
Testing this rule by calling RoomView.For alone isn't enough, because a leak can happen another way: a message sent to the wrong group, or a forgotten field. So I wrote the test end to end.
The test starts the app in memory with WebApplicationFactory and plays a round over four real SignalR connections: a room is created, three people join, the round starts, everyone votes for the spy, the spy guesses wrong. The test keeps every raw JSON message that reaches the spy's connection and checks that none of them, before the results, contains the place or the list of spies:
// Before the results, nothing the spy's connection received singled out the place.
foreach (var raw in spy.Raw)
{
Assert.DoesNotContain("wordIndex", raw, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("spyIds", raw, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("\"place\":{", raw);
}
Looking at the raw message matters. A test that inspects the view object can miss a field added later that the test doesn't check. In the raw text, the field is found wherever it is.
To see that the test really works, I copied the repository to a temporary folder and deliberately put the place on the spy's card:
? new CardView(true, Place: round.Deal.Word, places, round.Deal.SpyIds.Count)
The test broke right away:
failed A_whole_round_over_real_connections_and_the_spy_never_receives_the_place
Assert.DoesNotContain() Failure: Sub-string found
Found: ""place":{"
With the change reverted, all six game flow tests passed.
The same test file also has tests that protect the channel itself:
- Someone with a session who isn't in this room gets their connection closed and receives no state.
- A page on another site trying to open the channel with the player's cookie gets a 403.
- A player who isn't the host can't start the round, and nobody can vote before voting starts.
- If a connection makes more than 20 calls in 10 seconds, the extra calls are refused before they reach the game.
Summary
- Anything sent to the browser can be read. Information that must stay secret should never be sent to the client; not showing it in the interface isn't enough.
- In the old Casus, the spy's identity was in the page source and the role announcement went to the whole room over SignalR.
- In the new version every player has their own SignalR group. After every change the server builds a separate view for each player; the spy never gets the place, and civilians never get the spy's identity.
- The player's identity is taken from the session when the connection opens, and hub methods take no identity parameter.
- A round played over four real connections checks every raw message that reaches the spy. When I added a leak on purpose, the test broke.
Casus is live at spygame.miracdeprem.com. The source code is on GitHub: github.com/MrcDprm/spy-game.