I Built Smoke or Fire: A React Native Card Game (That Apple Won't Let You Have)
How a party card game became a React Native + Firebase multiplayer app, the Firebase array normalization problem that almost broke multiplayer, and why the App Store kept saying no.
It started at a party
Someone pulled out a deck of cards and announced we were playing Smoke or Fire. You probably know it. You guess whether the next card is red or black (smoke or fire), then higher or lower, then inside or outside your first two cards, then the suit. Get it right and someone drinks. Get it wrong and you drink. There’s a pyramid at the end where everyone’s cards come back to haunt them. Simple, chaotic, and genuinely fun.
The problem: you always need a physical deck of cards. Someone has to shuffle, someone has to deal, someone has to remember whose turn it is. Half the time the game falls apart before the pyramid because nobody can keep track.
I had been wanting to learn React Native for a while. Not just read about it, actually build something real with it. Smoke or Fire seemed perfect. It’s a game I knew well enough that I wasn’t figuring out the rules and the code at the same time, and the UI is essentially just cards and buttons. It felt scoped.
So I built it. The full source is on GitHub.
What the game does
The app has two phases.
The first is four sequential rounds. Every player takes a turn guessing before their card is flipped. Round one: red or black. Round two: higher or lower than your previous card. Round three: inside or outside your first two cards. Round four: name the suit. Correct means you give out drinks. Wrong means you take them.
After all four rounds, the pyramid. Nine cards in a diamond pattern, revealed one at a time. Each revealed card matches against every card every player drew across rounds one through four. Match a card in row one and you give a drink. Row two and you take one. The drink amounts scale toward the center. By the last card, things get chaotic.
Two ways to play: pass-and-play for couch sessions on a single phone, or real-time multiplayer where everyone joins with a four-letter room code. The room codes exclude the letters I and O so nobody types a zero when they mean an O:
function generateRoomCode(): string {
const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ"; // Exclude I and O to avoid confusion
let code = "";
for (let i = 0; i < 4; i++) {
code += letters.charAt(Math.floor(Math.random() * letters.length));
}
return code;
}
Small thing. Noticed it during testing when someone kept entering the wrong code.
The stack
React Native 0.81 + Expo SDK 54 because cross-platform was the goal from day one. iOS, Android, and web from one codebase, with Expo handling the native configuration that used to eat entire weekends.
TypeScript with strict mode throughout. For a multiplayer game with this much state, the type safety paid for itself within the first few days.
Firebase Realtime Database for multiplayer sync. More on why this caused problems in a minute.
react-native-reanimated v4 for the card flip animation. The 3D perspective flip on every card reveal is the single most satisfying part of the app. Reanimated’s worklet system runs on the native thread, which means the animation stays smooth even when Firebase is doing something slow in the background. The full hook is about 40 lines:
export function useCardAnimation() {
const rotation = useSharedValue(0);
const isFlipped = useSharedValue(false);
const flip = useCallback(() => {
rotation.value = withTiming(180, {
duration: 600,
easing: Easing.inOut(Easing.ease),
});
isFlipped.value = true;
}, [rotation, isFlipped]);
const frontAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ perspective: 1000 }, { rotateY: `${rotation.value + 180}deg` }],
backfaceVisibility: "hidden",
position: "absolute",
}));
const backAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ perspective: 1000 }, { rotateY: `${rotation.value}deg` }],
backfaceVisibility: "hidden",
position: "absolute",
}));
return { flip, reset, frontAnimatedStyle, backAnimatedStyle };
}
For state management: Context and useReducer, no Redux or Zustand. The game logic lives in a single gameReducer function with typed actions. Making that reducer pure and stateless was the right call. The same function drives both local games and Firebase-synced multiplayer, which meant I only had to get the game logic right once.
Google Mobile Ads for monetization. EAS (Expo Application Services) for building and distributing.
The hard part: Firebase multiplayer sync
Multiplayer was harder than I expected.
The architecture works like this: one player is the host. When the host takes an action, the reducer runs locally and the result gets written to Firebase. When a non-host player takes an action, they write to a pendingAction path instead. The host sees that, runs the reducer, and writes the result back. Everyone subscribes to the same game state and re-renders when it changes.
Simple in theory. Firebase had other ideas.
Firebase Realtime Database will silently mangle your data. Empty arrays become undefined. Arrays with items come back as objects with numeric keys ({ 0: card, 1: card }). Fields that were never set come back as null. After the first multiplayer bug, where player cards were disappearing between rounds, I added a normalizeGameState() function that runs on every single read from Firebase:
function normalizeGameState(state: GameState | null): GameState | null {
if (!state) return null;
const toArray = <T>(val: T[] | Record<string, T> | null | undefined): T[] => {
if (!val) return [];
if (Array.isArray(val)) return val;
// Firebase turned your array into { 0: item, 1: item }
return Object.values(val);
};
// Ensure we have one playerCards array per player, even if Firebase
// deleted empty arrays for players who haven't drawn yet
const players = toArray(state.players);
const rawPlayerCards = toArray(state.playerCards);
const normalizedPlayerCards = players.map((_, index) =>
toArray(rawPlayerCards[index])
);
return {
...state,
currentCard: state.currentCard ?? null,
currentGuess: state.currentGuess ?? null,
players: toArray(state.players),
deck: toArray(state.deck),
playerCards: normalizedPlayerCards,
pyramidCards: toArray(state.pyramidCards),
pyramidRevealed: toArray(state.pyramidRevealed),
pendingDrinkAssignments: toArray(state.pendingDrinkAssignments),
pyramidPendingAssigners: toArray(state.pyramidPendingAssigners),
};
}
This fixed the disappearing cards. It also introduced a related pattern in the reducer itself: using loose equality (!=) to guard against both null and undefined from Firebase, since you can’t always control which one you get:
case "MAKE_GUESS": {
// Use != (loose) to catch both null AND undefined from Firebase
if (state.phase !== "playing" || state.currentCard != null) {
return state;
}
// ... process the guess
}
The other piece that took real time was the pending action pattern. The host needs to process actions from non-host players in order and exactly once. I ended up timestamping each pending action and tracking the last processed timestamp in a ref to prevent double-processing. Overkill for a card game, but multiplayer drink assignments cannot afford to double-count.
The App Store saga
Submitted. Rejected. Guideline 1.4.3: apps that encourage or enable excessive consumption of alcohol.
Fair. I updated the metadata. Removed the word “drinking.” Resubmitted.
Rejected again.
Reframed the entire listing as a “party card game.” Positioned the drink mechanic as optional social scoring. Resubmitted.
Rejected again.
Removed all references to alcohol from screenshots and description. The listing described it as a card guessing game where correct answers mean you give points to other players. Nothing about drinking anywhere.
Rejected again.
Apple’s position seems to be: if the game mechanic is drinking, it qualifies under 1.4.3 regardless of how the metadata describes it. The game is the game.
At some point I had to decide whether to keep fighting over a free app. I didn’t. I moved on.
Where it lives now
Expo Web support meant the app already ran in a browser. The card flip animations work in Chrome. Multiplayer works. No install required. You text someone the link and you’re playing.
It’s slightly bittersweet that it never made it into the App Store, but the web version is genuinely fine. More than fine, actually. Anyone with a browser can play it, which is more people than would ever find it in the App Store anyway.
Source code is on GitHub if you want to dig into the multiplayer architecture or use the Firebase normalization pattern.
What I’d do differently
Start with web as the primary target if there’s any chance the App Store might reject your app. I built a full React Native app and discovered the main distribution channel was closed to me. The web fallback worked, but it was a fallback, not a plan.
Use Firestore instead of Realtime Database. Firebase Realtime Database works, but the normalization overhead was significant. Firestore returns data in a format that doesn’t mangle your arrays, and the query model is better for structured game state.
Write tests for the reducer. The game state logic got complex, especially the pyramid drink assignment across multiple players in real time. I tested it manually as I built it, which worked until it didn’t. A test suite for gameReducer would have caught several multiplayer bugs before they reached production.
The game is out there. People play it. That part worked.
Share this post
Built as part of
View the project →