How I Built Card of War
I shipped a card game this year. It's called Tiến Lên, the Vietnamese climbing game also known as Thirteen, and it lives at tienlen.games. The repo has a different name: card-of-war, and that name is thirteen years older than the game.
The project is a full-stack Swift app: a SwiftUI iOS client, a Vapor backend on Railway, and two shared Swift packages sitting between them. As of today it's 297 commits, around 350 Swift files, and about 56,000 lines of Swift across four targets.
But the interesting part isn't the inventory. It's the shape of the history. I ran git log --reverse while writing this post, and the log tells a story in three acts, plus a prologue from another decade: a manic two-day prototype sprint in January, a shipping push in March, and, after a five-month gap, a two-week blitz in August that transformed a vibe-coded prototype into something I'd actually defend. The commit messages themselves are an archaeological record. The project starts with working, wtf, and i dunno maybe, and ends with messages like Stop the sweep cursor stranding rows. That arc is what this post is about, and how apps generally get made from an indie perspective.
The 2013 false start
About that name. The repo still has a master branch whose entire history is one Tuesday afternoon:
a1d1986 2013-02-26 12:56 Initial commit Non turn based
371c8fb 2013-02-26 14:44 Turn Based Changes
5503c70 2013-02-26 18:15 Merge pull request #1 from kylebrowning/turn-based
February 26, 2013. Objective-C, UIKit, a GCTurnBasedMatchHelper.m wrestling Game Center's turn-based matches, and a plan to build a card game called Card of War. The attempt is three commits deep and died the same day it was born, mostly because GameKit's turn-based matchmaking fought me at every step and I ran out of steam before I got to any actual card game.
Thirteen years of silence later, the 2026 rewrite went into the same repo on a new branch. It inherited two things from that afternoon: the name, and the conviction that the multiplayer plumbing is the hard part. This time I skipped Game Center entirely and built my own server, which is why a card game ended up with a Vapor backend.
The two-day sprint
The first commit of the modern era landed on a Monday night:
6d7cd39 2026-01-26 20:09 Initial
0ab8f4d 2026-01-26 22:48 working
5b57908 2026-01-26 23:08 looks great
d970796 2026-01-26 23:10 remove shit
The Initial commit already contained a working full stack: a Vapor server with a 700-line LobbyController, WebSocket handlers, Fluent models for users, lobbies, and games, plus the SwiftUI app with card views, a hand view, and a game service. The gap between "I have an idea" and "I have a running client-server prototype" was a few hours on a Monday evening.
Twenty-six hours after Initial, this happened:
8d7e2b0 2026-01-27 21:21 i dunno maybe
d2f2bed 2026-01-27 21:38 working
0b98b45 2026-01-27 21:59 push fixes
6f918c8 2026-01-27 22:36 1.0
A commit literally titled 1.0, one day in. It was nowhere near an actual 1.0, as the rest of the history demonstrates. But it played a complete game of Tiến Lên against AI opponents, and online lobbies worked over WebSockets. That's the point of a prototype sprint: get to the moment where the thing is real as fast as possible, because a real thing tells you what to build next in a way a plan never does.
The rest of the winter was a trickle of evenings, short bursts around 10pm in the log, fixing rules edge cases (remove 2-consecutive-pairs as a valid hand) and making the AI feel human (Add AI names, personalities, and 60s turn timer). There is also a commit from 11pm on a Tuesday titled, in its entirety, wtf.
I want to defend wtf for a second. Everyone tells you to write good commit messages, and they're right, but a solo prototype has different economics. In February, nobody was ever going to git blame this code, and the honest emotional state of me at 11pm is information. The time to invest in commit hygiene is when the code starts mattering, and you can watch exactly that transition happen later in this log.
The architecture that fell out of it
One structural decision from the early days ended up carrying the whole project: the game rules live in their own package, TienLenCore, and both the iOS app and the Vapor server depend on it. When the server validates a play, it runs the same RulesEngine the client used to offer that play. There's no protocol drift between what the app thinks is legal and what the server enforces, because there's only one implementation.
A second shared package, TienLenClient, holds the networking layer (API client, DTOs, token storage), and it's used by the iOS app and by the backend's own integration tests. The backend tests exercise the server through the exact client code the app ships with. When a test creates a lobby, it calls lobbyService.createLobby(.southern) just like the app would. I stumbled into this pattern early and it paid for itself repeatedly. An entire class of "the server changed a field name and the app broke" bugs simply can't exist.
So the repo settled into four Swift targets:
card-of-war/
├── ios/TienLen/ # SwiftUI app
├── backend/ # Vapor server (Railway)
├── TienLenCore/ # Shared rules engine
└── TienLenClient/ # Shared networking + DTOs
The multiplayer loop itself is WebSockets end to end. Each seat in a lobby holds one socket, and every game action travels as a small JSON message: ready, play, pass, stage. When a play arrives, the Vapor handler does the sequence of checks server code has to do, and it does them with the shared rules engine rather than a reimplementation:
private static func handlePlay(
cards: [String], lobbyCode: String, lobbyId: UUID,
seatIndex: Int, db: any Database, ws: WebSocket
) async {
await LobbyLock.shared.run(lobbyCode) {
// ... load the lobby, game, and current GameStateData ...
guard gameState.currentPlayerSeat == seatIndex else {
try? await ws.send(encodeError("Not your turn"))
return
}
// Verify player has these cards. Counts duplicates: a set-based
// check would let one held card be replayed as a pair or a bomb.
guard let hand = gameState.hands[seatIndex],
RulesEngine.hand(hand, contains: cards) else {
try? await ws.send(encodeError("You don't have those cards"))
return
}
let engine = RulesEngine(config: lobby.rulesConfig ?? .southern)
let adapter = GameStateAdapter(gameState: gameState)
switch engine.validatePlay(cards: cards, playerSeat: seatIndex,
gameState: adapter) {
case .success:
break
case .failure(let error):
try? await ws.send(encodeError(error.errorDescription ?? "Invalid play"))
return
}
// ... apply the play, persist, broadcast a redacted turn_played ...
}
}
Two details in that handler took months to learn. The hand check counts duplicates, because a set-based check would happily accept one held three of spades played twice as a pair. And the whole body runs inside LobbyLock.run, which serializes every mutation for a lobby. Neither guard existed until August, and both have a story below.
Shipping mode
After a month of quiet, March opens with the largest commit in the repo:
84fe44b 2026-03-07 13:12 Bug fixes + Premium IAP + Railway deployment
60577aa 2026-03-09 08:44 Growth features + App Store submission prep
ecbddd7 2026-03-09 08:56 Remove AWS deployment, using Railway now
That first commit's message body reads like a confession of everything the prototype had been hiding: straights and consecutive pairs could never beat each other in canBeat (a genuinely game-breaking rules bug), the server wasn't validating plays at all, there was an N+1 query in lobby fetching, and double-saves in the WebSocket handler. It also added the business model, a $4.99 one-time Premium unlock covering ranked mode, six premium themes, and extra lobbies, built on StoreKit 2.
This is also where the app's service layer got its shape. Every service is a struct of closures rather than a protocol or a singleton, so each one ships with live, mock, and unimplemented variants and gets wired together in a single AppServices value:
struct PushService: Sendable {
var registerToken: @Sendable (_ token: String) async throws -> Void
var unregisterToken: @Sendable () async throws -> Void
var resetBadge: @Sendable () async -> Void
}
extension PushService {
static func live(api: APIService) -> PushService { /* real APNs work */ }
static var mock: PushService { /* no-ops for previews and tests */ }
}
Swapping a whole subsystem in a preview or a test is one line, and there's no mocking framework anywhere in the project.
Two days later the app went to App Store review, the backend moved from a half-configured AWS setup to Railway with auto-deploy on push to main, and then the log goes quiet.
The five-month gap
ad84f6f 2026-03-09 10:02 Speed up Docker builds with cached .build directory
0234ea6 2026-08-04 17:00 Fix gameplay correctness, hand leaks, and game-state races
Two adjacent commits, five months apart. The app was live, people were playing it, and I was busy with other projects. I suspect every indie developer recognizes this gap. What makes it worth writing about is what was quietly wrong the entire time, and what happened the day I came back.
The August blitz
Day one back, and the server is on fire
I sat down after work on August 4th to give the game some attention and found production down. It had been down since about one that morning. The postmortem I committed to the repo tells the chain plainly: Railway migrated its infrastructure to their Metal platform, which broke private networking between the app and Postgres. The server crashed at boot because autoMigrate had no retry logic. Railway's restart policy gave it three attempts, all of which failed inside the same broken network, and then it gave up.
A second failure turned the incident into a day-long outage. That last commit from March, the Docker build "optimization", used BuildKit cache mounts that Railway's builder rejected. So even after I diagnosed the problem, no fix could deploy. The pipeline that was supposed to ship the cure was itself broken, and had been for five months. Nothing had noticed because nothing had shipped.
0234ea6 2026-08-04 17:00 Fix gameplay correctness, hand leaks, and game-state races
a34d7e3 2026-08-04 17:04 Remove BuildKit cache mounts — Railway rejects them
4df8cbb 2026-08-04 17:32 Raise healthcheck timeout above the DB retry ladder
The lesson I took from this is that a dormant service accrues failure modes silently. The fixes that came out of the outage (a boot retry ladder for the database, a healthcheck timeout longer than that ladder, a Dockerfile that provably builds on the deploy target) are all things a "finished" project never gets, because nobody's watching.
The uncomfortable audit
That same evening, while fixing the deploy, I finally read the WebSocket broadcast code with fresh eyes and found this gem in the turn_played message: the server was sending every player's full hand, and the complete card mapping, to every client, on every turn. Any player with a proxy could see everyone's cards. The prototype had been a cheater's paradise since January, and nobody, including me, had looked.
The same session fixed that, made the server validate bombs with the same logic as the client, and introduced LobbyLock, because human plays, AI turns, and turn-timer expirations had been racing on the same state JSON since the beginning. The fix is a small actor that queues work per lobby in FIFO order:
/// Serializes game-state mutations per lobby so concurrent actions
/// (human plays, AI turns, turn-timer expiry) can't lose updates when
/// they read-modify-write the same game's state.
actor LobbyLock {
static let shared = LobbyLock()
private var tails: [String: (id: UUID, task: Task<Void, Never>)] = [:]
/// Runs `body` exclusively for the given lobby code, in FIFO order.
func run<T: Sendable>(_ lobbyCode: String,
_ body: @escaping @Sendable () async -> T) async -> T {
let previous = tails[lobbyCode]?.task
let id = UUID()
let task = Task { () -> T in
await previous?.value
return await body()
}
tails[lobbyCode] = (id, Task { _ = await task.value })
let result = await task.value
if tails[lobbyCode]?.id == id {
tails.removeValue(forKey: lobbyCode)
}
return result
}
}
Each lobby gets a chain of tasks, each awaiting the one before it. A play request, an AI turn, and a turn-timer expiry can all arrive in the same instant and they'll apply one at a time. It's about twenty lines, and it closed a whole category of corrupted-game bugs.
The design system port
Then the project changed character entirely. The app had shipped with a bolted-together theme system, and I wanted a real design system underneath every screen. On the evening of August 6th, between 19:37 and 19:52, a span of fifteen minutes, the log shows ten commits, each adding one design-system component:
9b7d483 2026-08-06 19:37 Add DS token layer: 6 themes x 2 modes, bundled fonts, parity check
e262215 2026-08-06 19:40 Add DSButton component
c4ab0fd 2026-08-06 19:41 Add DSIconButton and DSChip components
...
8ea31a2 2026-08-06 19:47 Merge branches 'ds-port/core-a', 'ds-port/core-b'
and 'ds-port/game-fdn' into design-system-port
That merge commit gives away the structure. The component work was split into three parallel git worktrees (core controls, list and layout pieces, and the game-table foundations), each branched from the frozen token commit with its own derived-data path, converging into one branch. The port itself ran as a gated pipeline: import the design reference, write an adoption plan, then a hard stop to read and approve that plan before any implementation started, then the parallel build-out, then a second review pass judging visual fidelity from screenshots. The kickoff doc in the repo is explicit about why. A plan you only read after the work is done can't be reviewed, only accepted.
After the components landed, adoption rolled through the app in waves: Settings, Onboarding, Home, Lobby, Matchmaking, and finally the game board. By the next morning, Wave 7 deleted the legacy theme system entirely. That's six themes with light and dark modes each, every screen on design tokens, overnight.
One habit from this stretch I'd actually evangelize is the lessons.md file. Every time something went sideways and cost me time, the correction got written down as a durable rule. Rules like "extract SwiftUI subviews as real structs, never computed properties, because a struct gives SwiftUI an identity boundary to diff against" and "one automation driver per simulator, always" each cost me an hour of misdiagnosis before they were written down. Neither ever cost me time again.
The 65-task audit loop
Next I ran a fresh audit over the whole codebase and turned the findings into a numbered backlog, 65 tasks with a dependency graph, then worked through it under a written set of guardrails: take the lowest unblocked task, confirm the defect still exists before editing (the audit was a point-in-time snapshot), write a failing test first, run the fast suites before committing, never push straight to main because it auto-deploys, and stop after three failed attempts on a task rather than thrash.
Tasks 4 and 5 are where that duplicate-counting hand guard in handlePlay came from. The audit found that a forged message could play one held card as a pair or a bomb, because the ownership check compared sets. The fix swapped it for multiplicity-counting containment, with regression tests that were confirmed failing before the change.
The most important guardrail: three of the tasks were design decisions rather than engineering tasks, and everything downstream of them was blocked until I'd written and ratified an actual decision document. That's how the repo ended up with CHOP-RULES.md (which bomb beats what, with a quads-trump toggle), MMR-POLICY.md (leavers take a forfeit loss; only ranked affects rating), and AUTH-PLAN.md (passkeys as the primary credential, silent device-ID accounts as the default, email magic links for recovery, and deliberately no Sign in with Apple). Separating "implement the policy" from "decide the policy" kept me from making rating-system decisions at 11pm inside a bug-fix commit.
You can watch the backlog get chewed through in the log: Task 43: six pairs now means six consecutive pairs, Task 39: a revoked token can no longer open a WebSocket, Task 15: move the JWT and deviceId into the Keychain. Dozens of commits over a few evenings, each traceable to a numbered finding.
Becoming a real service
The rest of August is the unglamorous stuff that separates an app from a service:
41d97b2 2026-08-09 17:06 Add game replay: capture, storage, and a replay screen
bee7e63 2026-08-10 17:39 Add the admin console SPA and deploy it to Cloudflare Pages
c908d18 2026-08-10 20:43 Add ranked seasons with a soft MMR reset
5c14183 2026-08-13 18:02 Add a ranked kill switch an operator can throw without a deploy
Ranked seasons with soft MMR resets landed the same night the whole thing moved to the tienlen.games domain. The moderation system followed: bans, reports, player blocks enforced in matchmaking, ban-evasion detection, and an audit trail. The admin console lets me watch live games, browse history, and validate replays server-side with ReplayValidator, so a reported game can be checked against the actual rules engine. On the operations side there are metrics with a denominator that survives a deploy, alarms that reach me when the tab is closed, and a kill switch that parks ranked play without shipping anything.
Notice the commit messages in this stretch. Make the alarms reach you when the tab is closed. Say plainly what Live games cannot see. Give failing requests a denominator that survives a deploy. The project that started with wtf now writes commit messages that state an operational guarantee in a sentence. The shift came from the code starting to matter, and from holding every commit to a simple bar: the message states what is now true.
One document from this period I'd recommend to anyone running game servers is SCALE.md, which records, in a table, every piece of process-local state that pins the server to exactly one replica. The lobby socket registry, the per-lobby game lock, turn timers, matchmaking queues, WebAuthn challenge storage, rate-limit counters. Running one replica is fine at my scale. Discovering those seven pins one production incident at a time after turning the replica count to two would not be. The day the game outgrows one instance, the migration is a checklist instead of an archaeology dig.
Today
The most recent commits, from this morning, look different again:
cda05d5 2026-08-15 07:29 Add visionOS spatial spike findings and go/no-go (APP-32)
4c43678 2026-08-15 09:10 Add APP-49 friends + online rematch technical design (CTO)
d5dbcfc 2026-08-15 09:17 Add PresenceTracker actor + heartbeat endpoint (APP-56)
The subjects carry ticket numbers now, design docs land before implementation, and a visionOS spike ships with an explicit go/no-go decision attached. The project has crossed into its fourth act: planned, tracked feature work (friends, rematches, presence, cosmetics) against a codebase that can absorb it because the August hardening happened first. Yesterday's commits made the app compile for macOS and visionOS and route the game board by window size class instead of hardware idiom, so that breadth is real.
What the log actually taught me
Reading your own git log --reverse from start to finish is a strange experience. A few things I'd tell past-me:
Prototype at prototype speed, and don't apologize for it. The wtf-era commits were the right commits for that era. The two-day sprint is why this project exists at all; a disciplined start would have died in planning.
A prototype that ships inherits production's obligations, on production's schedule. The cheating vector and the crash-loop boot were tolerable only while nobody real was on the other end, and the five-month gap let them age silently. The most dangerous phase in this whole history turned out to be the quiet part.
Share the rules engine. Putting TienLenCore between the client and server was the single highest-leverage architectural decision in the repo. Same for TienLenClient making the backend's tests exercise the app's real networking code.
Write everything down, in the repo. The decision documents, the lessons file, the audit backlog with its guardrails, the scale-pins table, and the postmortem each captured a decision or a mistake I only had to make once. The project got dramatically better when my intentions started living in files instead of in my head.
And the commit log is a mirror. If you want to know what a project actually is, read the log in order. The README will only tell you what it hopes to be.
Tiến Lên is on the App Store, and the ranked ladder could use more humans. Come take the round.