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
  3[Play Demo](/demos/game_tictactoe.html)
  4
  5A classic two-player Tic Tac Toe built entirely with SimVX UI widgets.
  6Demonstrates buttons, grids, labels, signals, and game state management
  7without any custom draw code.
  8
  9## What You Will Learn
 10
 11- **UI widgets** -- Build interfaces with `Button`, `Label`, `GridContainer`, `VBoxContainer`
 12- **Signal connections** -- Wire button presses to game logic with `btn.pressed.connect()`
 13- **GridContainer** -- Automatic grid layout for the 3x3 board
 14- **Dynamic UI updates** -- Change button text, colours, and label content at runtime
 15- **Game reset** -- Clear and reinitialise UI state
 16
 17## Controls
 18
 19Click any empty cell to place X or O. Click "New Game" to reset.
 20
 21## How It Works
 22
 23`TicTacToeGame` builds the UI in `ready()`:
 24
 251. A `VBoxContainer` holds the title, status label, game grid, and reset button
 262. A `GridContainer` with `columns=3` contains 9 `Button` widgets for the cells
 273. Each button's `pressed` signal connects to `make_move(row, col)` using a lambda
 284. `make_move()` places the current player's mark, updates the button's text and
 29   colour, then calls `_check_winner()` to scan rows, columns, and diagonals
 305. A win or draw updates the status label and emits the `game_over` signal
 31"""
 32
 33
 34from simvx.core import (
 35    Button,
 36    GridContainer,
 37    Label,
 38    Node,
 39    Signal,
 40    VBoxContainer,
 41)
 42from simvx.core.math.types import Vec2
 43from simvx.core.ui.core import Colour
 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        from menu import ScoreBoard
194
195        self.scores = ScoreBoard()
196        self._show_menu()
197
198    def _show_menu(self):
199        from menu import MainMenu
200
201        self.state = "menu"
202        self._clear_game()
203        self._clear_result()
204        self.menu = MainMenu(self.scores, _SCREEN_W, _SCREEN_H)
205        self.menu.play_pressed.connect(self._start_game)
206        self.menu.quit_pressed.connect(self._quit)
207        self.add_child(self.menu)
208
209    def _start_game(self):
210        self.state = "game"
211        if self.menu:
212            self.menu.destroy()
213            self.menu = None
214        self.game = TicTacToeGame()
215        self.game.game_over.connect(self._on_game_over)
216        self.add_child(self.game)
217
218    def _on_game_over(self):
219        if not self.game:
220            return
221        self.scores.record(self.game.winner if self.game.winner != "draw" else None)
222        self.state = "result"
223        self._show_result_overlay()
224
225    def _show_result_overlay(self):
226        self._result_ui = Node(name="ResultOverlay")
227
228        layout = VBoxContainer(name="ResultLayout")
229        layout.position = Vec2((_SCREEN_W - 360) // 2, _SCREEN_H // 2 - 30)
230        layout.size = Vec2(360, 140)
231        layout.separation = 10
232
233        # Play Again
234        again = Button("Play Again", name="PlayAgain")
235        again.size = Vec2(360, 45)
236        again.font_size = 18.0
237        again.bg_colour = (0.15, 0.35, 0.2, 1.0)
238        again.hover_colour = (0.2, 0.45, 0.3, 1.0)
239        again.pressed_colour = (0.1, 0.25, 0.15, 1.0)
240        again.border_colour = (0.3, 0.6, 0.4, 1.0)
241        again.pressed.connect(self._play_again)
242        layout.add_child(again)
243
244        # Back to Menu
245        back = Button("Back to Menu", name="BackToMenu")
246        back.size = Vec2(360, 40)
247        back.font_size = 16.0
248        back.bg_colour = (0.15, 0.15, 0.2, 1.0)
249        back.hover_colour = (0.2, 0.2, 0.3, 1.0)
250        back.pressed_colour = (0.1, 0.1, 0.15, 1.0)
251        back.border_colour = (0.3, 0.3, 0.4, 1.0)
252        back.pressed.connect(self._show_menu)
253        layout.add_child(back)
254
255        self._result_ui.add_child(layout)
256        self.add_child(self._result_ui)
257
258    def _play_again(self):
259        self._clear_result()
260        self._clear_game()
261        self._start_game()
262
263    def _clear_game(self):
264        if self.game:
265            self.game.destroy()
266            self.game = None
267
268    def _clear_result(self):
269        if self._result_ui:
270            self._result_ui.destroy()
271            self._result_ui = None
272
273    def _quit(self):
274        import sys as _sys
275
276        _sys.exit(0)
277
278
279if __name__ == "__main__":
280    App(title="Tic Tac Toe", width=_SCREEN_W, height=_SCREEN_H).run(TicTacToeApp())