Real-time multiplayer on Firestore, ~3 months in — room state model, listener fan-out, and four security patterns I now apply
Hey r/Firebase — solo dev. Shipped a real-time multiplayer party word game in late February, been running on Firebase for ~3 months. ~34 DAU right now, which is small but real: real spend, real player money flowing through Cloud Functions, real rules to get wrong. Wanted to write up what I learned because most multiplayer-on-Firestore content is either toy-scale tutorials or "we hit 10k DAU, here's how we sharded everything." This is the middle.
The stack
- Expo (React Native) clients
- Firestore for room + game state
- RTDB for presence (
onDisconnectis the killer feature) - Cloud Functions for ALL state mutations (
joinRoom,submitVote,submitAnswer,advancePhasevia Cloud Tasks) - A separate humanbot Node.js service that spawns AI players to fill empty seats (the long tail of "real-time multiplayer" at 34 DAU is that most lobbies need bot fill)
- Anthropic + OpenAI for content generation
Decisions that aged well
- One room doc, denormalized. Each
rooms/{roomCode}doc holds the entire room state — players, current round, current acronym, submissions, vote tallies. Clients listen to one doc. Simple, fast, no fan-out fanfare. - Cloud Tasks for phase transitions. When the submit phase ends, a Cloud Task fires
advancePhaseexactly once. Killed every "who advances the phase first?" race condition you get with client-driven transitions. - Server-only writes for currency. No client writes to
gems. All charges go through Cloud Functions.
Things that broke
- Listener fan-out cost. Even at small scale, 8 players × 1 room listener × N reads per round adds up faster than you'd think. Cut by being aggressive about what triggers re-renders and which fields actually need to be in the room doc.
- Bot orchestration as a long-running service. Works fine until you need to restart it during active games — drops every bot mid-round. Now I poll for empty rooms before any restart.
- No rate-limits collection on day one. Players could spam reactions. Added a
rate_limitscollection with TTL to track per-uid action counts. Should have been there from the start.
Four security patterns I now apply to every new feature
Working through a security pass on this project — sharing because most multiplayer-on-Firestore tutorials skip these and they're the ones that bite:
- Field-allowlist any user-owned doc. If
users/{uid}has any field that touches money (gems,isSubscriber, anything purchasable) or stats that drive matchmaking, block client writes to those fields with a rule allowlist. The Cloud Function is the intended path; without the rule, it's not the only one. - Rate-limit every callable that mutates state. Even harmless-looking ones. I keep a tiny
rate_limitscollection with per-uid + per-key counters and a TTL. Helper at the top of every callable that costs anything (DB write, paid API call, currency change). - Cloud-Tasks-only callables need OIDC verification. If a function is supposed to be invoked only by Cloud Tasks (like a phase advancer), verify the Bearer token against Google's JWKS and check
emailmatches the service account + theaudclaim matches the function URL.onCallalone isn't enough — any signed-in user can hit it. - Server-side receipt validation for IAPs. RevenueCat's webhook + their REST API can verify on your backend. Don't trust the client SDK's "purchase successful" callback as the source of truth for entitlements.
The rule pattern for #1, since it's the most overlooked:
match /users/{uid} {
allow read: if request.auth.uid == uid;
// Block client writes to monetization/stats fields.
// Those move server-side via Cloud Functions only.
allow write: if request.auth.uid == uid
&& !request.resource.data.diff(resource.data).affectedKeys()
.hasAny([
'gems', 'isSubscriber', 'manualAdFree',
'monthlyAiCalls', 'unlockedReactions', 'stats'
]);
}
The actual numbers
- ~34 DAU
- Firebase costs: under $10/mo (still mostly free tier)
- Expensive parts: AI API calls (~$126/mo recurring), not Firebase
- For solo-dev-scale games, Firebase is the cheapest infra you can buy. The "Firestore is too expensive" complaints usually come from apps that 100x'd overnight without re-architecting.
What I'd redesign
- Move chat history out of the room doc. The doc gets fat when chat is active and every player re-fetches it on every listener tick. Separate subcollection, paginate.
- Bot orchestration as Cloud Run jobs triggered by room creation, not a long-running VPS that hates restarts.
- Consider RTDB for the volatile per-round state (timer, current acronym, submissions-in-progress). Cheaper per write at small payloads, faster for ephemeral data. - Although I've found out that for realtime performance a VPS performs better in some cases.
Happy to answer questions about any of this — the room state model, the Cloud Tasks pattern for advancePhase, the rate-limit helper, the humanbot orchestration, whatever. The app's at acrophobia.app if you want to poke around, but that's not the point of the post.