DEV Community

GameHzLab
GameHzLab

Posted on

I Stopped Testing Card Games One Move at a Time

Suggested DEV description: A legal move is not proof that a game can finish. Here is how full-match simulations caught freezes, duplicate cards, broken round transitions, and rule bugs across five browser card games.

Suggested DEV tags: testing, typescript, gamedev, webdev


One of my card games once passed every rule test I had and still froze halfway through a match.

The deck contained the right cards. Players followed suit. Trick winners were calculated correctly. Scoring examples matched the rulebook. None of that helped when the computer reached a state where it was waiting for a player who had already been removed from the hand.

That bug changed the way I test turn-based games.

I still write small tests for individual rules, but I no longer treat them as evidence that a game works. A playable game has to move through every phase, survive every hand boundary, and eventually stop for the right reason.

Today I run complete simulated matches for five browser games: Spades, Hearts, Euchre, Contract Bridge, and Oh Hell. The computer players do not need to be clever in these tests. They only need to make legal decisions quickly enough to expose problems in the game loop.

This is the testing approach that has caught the bugs my example-based tests missed.

A legal move proves very little

Consider a follow-suit test:

it("requires a player to follow the led suit", () => {
  const state = makeState({
    lead: card("10H"),
    hand: [card("2H"), card("AS")],
  });

  expect(() => playCard(state, "AS")).toThrow();
  expect(playCard(state, "2H")).toBeDefined();
});
Enter fullscreen mode Exit fullscreen mode

This is useful. It proves one rule under one arrangement of cards.

It does not prove that:

  • the next player gets a turn;
  • the fourth card closes the trick;
  • the correct player leads next;
  • all cards remain accounted for;
  • the final trick reaches scoring;
  • the next round starts with an empty table;
  • the match eventually ends.

Most of the painful bugs in my games lived in that list. Each transition looked reasonable on its own. The failure only appeared after several correct transitions were combined.

Use the computer player as a test driver

Once a rules engine accepts actions without depending on the DOM, a computer player becomes a useful test driver.

Here is the basic shape of the Oh Hell simulation in my test suite:

let state = createGame();
let moves = 0;

while (state.phase !== "gameSummary" && moves++ < 2500) {
  if (state.phase === "dealing") {
    state = beginBidding(state);
  } else if (state.phase === "bidding") {
    state = submitBid(state, state.turn, chooseBid(state, state.turn));
  } else if (state.phase === "playing") {
    const card = chooseCard(state, state.turn);
    state = playCard(state, state.turn, card.id);
  } else if (state.phase === "collectingTrick") {
    state = collectTrick(state);
  } else if (state.phase === "roundSummary") {
    state = startNextRound(state);
  }
}

assert.equal(state.phase, "gameSummary");
assert.ok(moves < 2500);
Enter fullscreen mode Exit fullscreen mode

There is nothing sophisticated about the loop. That is the point. It keeps asking the engine, "What phase are you in, and what legal action moves you forward?"

If the state machine enters an unhandled phase, the test stops making progress. If turn ownership is wrong, the next action is rejected. If a round transition forgets to reset a field, a later invariant fails.

The computer player is not being tested for skill here. It is supplying valid traffic to the engine.

Always give the simulation a move budget

A test that waits forever is worse than a test that fails.

Every simulation has an upper bound:

while (!state.gameOver && moves++ < MAX_MOVES) {
  state = takeNextLegalAction(state);
}

assert.equal(state.gameOver, true);
assert.ok(moves < MAX_MOVES, "match exceeded its move budget");
Enter fullscreen mode Exit fullscreen mode

The number should be comfortably above a valid game's worst case, not a timeout measured in milliseconds. A move budget makes the failure deterministic and tells me something useful: the state machine did not converge.

This caught an Euchre problem in lone hands. The maker's partner was meant to sit out, but one turn-advance path still landed on that seat. The UI looked like it was thinking. The simulation simply consumed its move budget and failed.

The fix was to make "next active seat" a rule shared by every turn transition, rather than skipping the partner in only the visible play loop.

Check conservation laws after every action

Card games come with unusually good invariants. Cards should not appear, disappear, or exist in two places at once.

For Hearts, I collect cards from every live location after each play:

const cards = [
  ...Object.values(state.hands).flat(),
  ...state.currentTrick.plays.map((play) => play.card),
  ...state.completedTricks.flatMap((trick) =>
    trick.plays.map((play) => play.card)
  ),
];

assert.equal(cards.length, 52);
assert.equal(new Set(cards.map((card) => card.id)).size, 52);
Enter fullscreen mode Exit fullscreen mode

Checking only at the end of a round is too late. If a card is duplicated on play 17 and silently removed on play 31, the final count may look fine. An assertion after every action identifies the transition that broke the invariant.

