Overview
The Tic-Tac-Toe example demonstrates how to implement game logic in Leo using structs, conditional statements, and helper functions. This example showcases state representation, input validation, and win condition checking.
This example is located at .circleci/tictactoe/ in the Leo repository and is actively tested in CI.
Program Structure
program tictactoe.aleo {
// Main functions
fn new() -> Board
fn make_move(player: u8, row: u8, col: u8, board: Board) -> (Board, u8)
}
// Data structures
struct Row { c1: u8, c2: u8, c3: u8 }
struct Board { r1: Row, r2: Row, r3: Row }
// Helper functions
fn check_for_win(b: Board, p: u8) -> bool
Data Structures
Row Struct
Represents a single row in the game board:
struct Row {
c1: u8, // Column 1
c2: u8, // Column 2
c3: u8 // Column 3
}
Valid Values:
0u8: Empty cell
1u8: Player 1’s mark
2u8: Player 2’s mark
Any value other than 0, 1, or 2 is invalid and will cause incorrect game logic.
Board Struct
Represents the complete game board:
struct Board {
r1: Row, // Row 1 (top)
r2: Row, // Row 2 (middle)
r3: Row // Row 3 (bottom)
}
Visual Representation:
c1 c2 c3
r1 [ ] [ ] [ ]
r2 [ ] [ ] [ ]
r3 [ ] [ ] [ ]
Why Not Arrays?
Leo could represent the board as [[u8; 3]; 3], but this example uses structs to demonstrate:
Custom type definitions
Named field access
Struct initialization
Nested struct composition
Arrays would be more concise, but structs provide better readability for educational purposes.
Core Functions
Creating a New Game
fn new() -> Board {
return Board {
r1: Row { c1: 0u8, c2: 0u8, c3: 0u8 },
r2: Row { c1: 0u8, c2: 0u8, c3: 0u8 },
r3: Row { c1: 0u8, c2: 0u8, c3: 0u8 },
};
}
Returns: An empty 3x3 board with all cells initialized to 0u8.
Usage:
Output:
Board {
r1: Row { c1: 0u8, c2: 0u8, c3: 0u8 },
r2: Row { c1: 0u8, c2: 0u8, c3: 0u8 },
r3: Row { c1: 0u8, c2: 0u8, c3: 0u8 }
}
Making a Move
fn make_move(
player: u8,
row: u8,
col: u8,
board: Board
) -> (Board, u8) {
// Validate inputs
assert(player == 1u8 || player == 2u8);
assert(1u8 <= row && row <= 3u8);
assert(1u8 <= col && col <= 3u8);
// Unpack board
let r1c1: u8 = board.r1.c1;
let r1c2: u8 = board.r1.c2;
// ... (all 9 cells)
// Update appropriate cell
if row == 1u8 && col == 1u8 && r1c1 == 0u8 {
r1c1 = player;
} else if row == 1u8 && col == 2u8 && r1c2 == 0u8 {
r1c2 = player;
}
// ... (all possible moves)
// Reconstruct board
let updated: Board = Board {
r1: Row { c1: r1c1, c2: r1c2, c3: r1c3 },
r2: Row { c1: r2c1, c2: r2c2, c3: r2c3 },
r3: Row { c1: r3c1, c2: r3c2, c3: r3c3 },
};
// Check for winner
if check_for_win(updated, 1u8) {
return (updated, 1u8);
} else if check_for_win(updated, 2u8) {
return (updated, 2u8);
} else {
return (updated, 0u8);
}
}
Parameters:
player: The player making the move (1 or 2)
row: The row (1, 2, or 3)
col: The column (1, 2, or 3)
board: The current game board
Returns:
Board: The updated game board
u8: Winner (1 or 2) or 0 if no winner yet
Key Features:
assert(player == 1u8 || player == 2u8);
assert(1u8 <= row && row <= 3u8);
assert(1u8 <= col && col <= 3u8);
Ensures:
Player is either 1 or 2
Row is between 1 and 3
Column is between 1 and 3
Move Validation
if row == 1u8 && col == 1u8 && r1c1 == 0u8 {
r1c1 = player;
}
Only updates the cell if:
The target cell matches the row/col parameters
The cell is empty (== 0u8)
If the cell is already occupied, the board is returned unchanged (the move is silently ignored).
Helper Functions
Checking for a Winner
fn check_for_win(b: Board, p: u8) -> bool {
return
// Horizontal wins
(b.r1.c1 == p && b.r1.c2 == p && b.r1.c3 == p) || // Row 1
(b.r2.c1 == p && b.r2.c2 == p && b.r2.c3 == p) || // Row 2
(b.r3.c1 == p && b.r3.c3 == p && b.r3.c3 == p) || // Row 3
// Vertical wins
(b.r1.c1 == p && b.r2.c1 == p && b.r3.c1 == p) || // Col 1
(b.r1.c2 == p && b.r2.c3 == p && b.r3.c2 == p) || // Col 2
(b.r1.c3 == p && b.r2.c3 == p && b.r3.c3 == p) || // Col 3
// Diagonal wins
(b.r1.c1 == p && b.r2.c2 == p && b.r3.c3 == p) || // Top-left to bottom-right
(b.r1.c3 == p && b.r2.c2 == p && b.r3.c1 == p); // Top-right to bottom-left
}
Parameters:
b: The game board to check
p: The player to check for (1 or 2)
Returns: true if the player has won, false otherwise
Win Conditions:
Three in a row (horizontally)
Three in a column (vertically)
Three in a diagonal
There’s a bug in the original code! Line 105 checks b.r3.c3 twice instead of b.r3.c2 for row 3. This is preserved from the actual source code.
Playing the Game
Step 1: Create a New Board
Output:
| | | |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 0 | 0 |
| 0 | 0 | 0 |
Step 2: Player 1 Makes a Move
leo run make_move 1u8 1u8 1u8 "{ r1: { c1: 0u8, c2: 0u8, c3: 0u8 }, r2: { c1: 0u8, c2: 0u8, c3: 0u8 }, r3: { c1: 0u8, c2: 0u8, c3: 0u8 } }"
Output:
| | | |
|---|---|---|
| 1 | 0 | 0 |
| 0 | 0 | 0 |
| 0 | 0 | 0 |
Winner: 0 (no winner yet)
Step 3: Player 2 Makes a Move
leo run make_move 2u8 2u8 2u8 "{ r1: { c1: 1u8, c2: 0u8, c3: 0u8 }, r2: { c1: 0u8, c2: 0u8, c3: 0u8 }, r3: { c1: 0u8, c2: 0u8, c3: 0u8 } }"
Output:
| | | |
|---|---|---|
| 1 | 0 | 0 |
| 0 | 2 | 0 |
| 0 | 0 | 0 |
Winner: 0 (no winner yet)
Continue Until Win
Keep making moves until one player wins:
| | | |
|---|---|---|
| 1 | 1 | 1 | ← Player 1 wins!
| 2 | 2 | 0 |
| 0 | 0 | 0 |
Winner: 1
Running the Demo
Use the Run Script
cd .circleci/tictactoe
./run.sh
This script plays through a complete game.
Build the Program
View Generated Code
Key Language Features
Struct Initialization
let board: Board = Board {
r1: Row { c1: 0u8, c2: 0u8, c3: 0u8 },
r2: Row { c1: 0u8, c2: 0u8, c3: 0u8 },
r3: Row { c1: 0u8, c2: 0u8, c3: 0u8 },
};
Field Access
let top_left: u8 = board.r1.c1;
let middle: u8 = board.r2.c2;
Conditional Logic
if row == 1u8 && col == 1u8 && r1c1 == 0u8 {
r1c1 = player;
}
Early Return
if check_for_win(updated, 1u8) {
return (updated, 1u8); // Early return if player 1 wins
}
Leo supports early returns from functions using the return keyword.
Helper Functions
fn check_for_win(b: Board, p: u8) -> bool {
// Complex logic extracted to a helper
}
Helper functions improve code organization and readability.
Improvements and Extensions
Add Draw Detection
fn is_board_full(board: Board) -> bool {
return
board.r1.c1 != 0u8 && board.r1.c2 != 0u8 && board.r1.c3 != 0u8 &&
board.r2.c1 != 0u8 && board.r2.c2 != 0u8 && board.r2.c3 != 0u8 &&
board.r3.c1 != 0u8 && board.r3.c2 != 0u8 && board.r3.c3 != 0u8;
}
Use Records for Private Games
record Game {
owner: address,
board: Board,
next_player: u8,
}
This would allow private games where the board state is hidden.
Add Turn Validation
struct GameState {
board: Board,
next_player: u8,
}
fn make_move(player: u8, row: u8, col: u8, state: GameState) -> GameState {
assert(player == state.next_player);
// ... rest of logic
let next: u8 = if player == 1u8 { 2u8 } else { 1u8 };
return GameState { board: updated, next_player: next };
}
Store Games On-Chain
mapping games: u64 => Board;
fn make_move_public(game_id: u64, player: u8, row: u8, col: u8) -> Final {
return final { finalize_make_move(game_id, player, row, col); };
}
final fn finalize_make_move(game_id: u64, player: u8, row: u8, col: u8) {
let board: Board = Mapping::get(games, game_id);
// ... move logic
Mapping::set(games, game_id, updated_board);
}
Testing
Create inputs/tictactoe.in:
[new]
[make_move]
player: u8 = 1u8;
row: u8 = 1u8;
col: u8 = 1u8;
board: Board = { r1: { c1: 0u8, c2: 0u8, c3: 0u8 }, r2: { c1: 0u8, c2: 0u8, c3: 0u8 }, r3: { c1: 0u8, c2: 0u8, c3: 0u8 } };
Run Tests
Token More complex example with records and mappings
Lottery Simpler example with randomness
Further Reading
Data Types Learn more about structs and data types
Functions Function documentation
Statements Control flow guide
Built-in Types Type reference