Aggregated States

Grid-based games like Tic Tac Toe highlight how a quadrille can serve as a general model for game state. Here, all moves are recorded in a single Quadrille—an aggregate layer for the entire game—where the players are distinguished only by their values, x (='X') and o (='O').

This approach keeps turn logic straightforward: one grid, one shared history. The trade-off is that win detection must manage separate pattern sets for each player and match them exactly. Later chapters (e.g. Layered Boards) will explore alternatives where each player has their own quadrille.

This chapter teaches

  • How to manage all game state with a single quadrille
  • How to define and reuse winning patterns for each player
  • How to use search for exact pattern matching
  • How to implement basic turn logic with order, isEmpty, and fill

Aggregated Layer Tic Tac Toe

The classic 3×3 Tic Tac Toe is a perfect testbed for the aggregate approach. All moves are stored in a single Quadrille, with x (='X') and o (='O') alternating turns. Each click fills a cell if it is empty, the game checks for a winning pattern, and the board resets automatically on a draw.

code
let patterns1 = [];
let patterns2 = [];
let game;
let winner;
let resetButton;
const x = 'X', o = 'O';

function setup() {
  // Create canvas and UI
  createCanvas(300, 300);
  textAlign(CENTER, CENTER);
  resetButton = createButton('reset game');
  resetButton.position(10, height + 10);
  resetButton.mousePressed(resetGame);
  // Initialize board and win patterns
  resetGame();
  const diag1 = createQuadrille([
    [x],
    [null, x],
    [null, null, x]
  ]);
  const horiz1 = createQuadrille([x, x, x]);
  patterns1.push(diag1,
                 diag1.clone().reflect(),
                 horiz1,
                 horiz1.clone().transpose());
  const horiz2 = horiz1.clone().replace(x, o);
  const diag2 = diag1.clone().replace(x, o);
  patterns2.push(diag2,
                 diag2.clone().reflect(),
                 horiz2,
                 horiz2.clone().transpose());
}

function draw() {
  background('DarkSeaGreen');
  drawQuadrille(game);
  // Show winner message if game is over
  if (winner) {
    textSize(32);
    fill('yellow');
    text(`${winner} wins!`, width / 2, height / 2);
    noLoop();
  }
}

function mousePressed() {
  // Ignore clicks if game is over
  if (winner) return;
  const row = game.mouseRow;
  const col = game.mouseCol;
  // Place current marker if cell is valid and empty
  if (game.isValid(row, col) && game.isEmpty(row, col)) {
    const current = game.order % 2 === 0 ? x : o;
    game.fill(row, col, current);
    // Check for win or restart on draw
    winner = checkWinner();
    if (!winner && game.order === 9) resetGame();
  }
}

// Reset board and state; resume draw loop
function resetGame() {
  game = createQuadrille(3, 3);
  winner = undefined;
  loop();
}

// Search for winning pattern in board
function checkWinner() {
  return patterns1.some(p => game.search(p, true).length > 0) && 'Player 1' ||
         patterns2.some(p => game.search(p, true).length > 0) && 'Player 2';
}

Turn-Based Interaction

Player turns alternate automatically based on the parity of game.order. On each click, the code checks whether the chosen cell is valid and empty. If so, it places x or o, updates the board, and tests for a win. If the board fills without a winner, the game resets for a new round.

mousePressed
function mousePressed() {
  // Ignore clicks if game is over
  if (winner) return;
  const row = game.mouseRow;
  const col = game.mouseCol;
  // Place current marker if cell is valid and empty
  if (game.isValid(row, col) && game.isEmpty(row, col)) {
    const current = game.order % 2 === 0 ? x : o;
    game.fill(row, col, current);
    // Check for win or restart on draw
    winner = checkWinner();
    if (!winner && game.order === 9) resetGame();
  }
}

Key points:

  • mouseRow and mouseCol give the coordinates of the clicked cell.
  • isValid ensures the click is inside the board.
  • isEmpty ensures the cell isn’t already taken.
  • order counts how many moves have been made so far — its parity determines the current player.
  • fill(row, col, value) places the chosen marker.

Try this

  • Change the starting player.
  • Add a key to skip a turn by incrementing game.order.

Defining and Detecting Win Patterns

