DEV Community

Cover image for I Built the Game Logic Quickly. Making Players Understand It Was Much Harder
Vladimir Ushakov
Vladimir Ushakov

Posted on

I Built the Game Logic Quickly. Making Players Understand It Was Much Harder

Cat Laundry is a small browser strategy game played on a 4×4 board. The rule that drives it fits in a few sentences. Explaining the consequences of that rule on screen took much longer than implementing it.

The board contains sixteen cats, each belonging to one of two colors. On your turn, you click a cat of your color. The clicked cat changes to the opponent's color, while your color spreads outward along all four diagonals. If the resulting cells form a square, diamond, or cross, that shape scores.

That sounded readable when I described it in text. My first visual implementation proved otherwise.

A Cat Laundry match on the 4×4 board

A move is not one state change

The simple implementation is to calculate the final board, assign it, and render once. From the program's perspective, that is attractive: input goes in, the rule function returns the next state, and the UI displays it.

From a player's perspective, several cats flash to new colors for no visible reason.

The important information is not only which cells changed. It is the causal chain:

  1. I chose this cat.
  2. That cat was sacrificed to the other color.
  3. My color moved through the nearest diagonal cells.
  4. It continued to the next diagonal distance.
  5. The last recolored cell completed a shape.
  6. The shape scored and was redistributed.

If I render only the final array, steps 2–5 collapse into one frame. The result is mathematically correct and visually indistinguishable from random blinking.

The current move code therefore groups diagonal targets by their distance from the clicked cell:

const distances = [...new Set(
  targets.map(target => Math.abs(row(target) - row(origin)))
)].sort((a, b) => a - b);

for (let ringIndex = 0; ringIndex < distances.length; ringIndex++) {
  const distance = distances[ringIndex];
  const ring = targets.filter(target =>
    Math.abs(row(target) - row(origin)) === distance
  );

  waveNext = new Set(ring);
  render();
  await wait(ringIndex === 0 ? 120 : 1500);

  waveNext = new Set();
  recolor(ring);
  render();
}
Enter fullscreen mode Exit fullscreen mode

The structure is: announce the next ring, wait, apply it, render, then continue. waveNext, waveReached, waveOrigin, and the current actor are presentation state, not game rules. They exist because the player needs to see where the change came from and where it is going.

The tutorial showing the path of a diagonal wave

The shape highlight has to arrive late

I also had to separate "the board now contains a shape" from "show the scored shape."

Shape detection itself is straightforward. I slide templates over the 4×4 board: two forms of cross, a diamond, and a 2×2 square. A candidate counts only when every required cell has the scoring color and at least one of those cells was touched by the current move. Candidates are sorted by points, and overlapping lower-value shapes are rejected.

The timing was the difficult part.

If the highlight appears in the same render as the final recolor, it competes with the event that caused it. A player sees a colored outline and a changed cat, but cannot reliably tell which happened first. The UI is effectively presenting cause and conclusion at once.

After the final diagonal ring, the game now waits 650 ms before setting activePattern. Only then does it show the complete shape and add its points. It holds that state for another 1000 ms before redistributing the scored cells roughly between the two colors.

Those waits are not decorative polish. They turn one opaque transition into three statements:

  • the last cells changed;
  • this exact shape is now complete;
  • these cells are being recycled because the shape was consumed.

The rule engine does not need those phases. The person watching it does.

A completed shape highlighted after recoloring

Interruption made animation part of the game state

The delay between diagonal rings created a tactical possibility: another player can move before the previous inertia reaches its outer cells.

When that happens, the game does not simply discard the earlier action. It increments a generation token so the old asynchronous loop can no longer continue, evaluates shapes using the cells that wave actually touched, fixes any score, and starts the new move.

In the single-player code, each animated strike captures a local token. After every wait it checks that token against the current waveToken. Starting an interrupt increments the token. The suspended strike wakes up, sees that it belongs to an older generation, and returns.

This was useful beyond cancellation. It gave the mechanic an honest visual contract: if the wave has not visibly reached a cell, that cell is not silently included in the result.

It also explains why the AI cannot answer immediately. The move selector can rank legal sources synchronously, but an instant computer response would erase the reading window that makes interruption understandable. AI actions are deliberately scheduled with a random delay between 1200 and 2200 ms. The delay is a gameplay constraint and a readability constraint, not an attempt to imitate expensive computation.

The AI itself evaluates simulated strikes. Easy mode chooses randomly. The other modes rank candidates using immediate shape points, converted cells, diagonal reach, and—on the stronger evaluation path—the opponent's best immediate reply. None of that matters if the player cannot see what move the AI interrupted, so the pause is as important as the score function.

A rules modal was not enough

My original explanation treated the move as a sentence: click your cat, sacrifice it, recolor the diagonals, make shapes. Test players still missed either the sacrifice or the propagation.

The tutorial became an eleven-step interactive sequence with its own small state machine. It does not merely show completed boards. It asks for a legal click, animates diagonal distances, pauses on the last recolored vertex, and only then highlights the square, diamond, or cross. Later steps stage an opponent's partial wave and ask the player to interrupt it. Separate examples explain overlapping shapes, independent shapes, safe opponent waves, and the eight-second turn limit.

That sounds more elaborate than the underlying game because it is. A novel interaction often needs examples ordered by dependency. I cannot usefully explain interruption until the player can identify a wave in progress. I cannot explain a scored cross until the player has seen the final vertex change before the highlight appears.

The step-by-step Cat Laundry tutorial

Multiplayer had to transmit the unfinished move

For online matches, sending only the final board would recreate the original blinking problem on the second client. The server is authoritative, but its serialized match includes the visual intermediate state:

return {
  board: match.board,
  scores: match.scores,
  waveOrigin: match.waveOrigin,
  activeEmitter: match.activeEmitter,
  waveReached: [...match.waveReached],
  waveNext: [...match.waveNext],
  waveFront: [...match.waveFront],
  activePattern: match.activePattern,
  transitioning: Boolean(match.transitionUntil),
  version: match.version,
  serverTime: Date.now()
};
Enter fullscreen mode Exit fullscreen mode

The client polls the match endpoint every 240 ms and re-renders when the version changes. The server bumps that version when it announces the next diagonal ring, when it applies the ring, when it fixes a pattern, and when the transition finishes. A late or interrupted strike is protected by a server-side generation counter, just like the local token.

This means the intermediate animation is not reconstructed by guessing from two board snapshots. Both players receive the same origin, reached cells, next cells, active pattern, and transition lock. The causal explanation is part of synchronized state.

What I took from it

I used to think of animation state as a temporary layer placed over the real application state. Cat Laundry forced a more precise distinction.

Some animation is cosmetic. A hover bounce can disappear without changing what the interface communicates. Causal animation is different: it explains how one valid state became another. If users must make decisions during that transition—or if two clients must agree on it—the intermediate state deserves explicit names, cancellation rules, and synchronization.

The practical questions I now ask are:

  • What did the user cause?
  • Which visible change is the first consequence?
  • What must finish before I present the conclusion?
  • Can a new input interrupt the sequence?
  • If it can, which intermediate facts are already committed?
  • Would another client be able to render the same explanation?

You can play Cat Laundry in the browser. The tutorial is skippable, but it is also the quickest way to see the sequence described above.

When you try one move, is it clear which cat caused each recolor—and can you tell why a completed shape appeared before looking at the score?

Top comments (0)