Project Overview
Undercover is an online multiplayer social deduction game built entirely solo — from initial architecture to Steam release. Players communicate, vote, and try to identify the undercover agent among them before time runs out.
The game reached 16,000+ lifetime units sold on Steam within 2 years, with a playerbase that surfaced real networking edge-cases I had to diagnose and fix post-launch.
My Role
- Full solo development — design, programming, systems architecture
- All networking code written from scratch using FishNet
- Steam integration via Steamworks SDK
- Post-release maintenance and patch support
- Player feedback triage and live bug fixing
Technical Challenges & Solutions
Game state diverging between clients during the meeting phase — players seeing different UI states simultaneously.
Moved all meeting triggers to the server. Server fires ObserversRpc to all clients simultaneously, guaranteeing identical state.
Voting results inconsistent when players disconnected mid-vote or voted simultaneously at race condition boundaries.
All vote tallying and result calculation runs server-side only. Disconnected players auto-counted as skip votes.
Architecture: Server-Authoritative Flow
Every meaningful gameplay transition follows the same pattern: Client input → ServerRpc validation → Server state change → ObserversRpc broadcast → Client UI update. Clients never mutate shared state directly.
This made the codebase significantly easier to debug: if something was wrong visually, I always knew the source of truth was on the server and could log from there.
Voting System — Production Code
The voting system handles all edge cases: skip votes, ties, player disconnects mid-vote. All logic runs on the server; clients only receive the final result.
public enum VoteResultType { PlayerVotedOut, Tie, Skipped }
public class VoteResult
{
public VoteResultType ResultType;
public PlayerMovement VotedPlayer;
}
public class VotingSystem
{
private readonly List<PlayerMovement> _players;
public VotingSystem(List<PlayerMovement> players) => _players = players;
public VoteResult CalculateResult(int skipVotes)
{
PlayerMovement topPlayer = null;
foreach (var player in _players)
{
if (topPlayer == null || player.votes.Value > topPlayer.votes.Value)
topPlayer = player;
}
bool isTie = _players.Any(p =>
p != topPlayer && p.votes.Value == topPlayer.votes.Value);
bool skipWins = skipVotes > topPlayer.votes.Value;
if (skipWins)
return new VoteResult { ResultType = VoteResultType.Skipped };
if (isTie || skipVotes == topPlayer.votes.Value)
return new VoteResult { ResultType = VoteResultType.Tie };
return new VoteResult {
ResultType = VoteResultType.PlayerVotedOut,
VotedPlayer = topPlayer
};
}
}
What I Learned
- Shipping is the hardest test: players find bugs unit tests never will
- Server authority isn't optional in multiplayer — it's the foundation
- Designing disconnect handling from day one saves days of refactoring later
- Post-launch patches teach you more about networking than building from scratch
- Social deduction games require extremely tight state consistency — perfect domain for multiplayer skills