The same idea applies to values other than cards:

  • A Hearts round contains 13 hearts and, unless someone shoots the moon, 26 penalty points.
  • A four-player trick has four plays; a lone Euchre trick has three.
  • Bridge ends a played deal with 13 completed tricks.
  • Each Oh Hell round records one bid and one result per player.
  • Team trick totals in Spades must equal the number of completed tricks.

These checks are cheap, precise, and much more useful than taking screenshots of the final score.

Test phase transitions as first-class behavior

I used to think of phases as UI labels: bidding, playing, scoring. Now I treat them as part of the public contract of the engine.

A transition test should verify both sides of the boundary.

When Hearts finishes its final trick, for example, I care about more than the score:

expect(state.phase).toBe("round_end");
expect(state.completedTricks).toHaveLength(13);
expect(state.currentTrick.plays).toHaveLength(0);
expect(totalCardsInHands(state)).toBe(0);
Enter fullscreen mode Exit fullscreen mode

That empty currentTrick assertion matters. Without it, the next deal can begin while the previous four cards are still considered active. I found exactly that bug after the UI appeared to play the final trick twice.

Round boundaries deserve their own assertions because they carry more cleanup than ordinary turns: dealer rotation, passing direction, vulnerability, scores, active seats, bids, and visible trick state may all change together.

Seed randomness when you need to replay a failure

Random deals are useful because they explore arrangements I would not think to write by hand. They are also frustrating when a failing deal cannot be reproduced.

For repeated engine tests, I inject a small seeded generator:

function seededRandom(seed: number) {
  let value = seed >>> 0;

  return () => {
    value = (Math.imul(value, 1664525) + 1013904223) >>> 0;
    return value / 0x100000000;
  };
}

for (let seed = 1; seed <= 20; seed++) {
  playCompleteDeal(seededRandom(seed));
}
Enter fullscreen mode Exit fullscreen mode

If seed 14 fails, I can run only seed 14, inspect the exact deal, and turn it into a focused regression test.

Production shuffling still uses cryptographic randomness. Test randomness has a different job: explore several paths while leaving a receipt.

Do not let the test AI cheat

Because every hand exists in browser memory, it is easy to give a computer player the entire game state. That makes the test convenient, but it can hide bugs.

An AI that sees all hands may always find a legal escape from a bad situation. A real player-facing AI only knows its own cards and public information.

I prefer interfaces shaped like this:

chooseCard({
  hand: state.hands[seat],
  currentTrick: state.currentTrick,
  completedTricks: state.completedTricks,
  publicBids: state.bids,
  trump: state.trump,
});
Enter fullscreen mode Exit fullscreen mode

The test driver should use the same information boundary as the game. This does not secure browser memory from a determined user. It prevents the implementation from becoming accidentally omniscient.

It also makes failures more honest. If the computer cannot choose a legal card from the information it is allowed to receive, either the AI interface or the engine state needs attention.

Simulations found different bugs in different games

The value of this approach became clearer as I added games with different structures.

Spades exposed tied end conditions. Reaching the target score is not enough when both teams are tied; the match must continue until one leads.

Hearts exposed cleanup errors at the final trick and score conservation problems around shooting the moon.

Euchre exposed active-seat bugs when a player went alone. A seat still existed at the table but no longer owned turns in that hand.

Bridge exposed control problems around Dummy. The seat holding the cards and the player choosing the card are not always the same person.

Oh Hell exposed round-progression assumptions because the hand size changes every round and the dealer cannot always choose every numeric bid.

None of these needed a browser to reproduce. The browser showed the symptom; the engine simulation reduced it to a repeatable state transition.

What full-game simulation does not test

Engine simulations do not replace browser tests.

They will not tell me that a mobile hand overlaps the trick area, that a dialog covers the player's cards, or that a moving card changes its face halfway through an animation. I still need visual checks and a small number of end-to-end interactions for those problems.

I use three layers now:

  1. Small rule tests explain a specific rule and fail precisely.
  2. Full-match simulations prove that legal actions can carry the engine to a valid ending.
  3. Browser checks cover layout, input wiring, and animation behavior.

The middle layer was the one I was missing. It is fast enough to run with every change and broad enough to catch disagreements between individually correct rules.

Start with one ugly simulation

You do not need property-testing software or a brilliant AI to begin.

Pick the simplest legal action at every turn. Add a move budget. Assert that the match ends. Then add one invariant whenever a bug teaches you what must always remain true.

My first simulations were long while loops inside test files. Some still are. They are not the prettiest code in the project, but they answer the question I actually care about:

Can this game finish, repeatedly, without losing track of its own rules?

A passing unit test can tell me that one move was correct. A completed simulated match tells me that the moves still agree with one another hundreds of transitions later.

That distinction has saved me more debugging time than any individual test helper.

The five games mentioned here are playable at Trick Card Games. The engines are still evolving, but every new rule now has to survive more than a carefully chosen example. It has to survive the whole game.

Top comments (0)