DEV Community

GameHzLab
GameHzLab

Posted on

What Building Four Browser Card Games Taught Me About State

I started with Spades. The rules engine worked, the computer players could finish a hand, and the score came out correctly. Then I watched an opponent play a card and saw its face change halfway across the table.

Hearts found a different failure: the last trick of a round appeared twice. Euchre let a player go alone, but the sitting-out partner still looked active. Bridge could reach a valid contract and then stall because the human player was supposed to control Dummy rather than the seat at the bottom of the screen.

None of those bugs came from misunderstanding the card rules. They came from the boundary between game state and what the player sees.

I now have four playable trick-taking games in the browser: Spades, Hearts, Euchre, and Contract Bridge. They share cards, a table, menus, themes, and animation ideas, but they do not share the same game loop. Building them changed how I think about state in small games.

You can see the current result at Trick Card Games. This is what I learned while getting there.

Share the table, not the rules

The first temptation was to make one large CardGame abstraction. Four players sit around a table, everyone follows suit, somebody wins a trick, and scores are updated. It looked like an obvious inheritance tree.

It was not.

Spades has a bidding phase and a trump suit that must be broken. Hearts passes three cards before most rounds and scores penalty cards rather than contracts. Euchre uses 24 cards, changes the effective suit of the left bower, and may remove one partner from a lone hand. Bridge has an auction, an opening lead, a visible Dummy hand, vulnerability, and a declarer who controls two seats.

The useful shared layer turned out to be much smaller:

  • card dimensions and face rendering
  • hand fans for the four seats
  • dealing and trick-collection animation
  • table themes and card backs
  • menu, full-screen, sound, and speed preferences
  • common layout rules for desktop and mobile

The rules engines stay separate. That duplication is cheaper than an abstraction full of flags such as hasPassing, hasDummy, canGoAlone, and trumpMustBeBroken.

The rule I use now is simple: share presentation when the player expects consistency; keep domain state separate when the games use different language.

The engine owns the truth

My UI used to answer rule questions because the answer felt easy. Can this card be played? Is the hand over? Whose turn is next? That worked until one screen and one engine answered the same question differently.

Now the UI sends an action and waits for a result. It does not calculate legality on its own.

type PlayAction = {
  seat: Seat;
  card: Card;
};

type PlayResult = {
  state: GameState;
  playedCard: Card;
  trickComplete: boolean;
  roundComplete: boolean;
};

function playCard(state: GameState, action: PlayAction): PlayResult {
  // Validate turn, ownership, and follow-suit rules here.
  // Return a new state plus the facts the UI needs to animate.
}
Enter fullscreen mode Exit fullscreen mode

This matters even in a single-player browser game. Buttons, keyboard input, computer turns, and animation callbacks can all try to advance the same hand. There should be one place that is allowed to say what happened.

It also made testing easier. The engine does not need the DOM, timers, CSS transitions, or a browser. A test can play an entire match as fast as the CPU allows.

An animation needs its own snapshot

The card-face bug in Spades was caused by reusing a DOM node while the underlying hand changed. The animation began with one card, the next render reused the element, and the face changed before the movement finished.

The fix was not a longer delay. The moving card needed an immutable snapshot.

const result = playCard(state, action);

const animationFrame = {
  card: { ...result.playedCard },
  from: getSeatPosition(action.seat),
  to: getTrickPosition(action.seat),
};

patchVisibleHand(result.state, action.seat);
await animateCard(animationFrame);

state = result.state;
renderTrick(state.currentTrick);
Enter fullscreen mode Exit fullscreen mode

The animation reads animationFrame.card, not "whatever card is currently at index 7." The hand can shrink without changing the object that is moving.

This also stopped me from rerendering every hand after every play. Opponent card backs should lose one card; they do not need to disappear and rebuild. A small DOM patch looks steadier and is much easier to follow on a phone.

A completed trick is a transaction

The Hearts round bug was more subtle. The fourth card was played, the engine marked the round complete, and two pieces of UI code both tried to finish the trick. One collected the cards. The other rendered the "final" trick again before opening the score summary.

I fixed that by treating trick completion as one transaction:

  1. lock player input
  2. place the fourth card
  3. leave all four cards visible long enough to read
  4. record the winner exactly once
  5. animate the cards into the winner's pile
  6. clear the trick surface
  7. either start the next trick or open the round summary

