Y
Case Study · 01 / 06 Jun 2025 – Present

Masterworks of Horror

Role
Technical Producer · Network / Gameplay Engineer
Context
Masterworks Studios (The Prologue)
Engine · Stack
Unity 6 · NGO · PlayFab
Link
Steam Page ↗

Overview

Masterworks of Horror is a real-time PvP card battler set in the public-domain horror canon of Lovecraft, Poe, and others. It runs server-authoritative on dedicated servers, so a competitive match is decided by the server, never the client.

I joined as technical producer and engineer. I own the online layer and the build and release pipeline, and I built the studio's tooling for running a team of human engineers alongside AI coding agents.

Contributions

  1. Production tooling for the AI-agent era: an autonomous coding agent and the team's board.
  2. Server-authoritative netcode, and the live server migration to Microsoft Azure PlayFab.
  3. Client-side prediction so a server-authoritative game still feels instant.
  4. The multi-platform build and deploy pipeline across five targets.
The effort

A year, in commits

Calendar heatmap of Cooper Yang's Masterworks of Horror commits, June 2025 to May 2026, ramping to a dense crunch through February to April 2026

1,392

authored commits (+575 merges)

~40%

of the whole project · #1 of 25+

55%

of all days active · 189 / 342

14

day streak · peak 47 in one day

Chapter 01

Producing humans and agents

I treated every AI failure mode as a producer problem, not a model problem.

As AI coding agents arrived, the new friction was not the code they wrote. It was the seams: where an agent hands off to a human and the handoff breaks. As technical producer I built the tooling that closes those seams. It runs entirely on a Mac mini behind a tunnel, with a local database and zero cloud cost.

The pipeline I built and proved on the real game

01 · human

Idea

Raw text. No AI runs.

02 · human

Diagnose

Opt-in Opus enrich, then queue.

03 · agent

Implement

Isolated branch, commit per step.

04 · agent

Review + fix

Second Opus pass, one auto-fix.

05 · stuck?

One question

Asks, then re-enters the queue.

06 · human

Clean PR

First human contact is a reviewed PR.

Human Agent Question loop
The task intake list A task opened and diagnosed

The intake · capture a task, diagnose it, then queue it to the agent

Five seams, five fixes

Friction

Agents fail silently and dump noise on the human.

Mechanism

The agent writes one focused question and re-opens the loop. In its own words: turn attention from a dead end into a conversation.

Friction

Parallel agents collide on the git index and ref locks.

Mechanism

A worktree per task plus a serialized git mutex, born from a real three-worker collision.

Friction

An autonomous agent can burn unbounded tokens.

Mechanism

A hard ceiling of two to four Opus calls per task: one work, one review, an optional fix and re-review.

Friction

An AI call on every captured idea taxes capture itself.

Mechanism

Raw text saves with zero AI. Enrichment is a separate, opt-in click.

Friction

Reviewing AI-generated code is the biggest human cost.

Mechanism

The agent runs its own review and one auto-fix before a human ever opens the PR.

2–4

Opus calls per task

406

pull requests merged

362

branches integrated

921

AI co-authored commits gated

$0

per month · self-hosted

d666b414d fix review issues — prevent orphaned popup coroutines on disable/match-end

committed by the coding agent · Co-Authored-By: Claude Opus · CardEffectNotificationController.cs

Not a demo. The agent's own review caught and fixed a coroutine leak on live game code, before a human ever opened the pull request.

Two sides, one site

The tooling has two sides. On one, I capture a raw idea and hand it to the coding agent, which branches, implements, reviews, and opens a pull request. On the other, a team board with sprints, epics, and SQL-computed velocity and burndown holds the human engineers' work.

An idea captured on the agent side can be promoted straight onto the team board, so a solo experiment graduates into the team's plan.

Chapter 02

Competitive integrity, in real time

You play a card ServerRpc · a bot calls the server directly Server validates + performs NOW authoritative · never waits for clients 50Hz tick NetworkVariable<GameTicks> action, per recipient you: full · foe: hidden Client queues actions by tick Catch-up loop: replay tick-by-tick ≤ 200 ticks/frame · warns if > 50 behind Same logic both sides · visuals client-only
Server authority · perform now, replay by tick

The tension that shapes everything

A competitive card game has to be cheat-proof, which points straight at dedicated servers and a server that owns the truth. But this game is real-time, not turn-based, so it also has to feel instant under a player's hands. Those two goals pull against each other.

Built on Unity Netcode for GameObjects with a dedicated-server build, over plain UDP. Every player intent is a server RPC: ready a deck, mulligan, play, concede, rematch. The server is the only thing that mutates state, validates it, and rejects bad requests, then broadcasts the result. I am the top author of the player command surface and the replicated match model.

