u/Savings_Speaker6257

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 (onDisconnect is the killer feature)
  • Cloud Functions for ALL state mutations (joinRoom, submitVote, submitAnswer, advancePhase via 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 advancePhase exactly 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_limits collection 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:

  1. 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.
  2. Rate-limit every callable that mutates state. Even harmless-looking ones. I keep a tiny rate_limits collection 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).
  3. 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 email matches the service account + the aud claim matches the function URL. onCall alone isn't enough — any signed-in user can hit it.
  4. 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.

reddit.com
u/Savings_Speaker6257 — 3 days ago

Last spring I started rebuilding a 90s AOL game (Acrophobia) as a real-time multiplayer mobile app. A year of shipping later, here are the React Native / Expo decisions that paid off and the ones I'd undo.

Stack: Expo (managed workflow, bare for some native bits), Firestore + Cloud Functions, Anonymous Auth, a Node.js "humanbot" server for AI opponents, EAS Build/Submit/Update for delivery.

What worked

  1. Anonymous Auth as the default identity. Friction kills word games. Letting players in with zero signup and only prompting for a display name on first room-join roughly doubled D1 retention vs the version that asked for an email.

  2. Firestore listeners for game state, Cloud Functions for transitions. Each room is one document with a status field (lobby, writing, voting, results). Clients listen and re-render off snapshots. Cloud Functions are the only writers for phase transitions, scoring, and the round timer. Trying to do phase transitions on the client first was a disaster — clock skew, double-advance bugs, players seeing different rounds.

  3. EAS Update for OTA fixes. Shipped probably 30+ JS-only fixes without binary submissions. Saved my sanity during the launch month.

  4. Reanimated 3 + a tiny useGameClock hook driven by a server-authoritative endsAt timestamp instead of a local countdown. Late joiners and reconnects just compute remaining time from the server value — no drift.

What I'd do differently

  1. I'd plan the runtimeVersion / native build dance from day one. EAS doesn't auto-regenerate the native runtime version files (Expo.plist, strings.xml) when you bump app version. I once shipped an OTA at a new runtimeVersion the binary didn't accept, silently bricking the update for everyone on the prior store build. Now it's in a checklist.

  2. I'd put localization in the data model, not in JS bundles. I shipped 44 languages worth of UI strings by hand-fanning new keys across batch files. Should have keyed off Firestore so I could ship copy fixes without an OTA.

  3. I'd skip Realtime Database entirely. I tried it for presence early on, hated the dual-database mental overhead, ripped it out, used a Firestore presence doc with a TTL field. Simpler.

  4. I'd write the AI bot server in TypeScript from the start, not migrate later. The latency budget for "bot writes a phrase that sounds like a human in 12 seconds, in the player's language" is tight enough that the type errors I caught during the migration were already production bugs.

  5. I'd benchmark Firestore costs against player count weekly. With listeners on every player's client, an active room of 8 with bot fill can do ~200 reads in a 90-second round. Not free at scale.

Happy to dig into any of these — the AI-bot orchestration in 44 languages and the server-authoritative timer are probably the most interesting bits if anyone wants the deeper version.Last spring I started rebuilding a 90s AOL game (Acrophobia) as a real-time multiplayer mobile app. A year of shipping later, here are the React Native / Expo decisions that paid off and the ones I'd undo.

Stack: Expo (managed workflow, bare for some native bits), Firestore + Cloud Functions, Anonymous Auth, a Node.js "humanbot" server for AI opponents, EAS Build/Submit/Update for delivery.

What worked

  1. Anonymous Auth as the default identity. Friction kills word games. Letting players in with zero signup and only prompting for a display name on first room-join roughly doubled D1 retention vs the version that asked for an email.

  2. Firestore listeners for game state, Cloud Functions for transitions. Each room is one document with a status field (lobby, writing, voting, results). Clients listen and re-render off snapshots. Cloud Functions are the only writers for phase transitions, scoring, and the round timer. Trying to do phase transitions on the client first was a disaster — clock skew, double-advance bugs, players seeing different rounds.

  3. EAS Update for OTA fixes. Shipped probably 30+ JS-only fixes without binary submissions. Saved my sanity during the launch month.

  4. Reanimated 3 + a tiny useGameClock hook driven by a server-authoritative endsAt timestamp instead of a local countdown. Late joiners and reconnects just compute remaining time from the server value — no drift.

