Tic Tac Toe

Play Demo

A classic two-player Tic Tac Toe built entirely with SimVX UI widgets. Demonstrates buttons, grids, labels, signals, and game state management without any custom draw code.

What You Will Learn

  • UI widgets – Build interfaces with Button, Label, GridContainer, VBoxContainer

  • Signal connections – Wire button presses to game logic with btn.pressed.connect()

  • GridContainer – Automatic grid layout for the 3x3 board

  • Dynamic UI updates – Change button text, colours, and label content at runtime

  • Game reset – Clear and reinitialise UI state

Controls

Click any empty cell to place X or O. Click “New Game” to reset.

How It Works

TicTacToeGame builds the UI in ready():

  1. A VBoxContainer holds the title, status label, game grid, and reset button

  2. A GridContainer with columns=3 contains 9 Button widgets for the cells

  3. Each button’s pressed signal connects to make_move(row, col) using a lambda

  4. make_move() places the current player’s mark, updates the button’s text and colour, then calls _check_winner() to scan rows, columns, and diagonals

  5. A win or draw updates the status label and emits the game_over signal

Source Code

  1"""Tic Tac Toe -- UI Widget Game
  2
  3A classic two-player Tic Tac Toe built entirely with SimVX UI widgets.
  4Demonstrates buttons, grids, labels, signals, and game state management
  5without any custom draw code.
  6
  7## What You Will Learn
  8
  9- **UI widgets** -- Build interfaces with `Button`, `Label`, `GridContainer`, `VBoxContainer`
 10- **Signal connections** -- Wire button presses to game logic with `btn.pressed.connect()`
 11- **GridContainer** -- Automatic grid layout for the 3x3 board
 12- **Dynamic UI updates** -- Change button text, colours, and label content at runtime
 13- **Game reset** -- Clear and reinitialise UI state
 14
 15## Controls
 16
 17Click any empty cell to place X or O. Click "New Game" to reset.
 18
 19## How It Works
 20
 21`TicTacToeGame` builds the UI in `ready()`:
 22
 231. A `VBoxContainer` holds the title, status label, game grid, and reset button
 242. A `GridContainer` with `columns=3` contains 9 `Button` widgets for the cells
 253. Each button's `pressed` signal connects to `make_move(row, col)` using a lambda
 264. `make_move()` places the current player's mark, updates the button's text and
 27   colour, then calls `_check_winner()` to scan rows, columns, and diagonals
 285. A win or draw updates the status label and emits the `game_over` signal
 29"""
 30
 31
 32from menu import MainMenu, ScoreBoard
 33
 34from simvx.core import (
 35    Button,
 36    Colour,
 37    GridContainer,
 38    Label,
 39    Node,
 40    Signal,
 41    VBoxContainer,
 42)
 43from simvx.core.math.types import Vec2
 44from simvx.graphics import App
 45
 46
 47class TicTacToeGame(Node):
 48    """Complete Tic Tac Toe game with UI."""
 49
 50    def __init__(self, **kwargs):
 51        super().__init__(**kwargs)
 52        self.name = "TicTacToeGame"
 53        self.board: list[list[str | None]] = [[None] * 3 for _ in range(3)]
 54        self.current_player = "X"
 55        self.winner: str | None = None
 56        self.game_over = Signal()
 57
 58        # UI references (set in ready)
 59        self.cells: list[list[Button | None]] = [[None] * 3 for _ in range(3)]
 60        self.status_label: Label | None = None
 61        self.new_game_btn: Button | None = None
 62
 63    def ready(self):
 64        root = VBoxContainer(name="Root")
 65        root.position = Vec2(20, 20)
 66        root.size = Vec2(360, 440)
 67        root.separation = 10
 68
 69        # Title
 70        title = Label("Tic Tac Toe", name="Title")
 71        title.font_size = 24.0
 72        title.text_colour = Colour.WHITE
 73        title.alignment = "center"
 74        title.size = Vec2(360, 36)
 75        root.add_child(title)
 76
 77        # Status
 78        self.status_label = Label("Player X's turn", name="Status")
 79        self.status_label.font_size = 16.0
 80        self.status_label.text_colour = (0.7, 0.9, 1.0, 1.0)
 81        self.status_label.alignment = "center"
 82        self.status_label.size = Vec2(360, 24)
 83        root.add_child(self.status_label)
 84
 85        # Grid
 86        grid = GridContainer(columns=3, name="Grid")
 87        grid.size = Vec2(360, 330)
 88        grid.separation = 6
 89
 90        for row in range(3):
 91            for col in range(3):
 92                btn = Button("", name=f"Cell_{row}_{col}")
 93                btn.size = Vec2(110, 100)
 94                btn.font_size = 36.0
 95                btn.bg_colour = (0.15, 0.15, 0.2, 1.0)
 96                btn.hover_colour = (0.25, 0.25, 0.35, 1.0)
 97                btn.pressed_colour = (0.1, 0.1, 0.15, 1.0)
 98                btn.border_colour = (0.4, 0.4, 0.5, 1.0)
 99                r, c = row, col
