Layered Boards

Grid-based games like Tic Tac Toe can also be implemented using multiple layers, where each player interacts with their own Quadrille. This strategy keeps game state cleanly separated per player, improving clarity and modularity—especially in multiplayer, strongly typed, or bitboard-based setups.

Instead of placing both players’ moves on a single grid, each player fills their own layer, overlaid during display. Win detection is simpler and more general: a shared set of structural patterns is checked independently on each layer, with no need for player-specific values. This approach supports dynamic sizes, varied value types, and scales naturally to more complex rules.

This chapter teaches

  • How to use multiple Quadrille layers for per-player state
  • How to detect wins by checking shared patterns across layers
  • How to generate win patterns declaratively with predicates
  • How to work with dynamic board sizes using createQuadrille(n, n, predicate, value)

Layered 3×3 Tic Tac Toe

The sketch below implements Tic Tac Toe using two separate Quadrilles—player1 and player2—and overlays them on the same canvas. Each move is recorded in the active player’s layer, and the game checks whether any shared win pattern is matched.

code
const patterns = [];
let player1;
let player2;
let resetButton;
let winner;
Quadrille.cellLength = 100;
const cols = 3, rows = 3;

function setup() {
  // Create canvas and UI
  createCanvas(cols * Quadrille.cellLength, rows * Quadrille.cellLength);
  textAlign(CENTER, CENTER);
  resetButton = createButton('reset game');
  resetButton.position(10, height + 10);
  resetButton.mousePressed(resetGame);
  // Disable right-click context menu
  document.oncontextmenu = () => false;
  // Define win patterns
  const diag = createQuadrille([
    ['⨂'],
    [null, '⨂'],
    [null, null, '⨂']
  ]);
  const horiz = createQuadrille(['⨂', '⨂', '⨂']);
  patterns.push(diag, diag.clone().reflect(), horiz, horiz.clone().transpose());
  resetGame();
}

function draw() {
  background(0);
  drawQuadrille(player1);
  drawQuadrille(player2);
  // Display winner message if game is over
  if (winner) {
    textSize(32);
    fill('white');
    text(`${winner} wins!`, width / 2, height / 2);
    noLoop();
  }
}

function mousePressed() {
  // Ignore clicks if game is over
  if (winner) return;
  // Determine current and opponent players
  const current = player1.order <= player2.order ? player1 : player2;
  const opponent = current === player1 ? player2 : player1;
  // Determine clicked cell coordinates
  const row = current.mouseRow;
  const col = current.mouseCol;
  // Check bounds and ensure both layers are empty
  if (current.isValid(row, col) &&
      current.isEmpty(row, col) && opponent.isEmpty(row, col)) {
    current.fill(row, col, current === player1 ? x : o);
    current === player2 && o.loop(); // ensure video starts when first used
    // Check for win or restart on draw
    winner = checkWinner();
    if (!winner && player1.order + player2.order === 9) resetGame();
  }
}

// Initialize both player layers and reset state
function resetGame() {
  player1 = createQuadrille(3, 3);
  player2 = createQuadrille(3, 3);
  winner = undefined;
  loop();
}

// Check win patterns for each layer
function checkWinner() {
  return patterns.some(p => player1.search(p, false).length > 0) && 'Player 1' ||
         patterns.some(p => player2.search(p, false).length > 0) && 'Player 2';
}

🎬 Media-aware logic The second player uses a video symbol:

  • muted (o.volume(0))
  • hidden from DOM (o.hide())
  • paused initially (resetGame())
  • looped only when the player makes a valid move

This ensures video playback is tightly coupled to game state.

Try this Compare this layered strategy with Aggregated States.

  • What are the main differences in game logic and rendering?
  • How do they affect win detection, value types, and state separation? Write a short note highlighting benefits and tradeoffs.

Turn-Based Interaction