On the board · a real-time 1v1 the server decides

Chapter 03

Making it feel instant

Drag a card onto a zone Affordable? displayed = model − pending reservations Predict: animate now Pending · visual only Server: validate + perform Reconcile via the tick queue not a callback ✓ Confirm refresh card · no re-animate ✗ Reject roll back to hand 5s unconfirmed → timed out, kept to reconcile late Character / Setting cards only
Client prediction · predict in parallel, reconcile by tick

Prediction, then reconcile

A server-authoritative game can feel laggy if the player waits for a round trip on every action. So I built a client-side prediction system: drag a card and it plays locally right away, with the cost subtracted from your hand instantly. When the server confirms, the prediction swaps in the authoritative card and skips the redundant animation. When the server rejects, the card rolls back to your hand. It only ever touches visuals, never the authoritative model.

The piece that makes prediction free: I removed the old habit of pausing the server to wait for client animations. The simulation now ticks continuously at 50 Hz and animations are purely cosmetic, so they can never block or desync the real game state.

A card resolving in real time

Chapter 04

From Unity GS to Azure PlayFab

OnlineServices.cs
1// The whole dedicated-server lifecycle behind one interface.
2public interface IServerHostHandler
3{
4 Task<ServerAllocation> WaitForAllocationAsync();
5 void ReadyForPlayers();
6 Task OnShutdownAsync();
7}
8
9// Gameplay never learns which backend it is on.
10IServerHostHandler host = OnlineServices.ActiveBackend switch
11{
12 Backend.PlayFab => new PlayFabServerHostHandler(),
13 Backend.Ugs => new UgsServerHostHandler(),
14};

Swapping the backend under a live game

Partway through development the entire multiplayer backend changed hosts. We moved hosting and matchmaking off Unity Gaming Services and onto Microsoft Azure PlayFab, on a game that already had players in it.

I made the swap safe by hiding both backends behind one interface. The whole dedicated-server lifecycle, auth, heartbeat, allocation, and shutdown, sits behind IServerHostHandler, with the matchmaker behind its own. The game calls the interface and never knows the backend. We ran both in parallel, cut traffic over to PlayFab, then deleted the Unity path.

CLIENT SERVER Anon login · EntityToken Create ticket Poll status · every 6s Matched → IP : port StartClient Boot · role = Server GSDK callbacks + heartbeat ReadyForPlayers · StandingBy Await allocation Bind UTP · StartServer UDP connect → match starts 30s idle-timeout if no client joins
The fleet · two ladders meet at the connect

Hosting, matchmaking, and a one-button deploy

I own the entire online-services layer. A server build registers with the PlayFab game-server SDK, signals that it is standing by, waits for an allocation, and resolves its own UDP port so several servers can share a machine. Matchmaking runs through PlayFab queues for ranked, private, and solo-versus-bot.

Releasing all of that is a single 910-line Python tool I wrote. It packages the Linux server, uploads it, creates the build, and flips the live matchmaking queues over, with subcommands to scale capacity, SSH into a hot VM, and pull post-mortem logs. The fleet also defends itself: reconnects outlast the engine's async shutdown, the matchmaker retries transient failures with backoff, and idle servers shut themselves down after thirty seconds.

Loading into a match · matchmade, allocated, connected

Chapter 05

Optimization & systems

Past the netcode, the work I am most proud of: rendering and memory, save encryption, and the metagame economy.

14,000 materials, then 9

Every card cloned its own material to drive glow, corruption, and sweep, which broke Unity's SRP Batcher and pinned a CPU core on the render path. I baked seventeen shared per-rarity variants and routed every per-card property through one MaterialPropertyBlock, so the shared material is never mutated. Runtime material count fell from about fourteen thousand to nine.

59a92dba7 · Card3D · PR #635

A deck grid that doesn't grow

The collection grid spawned a full Card3D for every card you own, and Card3D is an 8,600-line object with meshes, VFX, and animation. I rebuilt it as a virtual scroll: only the visible window plus a small buffer is ever materialized, recycled through a pool with a proper reset. Its cost is now constant in collection size.

42e07fd87 · DeckEditorCardPoolManager · PR #462

Saves you can't edit

Account data was a plaintext JSON anyone could edit for free currency. I wrote a proper Encrypt-then-MAC module: AES-256-CBC plus HMAC-SHA256, keys derived with PBKDF2 at 200k iterations, a constant-time tag compare, atomic writes, and zero-touch migration of existing saves, with an honest threat model for what it does and doesn't stop.

446c42037 · EncryptedJsonFile.cs

An economy from a spreadsheet

