I was looking for some projects that would be a good demonstration of how AI can automate or solve problems just like humans. Yesterday, I was playing a tic tac toe game with my wife and I just thought this could be a very good starting point for it. So here I’m sitting at my desk trying to build one.
But plain old command line tic-tac-toe is not fun. You should be able to click on the squares and see it in real time. After all that’s the game experience. And for that I need GUI. I don’t know how to build one yet, let’s figure it out along the way.
This is my starting point:
Quick googling (yeah I’m still old fashion) and reading a post or two gave me a starting point on which library should I use for it. It seems TKinter is good to begin any GUI app. It comes with python standard library. All you need is to make sure is TKinter library is installed.
Table of Contents
IDE Setup
I use uv
to create development env.
uv init
That’s all it takes.
Then another important step is that your system should have TKinter installed. If you are on mac and use brew
simply run:
brew install python-tk
GUI Design
Obviously I will not go for anything fancy, just a simple window with a title and 9 boxes should do. I’m imagining something like this:
Let’s see how to create a window with TKinter.
This is how I can create a small window of 350x350
with a frame and a title. Isn’t it simple. I see a window with nice title and a label.
import tkinter as tk
class TicTacToeApp(tk.Tk):
def __init__(self):
super().__init__()
self.title("Tic Tac Toe")
self.geometry("350x350")
self.resizable(True, True)
self.container = tk.Frame(self, padx=12, pady=12)
self.container.pack(fill="both", expand=True)
tk.Label(self.container, text="Tic Tac Toe", font=("Helvetica", 16)).pack(pady=10)
if __name__ == "__main__":
app = TicTacToeApp()
app.mainloop()
TaDa!!!
This is looking good. Next I want to find ways to add buttons and listeners on those buttons such that if I click on the square block I should be able to do something. I’m thinking of first placing the buttons in 3×3 grid with fluent style. Let me find out how can I do that.
I tried creating a board with 9 cells with button equally spaced fluently.
class GameBoard(tk.Frame):
def __init__(self, master: tk.Misc | None = None,
cnf: dict[str, Any] | None = None, **kw: Any) -> None:
super().__init__(master=master, cnf=cnf or {}, **kw)
# 3x3 grid stretches uniformly
for i in range(3):
self.rowconfigure(i, weight=1, uniform="rows")
self.columnconfigure(i, weight=1, uniform="cols")
self.cells: list[list[tk.Button]] = []
for r in range(3):
row: list[tk.Button] = []
for c in range(3):
b = tk.Button(self, text="", bd=0, relief="flat", highlightthickness=0)
b.grid(row=r, column=c, sticky="nsew", padx=0, pady=0)
row.append(b)
self.cells.append(row)
But the window doesn’t look the way I expected, there’s a lot of space between title and buttons. Let’s see how can I fix that.
The trick is to set the container layout and then place the components in exact location. There are different ways to set your layout. The most flexible that I can relate with is the Grid Layout.
For this, I had to create a grid configuration for the main container and then place the container on a particular section of the grid. Finally something that resembles tic-tac-toe interface
This is the current state of the code
import tkinter as tk
from typing import Any
class GameBoard(tk.Frame):
def __init__(self, master: tk.Misc | None = None,
cnf: dict[str, Any] | None = None, **kw: Any) -> None:
super().__init__(master=master, cnf=cnf or {}, **kw)
# 3x3 grid stretches uniformly
for i in range(3):
self.rowconfigure(i, weight=1, uniform="rows")
self.columnconfigure(i, weight=1, uniform="cols")
self.cells: list[list[tk.Button]] = []
for r in range(3):
row: list[tk.Button] = []
for c in range(3):
b = tk.Button(self, text="", bd=0, relief="flat", highlightthickness=0)
b.grid(row=r, column=c, sticky="nsew", padx=0, pady=0)
row.append(b)
self.cells.append(row)
class TicTacToeApp(tk.Tk):
def __init__(self) -> None:
super().__init__()
self.title("Tic Tac Toe")
self.geometry("350x350")
self.resizable(True, True)
container = tk.Frame(self, padx=12, pady=12)
container.pack(fill="both", expand=True)
# Title keeps natural height; board gets the rest
container.grid_rowconfigure(0, weight=0)
container.grid_rowconfigure(1, weight=1)
container.grid_columnconfigure(0, weight=1)
tk.Label(container, text="Tic Tac Toe", font=("Helvetica", 16))\
.grid(row=0, column=0, sticky="n", pady=(0, 8))
self.board = Board(container)
self.board.grid(row=1, column=0, sticky="nsew")
if __name__ == "__main__":
app = TicTacToeApp()
app.mainloop()
Next step is to attach event listener’s on each cell and change its text to either O or X based on whose turn it is.
MVC Architecture for Application State Management
I will follow a MVC design pattern here, where views will emit events to controller and controller will manage the state of the view and render the parts that requires change. This is pretty standard and also provides a good way to separate out logic from the visual components. I find it much easier and cleaner way to build apps with GUI.
I just learned that I could add event listeners to TK component by using command
attribute. It is pretty straightforward.
Here’s what am gonna do. I will create following classes:
- GameState: This will hold the state of the game at all point in time. Any change to the state will trigger re-render on UI.
- GameController: This will be the controller that will hold all the business logic required to change the state. This will listen to button events and make the changes accordingly to the game state
- GameBoard (view): As you already saw it holds the buttons.
Let me get to work.
So after some reading, I was able to build a complete working GUI. The missing part is the AI call which I will add in next.
I started by creating a GameState
. This is the central state of the app at any point in time.
- turn: dictates who turn it is. fluctuates alternatively between “X” and “O”
- board: its a matrix of
tk.StringVar
I will explain why I need this - player_symbol: This we will decide randomly when game starts to distinguish a real player from LLM
class GameState:
turn: Literal["X", "O"] = "X"
board: list[list[tk.StringVar]] = []
player_symbol: Literal["X", "O"] = "X"
Next we need a GameController
.
The main idea is as follows:
- When “player” marks a cell, the event listener on the button will call the
play
method with the cell coordinates. - GameController will check if its empty first, and if its empty it will set the text of that cell to player’s symbol stored in the
turn
variable - Call the LLM for its turn.
The flow is pretty straightforward. But you must be thinking how would it re-render the UI on state changes. And for that TKinter provides State Variables.
You see tk.StringVar
that is the tkinter state variables. So instead of using regular variables, you will have to rely on TKinter state variables that offers automatic refresh of the UI when state changes. Therefore, in the game controller I initialize the board with these variables.
class GameController:
def __init__(self, root: tk.Misc, state: GameState) -> None:
self.root = root
state.board = [
[tk.StringVar(master=root, value="") for _ in range(3)] for _ in range(3)
]
state.player_symbol = random.choice(["X", "O"])
self.state = state
if not self.is_player_turn():
self.play_ai
def is_player_turn(self) -> bool:
return self.state.turn == self.state.player_symbol
def play_ai(self) -> None:
# Call LLM to get the response
return
def play(self, r: int, c: int) -> None:
if self.state.board[r][c].get():
return
self.state.board[r][c].set(self.state.turn)
self.state.turn = "O" if self.state.turn == "X" else "X"
self.(self.play_ai())
Another important change to the UI is that now instead of using simple string, I will have to bind the TKinter state variables to the button text. And for that you use textvariable
property like textvariable=controller.state.board[r][c]
. Here’s the update UI code. I made just one liner change to bind the state variables to display
class GameBoard(tk.Frame):
def __init__(self, controller: GameController, master: tk.Misc | None = None,
cnf: dict[str, Any] | None = None, **kw: Any) -> None:
super().__init__(master=master, cnf=cnf or {}, **kw)
for i in range(3):
self.rowconfigure(i, weight=1, uniform="rows")
self.columnconfigure(i, weight=1, uniform="cols")
self.cells: list[list[tk.Button]] = []
for r in range(3):
row: list[tk.Button] = []
for c in range(3):
b = tk.Button(
self,
textvariable=controller.state.board[r][c],
bd=0,
relief="flat",
highlightthickness=0,
command=partial(controller.play, r, c),
)
b.grid(row=r, column=c, sticky="nsew", padx=0, pady=0)
row.append(b)
self.cells.append(row)
With these changes the game works. All I need to do is integrate it with LLM.
For that I will make use of Langchain with AWS for Bedrock integration which offers me all the various models out of the box without having to manage multiple api keys for each LLM separately. Which is pretty handy if I want to let’s say compare the performance of one LLM with another. But here I will keep it super simple.
LLM Integration
I added the langchain
and langchain_aws
package using uv.
uv add langchain
uv add langchain-aws
This is not necessary as you can also use boto3
package that provides direct bedrock integration but I find langchain easier so I prefer this.
So, once you have installed the package, all I had to do is use ChatBedrock
class to call the LLM with the model of my choice.
Do not forget to replace the profile with your own. Or you could also integrate it with OpenAI whatever is feasible for you. Just make the necessary changes in the get_text_response
method.
import os
from langchain_aws import ChatBedrock
class ClaudeSonnetChatbot:
def __init__(self, temperature: float = 0, max_tokens: int = 2):
bedrock_kwargs = {
"model": "us.anthropic.claude-3-7-sonnet-20250219-v1:0",
"model_kwargs": {"temperature": temperature, "max_tokens": max_tokens},
"region": os.getenv("AWS_REGION", "us-east-1"),
}
bedrock_kwargs["credentials_profile_name"] = os.getenv("AWS_PROFILE")
self.llm = ChatBedrock(**bedrock_kwargs)
def get_text_response(self, prompt: str) -> str:
response = self.llm.invoke(prompt)
return response.text()
With this I was able to call the LLM and get back the response. The next thing was to make LLM give the correct response and in the format I want. Since all I need from LLM in this game is the next set of coordinates e.g. Row and Col so I would keep the response format pretty simple.
For that I used the following Prompt Template:
def generate_prompt(ai_symbol: str, board_state: list[list[str]]) -> str:
return f"""
You are a tic-tac-toe playing AI. You are playing as {ai_symbol}.
The board is represented as a 3x3 grid with rows and columns indexed from 0 to 2. The current state of the board is as follows:
{board_state}
It is your turn now. Please respond with your move in the format "row,column" (without quotes), where row and column are integers between 0 and 2.
For example, if you want to place your symbol in the top-left corner, you would respond with "0,0".
CRITICAL: DO NOT RESPOND WITH ANY OTHER TEXT. ONLY RESPOND WITH THE MOVE IN THE SPECIFIED FORMAT.
"""
Here I pass the AI Symbol so it knows what its play (as LLMs are stateless, every new request is like a new one so you need to provide the context) and the entire game board state. So it knows where it wants to place next. With this prompt I saw it worked correctly 100% of the times. Feel free to play around with the prompt to see how it changes the behaviour.
Now, I need to hook this LLM to my game controller.
from functools import partial
import random
import tkinter as tk
from typing import Any, Literal
from factories import ClaudeSonnetChatbot, generate_prompt
llm = ClaudeSonnetChatbot(temperature=6, max_tokens=1024)
class GameState:
turn: Literal["X", "O"] = "X"
board: list[list[tk.StringVar]] = []
player_symbol: Literal["X", "O"] = "X"
class GameController:
def __init__(self, root: tk.Misc, state: GameState) -> None:
self.root = root
state.board = [
[tk.StringVar(master=root, value="") for _ in range(3)] for _ in range(3)
]
state.player_symbol = random.choice(["X", "O"])
self.state = state
if not self.is_player_turn():
self.root.after_idle(self.play_ai)
def is_player_turn(self) -> bool:
return self.state.turn == self.state.player_symbol
def play_ai(self) -> None:
response = llm.get_text_response(prompt=generate_prompt(
ai_symbol="O" if self.state.player_symbol == "X" else "X",
board_state=[[cell.get() for cell in row] for row in self.state.board]
))
rr,cc = response.split(",")
self.state.board[int(rr)][int(cc)].set(self.state.turn)
self.state.turn = "O" if self.state.turn == "X" else "X"
def play(self, r: int, c: int) -> None:
if self.state.board[r][c].get():
return
self.state.board[r][c].set(self.state.turn)
self.state.turn = "O" if self.state.turn == "X" else "X"
self.root.after_idle(self.play_ai)
With this I had a complete game to play with the AI.
However, I noticed that when I click the cell, it blocks the thread and then calls the LLM without refreshing the UI. That kinda gives a bad experience as both you and AI turn shows up immediately. What I will have to do is run the AI on a separate thread and make it non-blocking. Let me do that and come back.
Make AI Move Non-Blocking
I moved the AI move to a non-blocking thread. That way it will take place in the background, thus, solving that sticky problem from earlier. After this change the game feels more natural and fluid. Here’s the change I made to the play_ai
method.
def play_ai(self) -> None:
if self._ai_busy:
return
def _apply_ai_move(r: int, c: int) -> None:
self.state.board[r][c].set(self.state.turn)
self.state.turn = "O" if self.state.turn == "X" else "X"
self._ai_busy = False
def _ai_worker() -> None:
response = llm.get_text_response(prompt=generate_prompt(
ai_symbol="O" if self.state.player_symbol == "X" else "X",
board_state=[[cell.get() for cell in row] for row in self.state.board]
))
rr_str, cc_str = response.split(",")
rr, cc = int(rr_str), int(cc_str)
self.root.after(0, _apply_ai_move, rr, cc)
self._ai_busy = True
threading.Thread(target=_ai_worker, daemon=True).start()
Highlighting the Cell
This is another important thing for aesthetics. We would want to display the line that has won by highlighting it. For that I’m thinking about keeping a reference of the game board in controller. So whenever controller says highlight(line)
and provide the line, the GameBoard should highlight that line. Also, we would add a clear highlight method as well to reset it to original.
class GameBoard(tk.Frame):
...
...
...
def highlight_line(self, coord: list[tuple[int, int]], color: str = "#b5f5b5") -> None:
for r, c in coord:
btn = self.cells[r][c]
btn.configure(highlightthickness=3, highlightbackground=color, highlightcolor=color)
def clear_highlight(self) -> None:
for row in self.cells:
for btn in row:
btn.configure(highlightthickness=0)
Identifying Winner or Draw
Now we have come to the closing that will make the game complete. For that we need to make the game alert when someone has Won or the game is a Draw. That is exactly what we will build in this section.
For this we need to run a piece of code after every move from AI or Player to check the state of board to identify if there’s any winner or the game is Draw. As soon as I find that state I should pop open a dialog box to the user with the message (state) of the board. We could also put a button for “Play again” or simply exit.
What are the winning conditions in Tic Tac Toe?
Well any horizontal line, vertical line or diagonal lines are winning (see the image below).
We run the same logic to check the state of the board every time.
Here, it builds all 8 winning triplets, then checks each triplet for three equal, non-empty marks. Returns the first matching triplet of coordinates (if any). Pretty simple and straightforward.
def _winning_line() -> Optional[list[tuple[int, int]]]:
lines = (
[[(r, 0), (r, 1), (r, 2)] for r in range(3)] +
[[(0, c), (1, c), (2, c)] for c in range(3)] +
[[(0, 0), (1, 1), (2, 2)],
[(0, 2), (1, 1), (2, 0)]]
)
g = lambda r, c: self.state.board[r][c].get()
for line in lines:
a, b, c = line
va, vb, vc = g(*a), g(*b), g(*c)
if va and va == vb == vc:
return line
return None
Well, the above code checks the winning condition. But how do we check if its a draw? Well if all cells are filled and there is no winner it means Draw. That’s what we will check next
def _no_more_moves() -> bool:
return all(cell.get() for row in self.state.board for cell in row)
Combining both of the above function together and we get our end condition check. Therefore, this is the complete method to check end conditions after every move. Here I will also call highlight(line)
function from GameBoard
to highlight the winning lines.
def _end_if_done(self) -> None:
def _no_more_moves() -> bool:
return all(cell.get() for row in self.state.board for cell in row)
def _winning_line() -> Optional[list[tuple[int, int]]]:
lines = (
[[(r, 0), (r, 1), (r, 2)] for r in range(3)] +
[[(0, c), (1, c), (2, c)] for c in range(3)] +
[[(0, 0), (1, 1), (2, 2)],
[(0, 2), (1, 1), (2, 0)]]
)
g = lambda r, c: self.state.board[r][c].get()
for line in lines:
a, b, c = line
va, vb, vc = g(*a), g(*b), g(*c)
if va and va == vb == vc:
return line
return None
line = _winning_line()
if line:
if self.board is not None:
self.board.highlight_line(line)
self.root.update_idletasks()
r0, c0 = line[0]
winner = self.state.board[r0][c0].get()
# schedule dialog so highlight paints first
self.root.after(0, lambda: (self.reset() if messagebox.askyesno("Game over", f"{winner} wins. Play again?") else None))
return
if _no_more_moves():
self.root.after(0, lambda: (self.reset() if messagebox.askyesno("Game over", "Draw. Play again?") else None))
After putting everything in place, here is the final code.
from functools import partial
import random
import re
import threading
import tkinter as tk
from tkinter import messagebox
from typing import Any, Literal, Optional
from factories import ClaudeSonnetChatbot, generate_prompt
llm = ClaudeSonnetChatbot(temperature=0.7, max_tokens=1024)
def extract_tag_content(text: str, tag: str) -> list[str]:
"""
Extracts all text contents inside provided tag.
Works with both <tag>content</tag> style tags.
"""
if text is None:
return []
esc = re.escape(tag)
pattern = rf"<{esc}>(.*?)</{esc}>"
matches = re.findall(pattern, text, flags=re.DOTALL)
return matches
class GameState:
turn: Literal["X", "O"] = "X"
board: list[list[tk.StringVar]] = []
player_symbol: Literal["X", "O"] = "X"
class GameController:
def __init__(self, root: tk.Misc, state: GameState) -> None:
self.root = root
self._ai_busy = False
self.board = None # will be set by attach_board
state.board = [[tk.StringVar(master=root, value="") for _ in range(3)] for _ in range(3)]
state.player_symbol = random.choice(["X", "O"])
self.state = state
if not self.is_player_turn():
self.root.after_idle(self.play_ai)
def is_player_turn(self) -> bool:
return self.state.turn == self.state.player_symbol
def play_ai(self) -> None:
if self._ai_busy:
return
def _apply_ai_move(r: int, c: int) -> None:
self.state.board[r][c].set(self.state.turn)
self.state.turn = "O" if self.state.turn == "X" else "X"
self._ai_busy = False
self._end_if_done()
def _ai_worker() -> None:
response = llm.get_text_response(prompt=generate_prompt(
ai_symbol="O" if self.state.player_symbol == "X" else "X",
board_state=[[cell.get() for cell in row] for row in self.state.board]
))
move = extract_tag_content(response, "move")
assert len(move) == 1
rr_str, cc_str = move[0].split(",")
rr, cc = int(rr_str), int(cc_str)
self.root.after(0, _apply_ai_move, rr, cc)
self._ai_busy = True
threading.Thread(target=_ai_worker, daemon=True).start()
def reset(self) -> None:
if self.board is not None:
self.board.clear_highlight()
for row in self.state.board:
for v in row:
v.set("")
self.state.turn = "X"
self.state.player_symbol = random.choice(["X", "O"])
if not self.is_player_turn():
self.play_ai()
def _end_if_done(self) -> None:
def _no_more_moves() -> bool:
return all(cell.get() for row in self.state.board for cell in row)
def _winning_line() -> Optional[list[tuple[int, int]]]:
lines = (
[[(r, 0), (r, 1), (r, 2)] for r in range(3)] +
[[(0, c), (1, c), (2, c)] for c in range(3)] +
[[(0, 0), (1, 1), (2, 2)],
[(0, 2), (1, 1), (2, 0)]]
)
g = lambda r, c: self.state.board[r][c].get()
for line in lines:
a, b, c = line
va, vb, vc = g(*a), g(*b), g(*c)
if va and va == vb == vc:
return line
return None
line = _winning_line()
if line:
if self.board is not None:
self.board.highlight_line(line)
self.root.update_idletasks()
r0, c0 = line[0]
winner = self.state.board[r0][c0].get()
# schedule dialog so highlight paints first
self.root.after(0, lambda: (self.reset() if messagebox.askyesno("Game over", f"{winner} wins. Play again?") else None))
return
if _no_more_moves():
self.root.after(0, lambda: (self.reset() if messagebox.askyesno("Game over", "Draw. Play again?") else None))
def play(self, r: int, c: int) -> None:
if self._ai_busy:
return
if self.state.board[r][c].get():
return
self.state.board[r][c].set(self.state.turn)
self.state.turn = "O" if self.state.turn == "X" else "X"
self._end_if_done()
self.root.after_idle(self.play_ai)
def attach_board(self, board: "GameBoard") -> None:
self.board = board
class GameBoard(tk.Frame):
def __init__(self, controller: GameController, master: tk.Misc | None = None,
cnf: dict[str, Any] | None = None, **kw: Any) -> None:
super().__init__(master=master, cnf=cnf or {}, **kw)
for i in range(3):
self.rowconfigure(i, weight=1, uniform="rows")
self.columnconfigure(i, weight=1, uniform="cols")
self.cells: list[list[tk.Button]] = []
for r in range(3):
row: list[tk.Button] = []
for c in range(3):
b = tk.Button(
self,
textvariable=controller.state.board[r][c],
bd=0,
relief="flat",
highlightthickness=0,
command=partial(controller.play, r, c),
)
b.grid(row=r, column=c, sticky="nsew", padx=0, pady=0)
row.append(b)
self.cells.append(row)
controller.attach_board(self)
def highlight_line(self, coord: list[tuple[int, int]], color: str = "#b5f5b5") -> None:
for r, c in coord:
btn = self.cells[r][c]
btn.configure(highlightthickness=3, highlightbackground=color, highlightcolor=color)
def clear_highlight(self) -> None:
for row in self.cells:
for btn in row:
btn.configure(highlightthickness=0)
class TicTacToeApp(tk.Tk):
def __init__(self) -> None:
super().__init__()
self.title("Tic Tac Toe")
self.geometry("350x350")
self.resizable(True, True)
self.controller = GameController(root=self, state=GameState())
container = tk.Frame(self, padx=12, pady=12)
container.pack(fill="both", expand=True)
container.grid_rowconfigure(0, weight=0)
container.grid_rowconfigure(1, weight=1)
container.grid_columnconfigure(0, weight=1)
tk.Label(container, text="Tic Tac Toe", font=("Helvetica", 16))\
.grid(row=0, column=0, sticky="n", pady=(0, 8))
self.board = GameBoard(controller=self.controller, master=container)
self.board.grid(row=1, column=0, sticky="nsew")
if __name__ == "__main__":
app = TicTacToeApp()
app.mainloop()
Now let’s play the game and see it in action.
Let’s try one more time…
Cool! its working as expected. It highlights the winning cells and also shows the dialog box asking to play again with correct message.
With little bit of prompt engineering we could turn up the difficulty so that it will not lose easily. COT Prompting is one quick way.
Well, that’s all for today. Until next time. Will build something more complex like a chess.