
The bugs, architecture calls, and hard-won lessons from building a multiplayer card game
Modularization, WebSockets, FCM edge cases, performance — every problem we hit and how we solved it, now documented.
Every article is a real problem I hit, how I diagnosed it, and how I fixed it — with code, architecture diagrams, and the decisions I got wrong first.

Adding a third game to our app required touching dozens of files across the codebase. That was the moment I knew something had to change.

The error was always the same — 'Provider<GameService> not found' — but it only happened when entering the module from a notification tap.

Most FCM tutorials show one case. Real apps need to handle all three — and getting any of them wrong breaks the user experience silently.

I noticed the freeze in QA but couldn't reproduce it consistently. Then I looked at which thread the generator was running on.

Presence seems simple — online, offline, in-game. The edge cases between those states are where multiplayer games break.

The game would silently disconnect on poor networks. Users thought the app crashed. It didn't — but I had no reconnection logic.

After shipping the Trek game module, I had zero insight into whether players were completing puzzles, where they dropped off, or what difficulty felt right.

Our App Store review came back with a Guideline 2.1 violation. I'd never written a line of ATT code. That was exactly the problem.

Active players see live statuses and get challenge requests. Offline players hear nothing. No cron job, no last_seen sweep — just Firebase TTL doing the work.

Weekly PQ delta gives casual users a spread of 2–3 points. Weekly XP gives 50 to 10,000+. Same users, but now there's real movement in the ladder.

Multiplayer games need an opponent who trusts the result. Solo games have no opponent — so the worst case is inflating your own score, clamped server-side.

When you force-quit the app, the WebSocket drops silently. The server detects the disconnect, writes an abandoned session with -50 XP, and cleans up.

Trimmed from bloated metadata blobs to 9 fields. Each game owns its own data. The session is just a receipt — and the receipt shouldn't contain the recipe.

Spin, get a prize. But the server validates, selects, and writes atomically — so you can't double-claim, skip ahead, or predict tomorrow's wheel.

Casual players never moved. The five-band system was too coarse. 52 tiers driven by weekly XP means every week is a fresh race — or a demotion.

Skill references too low, consistency maxing in 30 days, volume log giving 0.75 at 10K XP. Five constant changes later: 3-day users score 1026, veterans 1121.

Each gotcha took hours. set() flattening dotted keys into literal field names. serverTimestamp() throwing inside arrayUnion. FieldValue crashing because it's a transform sentinel, not a value.

End screen, spin wheel, rare drop celebration, next-game CTA — all driven by FunnelService with Remote Config toggles. The Trek BLoC doesn't know the wheel exists.