I built the metagame economy from scratch: a weighted-distribution roll engine with pity and guarantee rules, duplicate-to-currency conversion so a pull is never wasted, two currencies, and a soul exchange. All of it tuned from a designer spreadsheet, with no code changes to rebalance.

433045f5b · StoreManager.cs · ~2,400 lines

The hot path

Every action runs through a 50 Hz server broadcast. I replaced reflective instantiation with cached factory delegates, cut the per-recipient JSON from one payload per player to two, removed a LINQ allocation in the loop, and fixed a visual queue that iterated but never drained, so it leaked all match.

8e02ff8c7 · GameStateChangeAction

Two systems, up close

SERVER CLOCK · ticks at 50Hz now NetworkVariable<GameTicks> replicates serverTick CLIENT · a few ticks behind queued catch-up → serverTick ≤ 200 ticks/frame · warns if > 50 behind · FastLane first
The 50 Hz tick · the server never waits

How it stays in sync

The server runs a clock and never blocks. It ticks at 50 Hz and performs every action immediately. Under latency a client falls a few ticks behind, then a catch-up loop replays the queued actions tick-by-tick until it reaches the server's tick. Deterministic, and capped so a network hitch can never spiral.

Build Consoleenv · flags Config swapDev / Prod Preprocessstrip · stamp Build · 5 targetsiOS Android Win Mac Linux Postprocesssign · notarize Package.tar.gz DeployPlayFab MPS · flip queues
The pipeline · one window to a live deploy

One window to a live fleet

Shipping to five platforms is one window. The Build Console swaps Dev or Prod config, strips about 1.5 GB of card art per device build, builds each target (the server strips its own client assets), then packages and deploys the Linux server to the live PlayFab fleet, flipping the matchmaking queues over. The coding agent can run the whole thing headless.

The MOH Build Console in the Unity editor

The Build Console · the real window behind the diagram

One window, for real

The diagram, as the actual editor tool: pick Dev or Prod, the client targets, and the build flags (skip tutorial, unlock cards, the cheat manager), then build, package, and deploy to PlayFab in one click.

The deck editor · the grid stays cheap no matter how big the collection

Chapter 06

Debugging the bot

BotBehaviorProfile.cs · the bug I traced
1// The "frozen bot": at a full hand, drawing
2// should be worthless. A one-character typo
3// made it the bot's favorite move.
4
5float DrawScore(BoardState board)
6{
7 float value = 0f;
8 if (board.HandSize >= board.HandCap)
9 value -= -5000; // BUG: -= -5000 ADDS 5000.
10 return value; // Bot draws to lock-up, then freezes.
11}
12
13// FIX: value -= 5000; and read board.Hand.Count
14// live, not a HandSize cache draw never refreshed.

Hunting a frozen bot

The opponent bot is a hand-tuned scoring engine the team built, around 4,300 lines across two files. My job was to keep it from breaking in a real-time match, which meant living in its edge cases.

My favorite catch: a single typo, value -= -5000, made the bot value drawing at +5000 when its hand was already full, so it drew over its own cap and then refused to play. The infamous frozen bot, fixed by one sign and a live hand count. I also untangled a stack-overflow in its graveyard scoring and a stale cache that stalled its turn.

A hidden observation tool

I also built an in-game spectator and free-cam suite, invisible in shipped builds, flipped on by a build flag and a backquote key so it never shows in a screenshot. One feature mirrors your own board to render exactly what your opponent sees, which is how we debugged a real-time duel from both sides at once.

Game event card moved · combat · inspiration Score every legal play generic value, by card type + a hand-tuned branch for ~40 cards Play the highest score Difficulty sets how fast it acts Easy 10s · Hard 4s · Extreme 2s
The bot · score every play, take the best

How the bot decides

The bot the team built is a hand-tuned scoring engine, not a search. On every game event it scores each legal play, generic value by card type plus a specific branch for about forty named cards, and plays the highest score. Difficulty only changes how fast it acts. My job was finding where that scoring broke.

Chapter 07

Shown in the wild

Trailer still

From the trailer

The pitch

A fast, real-time card battler built on the public-domain horror canon. Build a deck from history's authors, blend their works, and duel for the win. The PC build is up for wishlist on Steam as The Prologue, with a mobile beta signing up players now.

Played by real people

Masterworks of Horror has been on the floor at GDC and an expo, with a playable build in front of strangers, which is the only test that really counts.

The Masterworks of Horror booth on the GDC show floor
GDC · the booth
The team at the GDC booth
GDC · the team on the floor
Trailer still In-game

Trailer still · in-game

What I take from it

The interesting work here was not one feature. It was holding competitive integrity and real-time feel together, shipping the result to three stores, and building the production pipeline that let a small team move at the speed AI agents promised without losing the thread.

Next · 02 / 06 SYNCED: Off Planet →