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 cleanly separates game state 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: shared structural patterns are matched independently on each layer, with no need for player-specific values. This approach also 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 comparing each layer against shared patterns
- How to generate win patterns declaratively using predicate functions
- How to work with dynamic board sizes using
createQuadrille(n, n, predicate, value)
Layered 3×3 Tic Tac Toe
The sketch below implements standard 3×3 Tic Tac Toe using two separate Quadrilles—player1
and player2
—and overlays them on a single canvas. Players alternate turns, and each move is recorded in the corresponding layer. The game checks whether any of the shared win patterns are matched in either layer.
code
let patterns = [];
let n;
let player1;
let player2;
let resetButton;
let winner;
Quadrille.cellLength = 100;
let x, o;
async function setup() {
// Load image and video assets
x = await loadImage('images/v.jpg');
o = await createVideo('clips/c2.webm');
o.volume(0); // mute video
o.hide(); // hide default video element
// Create canvas and UI
createCanvas(3 * Quadrille.cellLength, 3 * 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;
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 === n * n) resetGame();
}
}
// Initialize both player layers, reset state and patterns
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();
}
// 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 game logic
This version demonstrates how to manage media elements in a grid context. The createVideo
asset for Player 2 is:
- muted (
o.volume(0)
) - hidden from the default DOM (
o.hide()
) - paused initially in
resetGame()
- looped only when the player makes a valid move
This pattern ensures that video playback is tightly controlled and only activated during gameplay.
🧩 Try this:
Compare and contrast this layered implementation with the one in Aggregated States:
- What are the main differences in game logic and rendering?
- How do the strategies affect win detection, value types, and state separation? → Run both examples and write a short note highlighting benefits and tradeoffs of each approach.
Turn-Based Interaction
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 ideas:
- Each player fills only their layer.
- A move is valid if both layers are empty at the selected cell.
- Turn order is derived from the sum of orders.
- After each move, the game checks if that player’s grid matches a win pattern.
🧩 Try this:
- Implement fog of war: draw only the active player’s layer to hide the opponent’s moves.
- Add replay support: store and navigate previous states to enable undo or full timeline playback.
- Enable simultaneous turns: let both players submit their moves before revealing them.
Win Pattern Definition
Patterns are shared between players. Only one set is needed, since we check each pattern against each layer independently. They are defined using a base Quadrille and symmetry operations:
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
After each move, we use search(pattern, false)
on both player layers:
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 matching: only non-null cell positions are compared. Any player using consistent non-null values (like images or emojis) will match the ⨂
patterns.
Pattern Generation with Predicates
A major benefit of Quadrille’s functional design is support for predicates—functions that describe which cells to fill
based on their row
, col
coordinates. This enables clear, rule-based definitions of win conditions, ideal for larger or dynamic boards, and follows a declarative programming style where you describe what to fill rather than how to do it.
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 (excerpt)
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 patterns are visualized below, updating live as n
changes via the slider:
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 grid 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 exploring grid logic and symmetry-based pattern matching.
- Predicate creation in Quadrille — Generate flexible, rule-based patterns like lines, diagonals, or compound shapes.
- Bitboard-based setups — Efficient representations for grid states, often used for variable-length matches and AI logic.
Further Reading
- Declarative programming — Programming paradigm focused on describing what to compute, rather than how, especially useful for rule-based logic and pattern generation.
- Pattern matching (computer science) — Background on searching structured data, including visual and symbolic patterns.
- Symmetry operations on grids — Useful for reflecting and rotating patterns like L or T shapes.
- Connect Four — A classic gravity-based, connect-l game, useful for implementing the falling mechanics variant.
- Game balance — Introduces asymmetry in games and how different win conditions per player can still result in fair play.
- Encapsulation and abstraction — Highlights the benefits of separating state logic across layers.