There is only one owner for that sequence. A collection guard prevents another render path from entering it, and a transition token makes old timers harmless after a new game starts.

if (isCollectingTrick) return;
isCollectingTrick = true;

try {
  await showCompletedTrick();
  await collectTrick();
  advanceAfterTrick();
} finally {
  isCollectingTrick = false;
}
Enter fullscreen mode Exit fullscreen mode

The important part is not the boolean. It is deciding that "fourth card played" and "ready for the next action" are different moments.

Turn ownership is not the same as seat ownership

Bridge made this impossible to ignore.

The human normally sits South. If South becomes declarer, the human also plays North's exposed Dummy cards. If North becomes declarer, South may be Dummy, and the computer declarer must choose cards from the human's visible hand.

A check like this is wrong:

const humanCanPlay = currentTurnSeat === "south";
Enter fullscreen mode Exit fullscreen mode

The real question is who controls the seat for this contract:

function isHumanControlledSeat(
  seat: Seat,
  contract: Contract | null,
): boolean {
  if (!contract) return seat === "south";

  if (seat === "south") {
    return contract.dummy !== "south";
  }

  return seat === contract.dummy && contract.declarer === "south";
}
Enter fullscreen mode Exit fullscreen mode

Euchre has the same distinction in a smaller form. When somebody goes alone, the partner still owns a seat at the table, but does not own a turn in that hand.

Once control became an explicit function, the UI prompts, legal-card highlighting, and computer loop all used the same answer.

Computer players should not receive hidden cards

A browser has all four hands in memory, so it is easy to write a computer player that accidentally cheats. Passing the whole GameState into chooseCard() is convenient, but it also gives the function information a real player would not have.

The safer interface accepts only what that seat is allowed to know:

chooseCard({
  hand: hands[currentSeat],
  currentTrick,
  completedTricks,
  contract,
  publicBids,
});
Enter fullscreen mode Exit fullscreen mode

The function cannot inspect an opponent's hand because it never receives one. This is not security in the usual sense; a determined player can inspect browser memory. It is protection against my own implementation mistakes and a way to keep the computer's decisions honest.

Full-game simulations catch more than unit examples

Rule-level tests are still valuable. I test that the left bower belongs to trump in Euchre, that Hearts has exactly 26 penalty points, and that Bridge scoring handles vulnerable and doubled contracts.

But the tests that have saved the most time run complete games.

They check invariants such as:

  • every deal contains the expected number of unique cards
  • no player breaks follow-suit rules
  • each trick removes one card from every active hand
  • the round reaches scoring rather than stalling
  • the dealer and vulnerability rotate correctly
  • a tied end condition continues when the rules require it
  • the next round starts with an empty trick surface

A simulation does not care whether the computer plays brilliant cards. It cares that hundreds of legal decisions eventually reach a valid result. That is exactly where turn-loop bugs tend to hide.

Static pages and interactive tables can coexist

The site uses Astro to generate the surrounding pages and TypeScript clients for the tables. The rules, scoring explanations, FAQs, and links arrive as HTML. The game code loads only where a table needs it.

That split has been useful for reasons beyond performance. A playable game needs fast input and local state. A rules page needs stable URLs, readable headings, internal links, and content that still exists before JavaScript starts.

Putting the table and its guide on the same page also gives players somewhere to check a rule without leaving the game entirely. The product and the documentation can share a URL without sharing an execution model.

What I would do differently

If I started again, I would establish three boundaries before drawing the first card:

  1. The engine is the only owner of legal state transitions.
  2. Animations consume snapshots and never infer card identity from a changing array index.
  3. Each async transition has one owner, one lock, and a clear cancellation path.

I would still build separate engines for the four games. Their differences are the interesting part. The common table should make them feel like one product, but it should not pretend their rules are the same.

Card games look small because the board fits on one screen. Their state is not small. Hidden information, strict turn order, delayed animation, partnerships, and round transitions create plenty of room for two individually correct systems to disagree.

Most of my hardest bugs lived in that disagreement. Making the boundary explicit did more for the games than adding another clever computer strategy ever could.

The four games are playable at Trick Card Games. I am still refining the engines and the table, but the state model is finally carrying more of the weight than the UI timers.

Top comments (0)