Card System

engine/cards.ts provides serializable cards and immutable helpers. Every card has a stable id so the UI can target and animate it regardless of array position.

interface Card {
  id: string;
  suit?: Suit; rank?: Rank; value?: number;
  faceUp?: boolean;
  owner?: PlayerId | null;
  [extra: string]: unknown;   // game-specific fields welcome
}

Building & shuffling

import { Cards } from './engine';

const deck = Cards.shuffle(Cards.standard52(), ctx.random); // deterministic

standard52() returns 52 face-down cards (AS, KH, …). shuffle returns a new array (Fisher–Yates via the seeded RNG) and leaves the input untouched.

Dealing

const { hands, rest } = Cards.deal(deck, ['p0', 'p1'], 3);
// hands = { p0: Card[3], p1: Card[3] }, rest = remaining draw pile

Dealing is round-robin (one card per player per pass).

Zones (piles, hands, discards)

Zones are just Card[] (top = last element). All operations are pure:

Helper Purpose
topOf(pile) Peek the top card.
drawN(pile, n) { drawn, rest } off the top.
removeCard(zone, id) { card, rest } by id.
addCard(zone, card, faceUp?) Append (optionally flipping).
flip(card, faceUp) New card with a new face state.
publicView(card) Hide the face when face-down (for opponent views).

Hidden information

State stores the real cards. Whether a player sees them is a rendering concern: GameSurface computes a viewerId (the current player in hot-seat, or “you” online) and passes it to the view, which reveals only that player’s hand and renders everyone else’s as backs (<PlayingCard forceBack />).

For fully secure hidden information online (so a peer can’t read your hand from the synced snapshot), add a server-side per-player state filter. The current local/offline and cross-tab modes share full state; this is documented as a known limitation in Firebase Multiplayer.

See Crown Rush (src/games/crown-rush.ts) for a complete worked example: draw/discard, reshuffle when the stock empties, and a three-of-a-kind win.