100                btn.pressed.connect(lambda r=r, c=c: self.make_move(r, c))
101                grid.add_child(btn)
102                self.cells[row][col] = btn
103
104        root.add_child(grid)
105
106        # New Game button
107        self.new_game_btn = Button("New Game", name="NewGame")
108        self.new_game_btn.size = Vec2(360, 35)
109        self.new_game_btn.bg_colour = (0.2, 0.5, 0.3, 1.0)
110        self.new_game_btn.hover_colour = (0.3, 0.6, 0.4, 1.0)
111        self.new_game_btn.pressed.connect(self.reset)
112        root.add_child(self.new_game_btn)
113
114        self.add_child(root)
115
116    def make_move(self, row: int, col: int) -> bool:
117        """Place current player's mark. Returns True if move was valid."""
118        if self.winner is not None or self.board[row][col] is not None:
119            return False
120        self.board[row][col] = self.current_player
121        btn = self.cells[row][col]
122        btn.text = self.current_player
123        btn.text_colour = (0.3, 0.8, 1.0, 1.0) if self.current_player == "X" else (1.0, 0.4, 0.4, 1.0)
124        self._check_winner()
125        if self.winner is None:
126            self.current_player = "O" if self.current_player == "X" else "X"
127            self.status_label.text = f"Player {self.current_player}'s turn"
128        return True
129
130    def _check_winner(self):
131        b = self.board
132        lines = [
133            # Rows
134            [(0, 0), (0, 1), (0, 2)],
135            [(1, 0), (1, 1), (1, 2)],
136            [(2, 0), (2, 1), (2, 2)],
137            # Columns
138            [(0, 0), (1, 0), (2, 0)],
139            [(0, 1), (1, 1), (2, 1)],
140            [(0, 2), (1, 2), (2, 2)],
141            # Diagonals
142            [(0, 0), (1, 1), (2, 2)],
143            [(0, 2), (1, 1), (2, 0)],
144        ]
145        for line in lines:
146            vals = [b[r][c] for r, c in line]
147            if vals[0] is not None and vals[0] == vals[1] == vals[2]:
148                self.winner = vals[0]
149                self.status_label.text = f"Player {self.winner} wins!"
150                self.status_label.text_colour = (0.2, 1.0, 0.4, 1.0)
151                self.game_over()
152                return
153        # Check draw
154        if all(b[r][c] is not None for r in range(3) for c in range(3)):
155            self.winner = "draw"
156            self.status_label.text = "It's a draw!"
157            self.status_label.text_colour = (1.0, 1.0, 0.4, 1.0)
158            self.game_over()
159
160    def reset(self):
161        """Reset the board for a new game."""
162        self.board = [[None] * 3 for _ in range(3)]
163        self.current_player = "X"
164        self.winner = None
165        for row in range(3):
166            for col in range(3):
167                self.cells[row][col].text = ""
168        self.status_label.text = f"Player {self.current_player}'s turn"
169        self.status_label.text_colour = (0.7, 0.9, 1.0, 1.0)
170
171
172_SCREEN_W = 400
173_SCREEN_H = 550
174
175
176class TicTacToeApp(Node):
177    """Root node: manages menu and game scenes, tracks scores.
178
179    State machine:
180        menu → game → result → menu (loop)
181    """
182
183    def __init__(self, **kw):
184        super().__init__(**kw)
185        self.name = "TicTacToeApp"
186        self.scores = None
187        self.state = "menu"  # "menu" | "game" | "result"
188        self.menu = None
189        self.game: TicTacToeGame | None = None
190        self._result_ui: Node | None = None
191
192    def ready(self):
193        self.scores = ScoreBoard()
194        self._show_menu()
195
196    def _show_menu(self):
197        self.state = "menu"
198        self._clear_game()
199        self._clear_result()
200        self.menu = MainMenu(self.scores, _SCREEN_W, _SCREEN_H)
201        self.menu.play_pressed.connect(self._start_game)
202        self.menu.quit_pressed.connect(self._quit)
203        self.add_child(self.menu)
204
205    def _start_game(self):
206        self.state = "game"
207        if self.menu:
208            self.menu.destroy()
209            self.menu = None
210        self.game = TicTacToeGame()
211        self.game.game_over.connect(self._on_game_over)
212        self.add_child(self.game)
213
214    def _on_game_over(self):
215        if not self.game:
216            return
217        self.scores.record(self.game.winner if self.game.winner != "draw" else None)
218        self.state = "result"
219        self._show_result_overlay()
220
221    def _show_result_overlay(self):
222        self._result_ui = Node(name="ResultOverlay")
223
224        layout = VBoxContainer(name="ResultLayout")
225        layout.position = Vec2((_SCREEN_W - 360) // 2, _SCREEN_H // 2 - 30)
226        layout.size = Vec2(360, 140)
227        layout.separation = 10
228
229        # Play Again
230        again = Button("Play Again", name="PlayAgain")
231        again.size = Vec2(360, 45)
232        again.font_size = 18.0
233        again.bg_colour = (0.15, 0.35, 0.2, 1.0)
234        again.hover_colour = (0.2, 0.45, 0.3, 1.0)
235        again.pressed_colour = (0.1, 0.25, 0.15, 1.0)
236        again.border_colour = (0.3, 0.6, 0.4, 1.0)
237        again.pressed.connect(self._play_again)
238        layout.add_child(again)
239
240        # Back to Menu
241        back = Button("Back to Menu", name="BackToMenu")
242        back.size = Vec2(360, 40)
243        back.font_size = 16.0
244        back.bg_colour = (0.15, 0.15, 0.2, 1.0)
245        back.hover_colour = (0.2, 0.2, 0.3, 1.0)
246        back.pressed_colour = (0.1, 0.1, 0.15, 1.0)
247        back.border_colour = (0.3, 0.3, 0.4, 1.0)
248        back.pressed.connect(self._show_menu)
249        layout.add_child(back)
250
251        self._result_ui.add_child(layout)
252        self.add_child(self._result_ui)
253
254    def _play_again(self):
255        self._clear_result()
256        self._clear_game()
257        self._start_game()
258
259    def _clear_game(self):
260        if self.game:
261            self.game.destroy()
262            self.game = None
263
264    def _clear_result(self):
265        if self._result_ui:
266            self._result_ui.destroy()
267            self._result_ui = None
268
269    def _quit(self):
270        self.app.quit()
271
272
273if __name__ == "__main__":
274    App(title="Tic Tac Toe", width=_SCREEN_W, height=_SCREEN_H).run(TicTacToeApp())