What I'd do differently

  1. I'd plan the runtimeVersion / native build dance from day one. EAS doesn't auto-regenerate the native runtime version files (Expo.plist, strings.xml) when you bump app version. I once shipped an OTA at a new runtimeVersion the binary didn't accept, silently bricking the update for everyone on the prior store build. Now it's in a checklist.

  2. I'd put localization in the data model, not in JS bundles. I shipped 44 languages worth of UI strings by hand-fanning new keys across batch files. Should have keyed off Firestore so I could ship copy fixes without an OTA.

  3. I'd skip Realtime Database entirely. I tried it for presence early on, hated the dual-database mental overhead, ripped it out, used a Firestore presence doc with a TTL field. Simpler.

  4. I'd write the AI bot server in TypeScript from the start, not migrate later. The latency budget for "bot writes a phrase that sounds like a human in 12 seconds, in the player's language" is tight enough that the type errors I caught during the migration were already production bugs.

  5. I'd benchmark Firestore costs against player count weekly. With listeners on every player's client, an active room of 8 with bot fill can do ~200 reads in a 90-second round. Not free at scale.

Happy to dig into any of these — the AI-bot orchestration in 44 languages and the server-authoritative timer are probably the most interesting bits if anyone wants the deeper version.

reddit.com
u/Savings_Speaker6257 — 16 days ago
▲ 2 r/playmygame+2 crossposts

Solo dev, ~1 year in. Acrophobia is a real-time multiplayer word game — random letters appear (e.g. J.C.F.), every player races to write the funniest phrase that matches ("Jumping Crotch First"), then everyone votes. Originally a 90s AOL game; I rebuilt it as mobile multiplayer with friends, randoms, or AI bots when the lobby's empty.

Free, no ads in gameplay, no pay-to-win.

iOS: https://apps.apple.com/us/app/acrophobia-the-acronym-game/id6760745131 Android: https://play.google.com/store/apps/details?id=com.jscriptz.acrophobia

Posting the technical breakdown (Expo + Firebase architecture, the bot server, 44-locale gotchas) as the first comment so this stays scannable. Happy to swap notes with anyone shipping multiplayer solo.

u/Savings_Speaker6257 — 10 days ago

Hey everyone! I'm a solo dev and I built Acrophobia, a real-time multiplayer word game for iOS and Android.

How it works: Everyone sees random letters (like G.F.I.E.) and races to write the funniest phrase that matches (e.g., Got Fired In Elevator"). Then you vote on the best one. It's basically improv comedy with acronyms.

What makes it different:

- Play with friends or jump into public rooms with AI bots that have actual personalities and voice lines

- 20 AI bot characters that judge rounds, react to wins/losses, and each have unique voting styles you can learn to exploit

- Rotating Judge mode where one player picks the winner each round

- 44 languages supported with real-time translation

- Categories like "Florida Man Headlines," "Rejected Superhero Powers," and "Things You'd Hear at a Party"

- Daily challenges, leaderboards, and a community page to browse the best answers

Links:

- https://apps.apple.com/us/app/acrophobia-the-acronym-game/id6760745131

What's new this week across v1.3.8 + v1.3.9

🎙 Bot Personalities (v1.3.8)

- 300 unique voice lines — 5 intros + 5 win reactions + 5 loss reactions per bot

- Quirky judge intros with catchphrases and personality quirks

- Bots celebrate your wins and groan at your losses

- Smoother judge-intro and bot-profile video playback

📡 Bluetooth LAN Multiplayer

- Play face-to-face with friends with zero internet, zero WiFi — just Bluetooth

- Auto-discovery, no room codes needed

- Cross-platform: iOS ↔ Android in the same room

👑 Host Controls (v1.3.9)

- Kick bots (or humans) mid-game in both online and LAN

- Pause/resume LAN games (host only)

- Force-close on a player's phone? Host's roster updates instantly

🔑 Personal Room Code

- Every player gets a permanent room code that's reused on every room you create

- Find it in your Profile, share once, friends keep using it forever

🤖 Smarter Bots

- Category-themed phrases when category mode is on

- More human-feeling voting (30% human bias)

📨 Cleaner Inbox

- DMs show newest first

- Unread badge ignores your own sent messages

- Chat keyboard fixed on Android — input no longer hides behind the keyboard

🎮 Offline Mode Polish

- Same setup screen as LAN (Game Preferences + Bot Preferences)

- KLIPY GIF picker now offline-aware (pulls from local cache)

- Play Offline button surfaces automatically when you have no connection

u/Savings_Speaker6257 — 21 days ago