Player turns alternate automatically based on the parity of moves across both layers. On each click, the logic verifies that the chosen cell is valid and empty in both layers. If so, it fills the current player’s layer, updates the board, and checks 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;
  // Determine current and opponent players
  const current = player1.order <= player2.order ? player1 : player2;
  const opponent = current === player1 ? player2 : player1;
  // Determine clicked cell coordinates
  const row = current.mouseRow;
  const col = current.mouseCol;
  // Check bounds and ensure both layers are empty
  if (current.isValid(row, col) &&
      current.isEmpty(row, col) && opponent.isEmpty(row, col)) {
    current.fill(row, col, current === player1 ? x : o);
    current === player2 && o.loop(); // ensure video starts when first used
    // Check for win or restart on draw
    winner = checkWinner();
    if (!winner && player1.order + player2.order === 9) resetGame();
  }
}

Key points:

Try this

  • Hide the opponent’s layer for a fog of war effect.
  • Add undo/redo support by storing clones of both layers (see Timelines).
  • Let players submit moves simultaneously before revealing them.

Win Pattern Definition

Patterns are shared: one set suffices, since each is checked against both layers. From simple seeds, all orientations can be generated with reflect() and transpose().

setup (excerpt)
const diag = createQuadrille([
  ['⨂'],
  [null, '⨂'],
  [null, null, '⨂']
]);
const horiz = createQuadrille(['⨂', '⨂', '⨂']);
patterns.push(diag, diag.clone().reflect(), horiz, horiz.clone().transpose());

You can see all these patterns visualized below.

Detecting a Winner

Win detection simply tests each pattern against both layers, ignoring value types:

checkWinner
function checkWinner() {
  return patterns.some(p => player1.search(p, false).length > 0) && 'Player 1' ||
         patterns.some(p => player2.search(p, false).length > 0) && 'Player 2';
}

The false flag in search disables value equality checks—only positions matter. Any non-null cell counts as a match, so even when win patterns are defined with symbols like , any consistent non-null value (image, emoji, string, etc.) will satisfy them.

Pattern Generation with Predicates

Quadrille’s API supports predicates—functions that decide which cells to fill based on their row, col coordinates. This lets you define win conditions or custom shapes declaratively, describing what to occupy rather than how to construct it. Predicates make it easy to generalize rows, columns, diagonals, or compound shapes across different board sizes, keeping logic concise and reusable.

The sketch below shows a connect-n version of the game, where n is randomly chosen at each reset. All win patterns are defined declaratively using simple predicates, such as rows, diagonals, or custom shapes.

Inside resetGame():

resetGame
function resetGame() {
  o.pause();
  n = int(random(4, 10));
  Quadrille.cellLength = width / n;
  player1 = createQuadrille(n, n);
  player2 = createQuadrille(n, n);
  winner = undefined;
  patterns = [];
  const qDiag1 = createQuadrille(n, n, ({ row, col }) => row === col, '⨂');
  const qDiag2 = qDiag1.clone().reflect();
  const qRow = createQuadrille(n, 1, ({ row }) => row === 0, '⨂');
  const qCol = qRow.clone().transpose();
  patterns.push(qDiag1, qDiag2, qRow, qCol);
  loop();
}

These -based patterns are visualized below, updating live as n changes via the slider:

⚠️ Consistency note
In the earlier example, the draw condition is hard-coded for a fixed 3×3 board.
For variable board sizes, change that line to:

if (!winner && player1.order + player2.order === n * n) resetGame();

This ensures the reset works correctly when n is chosen randomly in resetGame().

What are Predicates?

A predicate is a function that returns true or false for each { row, col } cell. It determines whether to assign a value at that location:

({ row }) => row === 0        // Top row
({ row, col }) => row === col // Main diagonal

This approach replaces manual input with concise, reusable logic.

Try this

  • Define qCol directly using a predicate, instead of transposing qRow.
  • Create an anti-diagonal pattern with ({ row, col }) => row + col === n - 1.
  • Create a T-shaped or L-shaped pattern using compound predicate logic.
  • Create other variants, such as:
    • A “connect-l” version of the game, where 3 ≤ l ≤ n, and patterns must match exactly l consecutive cells.
    • A gravity-based version where pieces fall to the lowest empty cell in a column.
    • Asymmetric win conditions: assign different patterns or connect-lengths to each player.
    • A highlight mode that visually marks the matching pattern when a player wins.
    • A block-based version where players place compound shapes (like tetrominoes) instead of single cells.

References

Further Reading