Why a Card Game in React Native
Italian Bridge is a trick-taking card game I grew up playing with family. It's played with a standard 52-card deck, involves bidding, hidden trump suits, and partnership play. I wanted to bring it to mobile — Android, iOS, and web — from a single codebase.
React Native with Expo was the obvious choice for cross-platform delivery. But the interesting challenge wasn't the UI — it was the state management. A card game has complex state: the deck, each player's hand, the bidding phase, the trump suit (hidden from some players), the trick pile, the score. And it needs to be deterministic — given the same inputs, the game engine must produce the same outputs.
Game Engine as Pure Functions
The first decision I made was to separate the game logic from the UI completely. The game engine is a set of pure TypeScript functions that take a game state and return a new state. No React, no Expo, no UI dependencies.
type GameState = {
phase: 'dealing' | 'bidding' | 'playing' | 'scoring';
deck: Card[];
hands: [Card[], Card[], Card[], Card[]];
currentPlayer: number;
trumpSuit: Suit | null;
trickPile: Card[];
scores: [number, number];
};
function dealCards(state: GameState): GameState {
const deck = shuffleDeck([...state.deck]);
const hands: [Card[], Card[], Card[], Card[]] = [[], [], [], []];
for (let i = 0; i < 52; i++) {
hands[i % 4].push(deck[i]);
}
return {
...state,
phase: 'bidding',
deck: [],
hands: hands.map(sortHand) as [Card[], Card[], Card[], Card[]],
currentPlayer: Math.floor(Math.random() * 4),
};
}
function playCard(state: GameState, card: Card): GameState {
if (state.phase !== 'playing') throw new Error('Not in playing phase');
if (state.currentPlayer !== state.humanPlayer) throw new Error('Not your turn');
const newHands = state.hands.map((hand, i) =>
i === state.currentPlayer
? hand.filter(c => c.id !== card.id)
: hand
) as [Card[], Card[], Card[], Card[]];
const newTrickPile = [...state.trickPile, card];
// If 4 cards played, resolve the trick
if (newTrickPile.length === 4) {
const winner = resolveTrick(newTrickPile, state.trumpSuit, state.leadSuit);
return {
...state,
hands: newHands,
trickPile: [],
currentPlayer: winner,
trickWinner: winner,
};
}
return {
...state,
hands: newHands,
trickPile: newTrickPile,
currentPlayer: (state.currentPlayer + 1) % 4,
};
}
The key insight: the game state is immutable. Every function returns a new state object, never mutating the existing one. This makes testing trivial — you can call dealCards(initialState) and assert the output has 13 cards in each hand. No mocking, no setup, no teardown.
Why Zustand Over Redux
I evaluated three state management options: React Context + useReducer, Redux Toolkit, and Zustand.
Context + useReducer was the first attempt. It worked for the basic game flow, but performance was terrible. Every state change re-rendered all consumers. When a card is played, the entire game tree re-renders — all 4 hands, the trick pile, the score, the UI chrome. On a mid-range Android phone, this caused visible jank.
Redux Toolkit was the next try. It solved the re-render problem with selectors, but the boilerplate was overwhelming. A game with 6 phases, 15+ actions, and complex state transitions meant 300+ lines of Redux code before any game logic. For a card game, that's absurd.
Zustand was the answer. It gives you Redux-like capabilities (centralized store, selectors, middleware) with a fraction of the API surface. The entire game store is about 80 lines:
import { create } from 'zustand';
interface GameStore {
state: GameState;
humanPlayer: number;
// Actions
deal: () => void;
bid: (amount: number, suit: Suit) => void;
playCard: (card: Card) => void;
aiTurn: () => void;
// Derived
getVisibleState: (playerIndex: number) => VisibleGameState;
}
const useGameStore = create<GameStore>((set, get) => ({
state: initialGameState,
humanPlayer: 0,
deal: () => set((store) => ({
state: dealCards(store.state),
})),
playCard: (card) => set((store) => ({
state: playCard(store.state, card),
})),
aiTurn: () => {
const { state, humanPlayer } = get();
if (state.currentPlayer === humanPlayer) return;
const aiPlayer = createAI(state.currentPlayer, state);
const action = aiPlayer.decide(state);
set({ state: executeAction(state, action) });
},
getVisibleState: (playerIndex) => createVisibleState(get().state, playerIndex),
}));
The slice pattern keeps things organized. Each game phase (dealing, bidding, playing, scoring) has its own slice of the store, and components subscribe only to the slice they need:
// This component only re-renders when the hand changes
const MyHand = () => {
const hand = useGameStore(state => state.state.hands[state.humanPlayer]);
return <Hand cards={hand} />;
};
// This component only re-renders when the score changes
const ScoreBoard = () => {
const scores = useGameStore(state => state.state.scores);
return <Scoreboard scores={scores} />;
};
Visible vs Hidden State
This was the trickiest design problem. In Italian Bridge, players can see their own hand and the cards played in tricks. But they can't see other players' hands, and the trump suit is hidden during the bidding phase.
If you store the full game state in Zustand and pass it to all components, any component can accidentally (or intentionally) access hidden information. The UI could leak the trump suit before it's revealed.
The solution: the store holds the full state, but components only ever receive a visible state slice:
type VisibleGameState = {
phase: GameState['phase'];
myHand: Card[];
trickPile: Card[];
myIndex: number;
currentPlayer: number;
scores: [number, number];
trumpSuit: Suit | null; // null during bidding, revealed after
};
function createVisibleState(
state: GameState,
playerIndex: number
): VisibleGameState {
return {
phase: state.phase,
myHand: state.hands[playerIndex],
trickPile: state.trickPile,
myIndex: playerIndex,
currentPlayer: state.currentPlayer,
scores: state.scores,
// Trump is only visible after bidding phase
trumpSuit: state.phase === 'playing' ? state.trumpSuit : null,
};
}
Every component that renders game state calls getVisibleState(playerIndex) and only sees what that player is allowed to see. The AI players use the full state (they need it to make decisions), but the human player's UI is strictly filtered.
This pattern — full state in the store, visible state at the component boundary — is the game dev equivalent of "never trust the client." The game engine is the source of truth, and the UI is a read-only view of a filtered projection.
Animating Cards with Reanimated
Card games live or die by their animations. A card that just teleports from hand to table feels like a spreadsheet. A card that smoothly flies, flips, and lands feels like a game.
Reanimated 3 handles the heavy lifting. The key animation is the "card play" — a card moves from the player's hand to the center of the table:
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
} from 'react-native-reanimated';
function AnimatedCard({ card, isPlayed }: { card: Card; isPlayed: boolean }) {
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const rotation = useSharedValue(0);
useEffect(() => {
if (isPlayed) {
translateX.value = withSpring(tableCenterX, { damping: 15 });
translateY.value = withSpring(tableCenterY, { damping: 15 });
rotation.value = withTiming(360, { duration: 400 });
}
}, [isPlayed]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value },
{ rotate: `${rotation.value}deg` },
],
}));
return (
<Animated.View style={[styles.card, animatedStyle]}>
<CardFace card={card} />
</Animated.View>
);
}
The spring animation (damping: 15) gives the card a natural bounce when it lands. The rotation adds a satisfying flip. And because Reanimated runs animations on the UI thread, they stay smooth even when the JS thread is busy calculating AI moves.
For the dealing animation, I used staggered delays:
const dealAnimation = (cardIndex: number) => ({
initial: { opacity: 0, y: -200, rotateZ: '180deg' },
animate: {
opacity: 1,
y: 0,
rotateZ: '0deg',
transition: {
delay: cardIndex * 100, // 100ms between each card
type: 'spring',
damping: 12,
},
},
});
Each card flies in from above with a slight delay, creating a cascading deal effect. The spring physics make each card settle naturally instead of stopping abruptly.
Testing the Game Engine
Because the game engine is pure functions, testing is straightforward:
describe('playCard', () => {
it('removes the card from the player\'s hand', () => {
const state = dealCards(initialState);
const card = state.hands[0][0];
const newState = playCard(state, card);
expect(newState.hands[0]).not.toContainEqual(card);
});
it('resolves the trick after 4 cards', () => {
let state = dealCards(initialState);
// Play 3 cards
for (let i = 0; i < 3; i++) {
state = playCard(state, state.hands[state.currentPlayer][0]);
}
// Play the 4th card
const fourthCard = state.hands[state.currentPlayer][0];
const finalState = playCard(state, fourthCard);
expect(finalState.trickPile).toHaveLength(0);
expect(finalState.trickWinner).toBeDefined();
});
it('throws if playing out of turn', () => {
const state = dealCards(initialState);
const wrongPlayer = (state.humanPlayer + 1) % 4;
const card = state.hands[wrongPlayer][0];
expect(() => playCard(state, card)).toThrow('Not your turn');
});
});
No mocking, no rendering, no async. Just call a function, check the output. This caught three bugs in the trick resolution logic that would have been painful to find through UI testing.
What I Learned
The biggest lesson: state management for games is fundamentally different from state management for CRUD apps. In a web app, state reflects what's in a database. In a game, state reflects what's happening right now — and it changes 60 times per second during animations.
Zustand was the right choice because it doesn't force you into a pattern. Redux assumes your state is a flat object with normalized entities. Context assumes your state changes infrequently. Zustand assumes nothing — it's a pub/sub system with a hook API. For a game with complex, rapidly-changing state, that flexibility matters.
The visible/hidden state split was the most important architectural decision. It prevented an entire class of bugs (information leaks) at the component boundary, not in every individual component. When the architecture prevents bugs, you don't need tests to catch them.
