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:
mouseRow
andmouseCol
locate the clicked cell.isValid
ensures the move is inside the board.isEmpty
checks both layers for emptiness.order
tracks how many moves each layer contains; their sum determines the current turn.fill(row, col, value)
places the current player’s symbol.
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 transposingqRow
. - 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 exactlyl
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.
- A “connect-l” version of the game, where
References
- Tic Tac Toe — Classic example for symmetry and win detection.
- Quadrille predicates — Rule-based grid filling.
- Bitboards — Efficient layered representation for scalable games.
Further Reading
- Declarative programming — Why describing what to compute scales better.
- Pattern matching (computer science) — General background on structural search.
- Symmetry in mathematics — Formal basis for reflection/rotation of patterns.
- Connect Four — A classic connect-l game with falling mechanics.
- Game balance — How asymmetry and alternate win conditions remain fair.
- Browne, C. (2009). Automatic Generation and Evaluation of Recombination Games (PhD, QUT). — Academic work on connect-style and recombination games, useful for deeper exploration of layered rules.
- Encapsulation and abstraction — Highlights the benefits of separating state logic across layers.