In a single, shared quadrille, player identity comes from values alone. We build two sets of patterns, one using x (='X') and the other using o (='O'), and detect a win by matching the board against the appropriate set.

Defining Patterns

From a diagonal and a horizontal seed, all win shapes follow through symmetry operations. Player-2 inherits the same shapes, obtained by value substitution.

setup (excerpt)
const diag1 = createQuadrille([
  [x],
  [null, x],
  [null, null, x]
]);
const horiz1 = createQuadrille([x, x, x]);
patterns1.push(diag1,
               diag1.clone().reflect(),
               horiz1,
               horiz1.clone().transpose());
const horiz2 = horiz1.clone().replace(x, o);
const diag2 = diag1.clone().replace(x, o);
patterns2.push(diag2,
               diag2.clone().reflect(),
               horiz2,
               horiz2.clone().transpose());

You can see all these patterns (patterns1 and patterns2) visualized below.

Detecting Wins

Win detection reduces to testing patterns with search(pattern, true), which enforces value equality on a shared quadrille.
The check is wrapped in Array.prototype.some(), ensuring a winner is reported immediately on the first match.

checkWinner
function checkWinner() {
  return patterns1.some(p => game.search(p, true).length > 0) && 'Player 1' ||
         patterns2.some(p => game.search(p, true).length > 0) && 'Player 2';
}

💡 Without some, you’d need to use a for…of loop with early returns:

function checkWinner() {
  for (let p of patterns1) {
    if (game.search(p, true).length > 0) {
      return 'Player 1';
    }
  }
  for (let p of patterns2) {
    if (game.search(p, true).length > 0) {
      return 'Player 2';
    }
  }
  return undefined;
}

Try this

  • Define winning patterns in the shape of an L with three cells.
  • Experiment with patterns that include gaps (e.g., x·x).

Working with Different Value Types

Quadrille cells act as typed containers: they can store any valid JavaScript value, from primitives (strings, numbers, booleans) to complex objects (images, functions). In an aggregated layer, win detection with search remains valid as long as values match exactly, regardless of their type.

This means the same game logic can be applied with strings, numbers, images, or custom objects—showcasing the generality of Quadrille’s data model.

Strings and Numbers

In this variant, Player 1 uses the string 'X', while Player 2 uses the number 125. Even though the types differ, win detection works identically.

Winning patterns:

Images as Values

Player symbols need not be text or numbers—they can also be images. When loaded once and reused consistently, image references behave exactly like other values when passed to search or replace.

Winning patterns:

🖼️ For image-based values to match, the same image object must be reused in both the game state and the win patterns.
Reloading or creating duplicate instances will break equality checks.

Try this

  • Replace 'X' and 'O' with images (e.g., icons or small sprites) as player symbols.
  • Use booleans (true and false) instead of strings, and confirm that search still detects wins correctly.

3×3 Pattern Search in Larger Boards

The same 3×3 winning patterns can be reused on an n×n board, where n is chosen at random between 4 and 9. Neither the logic nor the pattern definitions need to change—Quadrille’s search scans for the 3×3 subgrid anywhere on the larger board.

This illustrates the local nature of pattern matching: patterns remain fixed in size, while the board can grow arbitrarily.
The call search(pattern, true) checks every possible subgrid and succeeds if any region matches the pattern exactly.

💡 Win detection is position-independent. As long as the board contains a region identical to the 3×3 pattern, search will find it—even when the board is larger.

Logic for variable board size

Only two changes are required to support a dynamic board:

  • Resize the cells so the board always fits within the canvas.
  • Reset the game after all n × n cells have been filled.
sketch excerpt
let n;

function resetGame() {
  n = int(random(4, 10)); // Choose board size at random
  Quadrille.cellLength = width / n; // Scale cells to fit canvas
  game = createQuadrille(n, n);
  // ...
}

function mousePressed() {
  // ...
  if (!winner && game.order === n * n) resetGame();
}

The variable n controls both the grid dimensions and the draw condition. If no winner is found after n × n moves, the board resets automatically. Cell size is recomputed dynamically from Quadrille.cellLength so the grid always fits.

Try this

  • Increase n to make the game harder: as the board grows, three-in-a-row becomes less likely.
  • Keep the 3×3 patterns fixed but let n vary—observe how pattern search adapts without changes in definition.

References

Further Reading