Vincent Schwier
← Back to Portfolio
✦ Shipped on Steam Solo Developer · 2024

Undercover

Online Multiplayer · Social Deduction · Unity · C# · FishNet · Steamworks

UnityC#FishNet SteamworksClient-Server State MachinesSteam LobbyServerRpc
Undercover

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

Problem

Game state diverging between clients during the meeting phase — players seeing different UI states simultaneously.

Solution

Moved all meeting triggers to the server. Server fires ObserversRpc to all clients simultaneously, guaranteeing identical state.

Problem

Voting results inconsistent when players disconnected mid-vote or voted simultaneously at race condition boundaries.

Solution

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