DEV Community

Vladimir Ushakov
Vladimir Ushakov

Posted on

When One Boolean Changes Five Things: Keeping Mid-Match Rules in Sync

Cat Laundry is a browser strategy duel played on a shared 4×4 board. A move begins by clicking one of your own cats. The clicked cat switches to the opponent, while your color travels along the diagonals and may complete a square, diamond, or cross.

The late-game mode, Spin Cycle, changes the match after either player reaches 70 points. Cat rotation becomes much faster, squares and diamonds stop scoring, only crosses remain valid, every cross is worth 10 points, and the winning target stays at 100.

The difficult implementation detail was not the threshold. It was preventing five different parts of the game from disagreeing about whether that threshold had been crossed.

A Cat Laundry match on the 4×4 board

Change the state after the scoring event

The computer match keeps the late-game state as one boolean:

let finalPhase = false;

function activateFinalPhase() {
  if (
    finalPhase ||
    matchTarget < 100 ||
    Math.max(humanScore, aiScore) < 70
  ) return false;

  finalPhase = true;
  return true;
}
Enter fullscreen mode Exit fullscreen mode

The matchTarget < 100 guard matters because the first computer match can be a shorter introduction. Spin Cycle belongs to the regular 100-point match, not the 60-point trial.

More importantly, I call activateFinalPhase() only after the current move has found its patterns and awarded their points. The move that reaches 70 is evaluated under the rules that were active when the move began. Spin Cycle applies to the state that follows it.

That ordering gives the transition a clean causal chain:

  1. the diagonal wave finishes;
  2. completed figures are detected;
  3. points are added;
  4. the score crosses 70;
  5. Spin Cycle is announced;
  6. later moves use the new scoring rules.

The function also returns whether this specific scoring event started the phase. That is different from merely checking whether finalPhase is true. The UI needs a one-time transition message, while the board needs a persistent late-game appearance.

Let scoring consume the state directly

All figure detection still passes through completedPatterns(). Spin Cycle narrows that existing pipeline instead of adding a second scoring system:

for (const pattern of PATTERNS) {
  if (finalPhase && !pattern.name.startsWith('КРЕСТ')) continue;

  const scoredPattern = finalPhase
    ? { ...pattern, points: 10 }
    : pattern;

  // Find matching placements that include a cell touched by this move.
}
Enter fullscreen mode Exit fullscreen mode

This is where the rule has to be authoritative. In late game, square and diamond candidates never enter the accepted set. Both cross variants survive, and their points are replaced with 10.

Everything after detection can remain unchanged: overlapping figures are resolved, points are summed, the scored cells are recycled, and the next turn begins. The UI labels also come from the accepted pattern objects, so a late-game cross is displayed as +10 because the scoring object itself contains 10. There is no separate display-only value to drift out of sync.

The computer opponent uses the same function while simulating candidate moves:

function simulateStrike(state, index, color) {
  // Apply the clicked-cat sacrifice and diagonal recoloring.
  return {
    state: next,
    touched,
    patterns: completedPatterns(color, touched, next)
  };
}
Enter fullscreen mode Exit fullscreen mode

That means its move evaluation changes automatically when finalPhase changes. I do not maintain one scoring table for the real move and another for the AI.

Make the copy and animation read the same state

The renderer maps the boolean to a persistent CSS class and to the visible banner:

grid.classList.toggle('final-phase', finalPhase);
ui.finalPhase.classList.toggle('hidden', !finalPhase);
Enter fullscreen mode Exit fullscreen mode

The class changes several visual channels together:

.machine-grid.final-phase {
  filter: saturate(1.45) contrast(1.08);
}

.machine-grid.final-phase .cat-wrap {
  animation-duration: 1.05s;
}

.machine-grid.final-phase .glass {
  animation: finalGlassPanic .34s steps(2, end) infinite;
}
Enter fullscreen mode Exit fullscreen mode

The normal cat rotation is seven seconds, so 1.05 seconds is an unmistakable change. Saturation, flashing glass, and faster lamps reinforce the same state without changing the underlying board.

The banner states the scoring rule explicitly: Spin Mode activated; only crosses score; each cross is worth 10 points. The English strings live in the same translation map as the rest of the interface, including the 70-point announcement. This matters because an untranslated or stale rule sentence would be a functional bug: it could tell the player that squares still count while the scoring function has already rejected them.

The diagonal wave tutorial in Cat Laundry

Keep the network match server-authoritative

The multiplayer implementation has its own match object, but not its own interpretation of the rule. The server owns finalPhase, sets it after scoring, and stores when the transition began:

const phaseStarted =
  !match.finalPhase &&
  Math.max(match.scores[LEFT], match.scores[RIGHT]) >= 70;

if (phaseStarted) {
  match.finalPhase = true;
  match.finalPhaseStartedAt = Date.now();
}
Enter fullscreen mode Exit fullscreen mode

Server-side completedPatterns() applies the same cross-only filter and 10-point override. The serialized match then sends both persistent and short-lived information:

{
  finalPhase: !!match.finalPhase,
  finalPhaseAnnouncing:
    !!match.finalPhaseStartedAt &&
    Date.now() - match.finalPhaseStartedAt < 3000
}
Enter fullscreen mode Exit fullscreen mode

The client uses finalPhase for the banner, fast animation, and continuing visual state. It uses finalPhaseAnnouncing for the temporary 70-point message. Scoring never depends on whether a client rendered that message in time; the server has already decided which figures count.

This separation is useful beyond games. A state transition often has two presentation needs:

  • a durable indication of the current mode;
  • a short explanation of the event that changed the mode.

Trying to derive both from a timer or animation callback would make presentation timing responsible for game truth.

The invariant I actually care about

Spin Cycle is safe only if these statements are always true together:

  • if the banner says cross-only, the detector rejects squares and diamonds;
  • if the detector awards 10, the figure label also says +10;
  • if cats are spinning faster, the match is already in late game;
  • the AI evaluates the same late-game moves that the real scorer accepts;
  • in multiplayer, the server decides the phase and clients only present it;
  • changing the allowed figures never changes the 100-point victory condition.

The code is small because the transition has one source of truth and several explicit consumers. The work is in making every consumer read that truth at the correct moment.

You can play Cat Laundry in the browser.

When an interface changes rules while an interaction is still in progress, do you prefer a persistent mode indicator, a one-time explanation, or both?

Top comments (0)