I Built Tic-Tac-Toe the Old Way - Before Coding Agents
What I did. I recently built a multiplayer Tic-Tac-Toe backend in Go. At first, it sounds almost too simple to be worth writing about. It has a 3×3 board. Two players. Some HTTP endpoints. A WebSocket connection. Done. But somewhere along the way, a tiny game turned into a surprisingly good exercise in backend engineering. And I deliberately built this one the old way . No coding agent writing the implementation. No prompt → generate → test → fix loop. I wanted to sit with the problem, make the design decisions myself, write the code, break it, debug it, and understand why every piece existed. That was the actual point of the project.
The final result
The frontend ended up looking much nicer than the backend deserves. ๐ The important part for me, though, was everything happening behind that board. The server handles:
- Creating games
- Joining games
- Starting games
- Making moves
- Validating moves
- Checking winners
- Managing turns
- Maintaining game state
- Managing WebSocket connections
- Broadcasting game updates
API endpoints
The API roughly looks like:
- POST /games
- GET /games/{gameId}
- POST /games/{gameId}/join
- POST /games/{gameId}/start
- POST /games/{gameId}/moves
- GET /games/{gameId}/ws
The last endpoint was where things got interesting.
Why Tic-Tac-Toe?
Because I didn't want the problem itself to be difficult. When learning backend architecture, it's easy to pick a complicated project and then spend most of your time fighting the domain. I wanted something where the rules could fit in my head.
Board ↓
Players ↓
Turns ↓
Moves ↓
Winner
That leaves enough mental space to think about the engineering around it. How should the game be represented? Who owns the state? Who validates a move? How do multiple games exist at the same time? How does a player connect to a game? How do both players know when something changed? Those questions were more interesting to me than Tic-Tac-Toe itself.
Starting with the simplest model
The first thing I needed was a board. Nothing fancy.
type boardStatus int
const (
emptyCell boardStatus = iota
player1
player2
)
type Board struct {
board [][]boardStatus
}
func NewBoard() Board {
board := make ([][]boardStatus , 3 )
for i := range 3 {
board [ i ] = make ([]boardStatus , 3 )
}
return Board { board : board , }
}
The interesting part wasn't creating a 3×3 array. It was deciding where the responsibility for the board should live. I didn't want HTTP handlers deciding whether a move was valid. The game should know its own rules. So the direction became:
HTTP Handler
↓
GameManager
↓
Game
↓
Board
The handler deals with HTTP. The manager deals with games. The game deals with game rules. That separation made the code much easier to reason about.
The GameManager
Once I had one game, the next obvious problem was: What happens when there are 100 games? The answer wasn't to make the handler keep track of them. I introduced a GameManager. Conceptually:
GameManager
├── Game A
├── Game B
├── Game C
└── Game D
The manager became responsible for things like:
- CreateGame()
- GetGame()
The HTTP layer doesn't need to know how games are stored. It just asks:
game, err := h.gameManager.GetGame(gameID)
That small separation is something I probably wouldn't have appreciated as much without actually building it.
Then came HTTP
Go's net/http made the API surprisingly simple.
mux := http.NewServeMux()
mux.HandleFunc("POST /games", handler.CreateGame)
mux.HandleFunc("GET /games/{gameId}", handler.GetGame)
mux.HandleFunc("POST /games/{gameId}/join", handler.JoinGame)
mux.HandleFunc("POST /games/{gameId}/start", handler.StartGame)
mux.HandleFunc("POST /games/{gameId}/moves", handler.Move)
mux.HandleFunc("GET /games/{gameId}/ws", handler.WebSocket)
One thing I liked here was being able to use method-aware routes directly: "POST /games/{gameId}/moves" instead of having one handler manually inspect r.Method. The API started to feel like an actual backend rather than a collection of functions.
Designing the game rules
This is where simple problems are deceptive. A move isn't just:
board[row][column] = player
You need to answer:
- Does the game exist?
- Has the game started?
- Is the game already finished?
- Is this actually the player's turn?
- Is the position already occupied?
- Is the requested position valid?
- Did this move win the game?
- Is the board full?
Those rules belong to the domain. Not inside the HTTP handler. For example:
func (g *Game) CheckWinner() (string, error) {
// check rows
// check columns
// check diagonals
}
That led to one of the biggest lessons from this project: A handler shouldn't know how your business works. It should know how to talk to your business logic.
Then I wanted real-time updates
The HTTP API worked. I could make a move. But there was an obvious problem. If Player 1 makes a move:
Player 1 │ │ POST /moves โผ Server
how does Player 2 know? They could repeatedly call:
GET /games/{gameId}
but that's polling. I wanted the server to say:
Something changed. Here's the new state.
That's where WebSockets came in.
Understanding WebSockets instead of just using them
The WebSocket endpoint became:
GET /games/{gameId}/ws
The important thing I learned is that a WebSocket starts as an HTTP request and then gets upgraded into a persistent connection.
HTTP Request
│
โผ
WebSocket Upgrade
│
โผ
Persistent Connection
├── Server → Client
└── Client → Server
Once upgraded, the server can keep the connection alive and send messages whenever something happens.
ConnectionManager
This created another responsibility. The Game shouldn't know about TCP connections. The GameManager shouldn't know about WebSocket connections. So I created a ConnectionManager.
type ConnectionManager struct {
connections map[string][]*websocket.Conn
}
The key is the gameID. So:
game-123
├── Player 1 connection
└── Player 2 connection
game-456
├── Player 1 connection
└── Player 2 connection
And then:
func (cm *ConnectionManager) Broadcast(gameID string, message any) {
// send message to all connections for this game
}
This was one of those design decisions that seems obvious after you arrive at it. But getting there yourself is the useful part. The connection manager shouldn't care whether the message means:
- game started
- player moved
- game over
It should just deliver the message.
Message representation
One option:
func (cm *ConnectionManager) Broadcast(gameID string, message any) {
// ...
}
That keeps the connection manager generic. Then the caller can send:
cm.Broadcast(gameID, message)
Another option is to define a common envelope:
type WSMessage struct {
Type string `json:"type"`
Data any `json:"data"`
}
So the client receives:
{
"type": "game_update",
"data": {
"currentPlayer": "player2"
}
}
I liked this approach because the transport layer stays generic while the client still has a predictable event format.
The architecture started to emerge
At this point the project roughly looked like:
HTTP
โผ
┌─────────────┐
│ GameHandler │
└──────┬──────┘
โผ
┌─────────────┐
│ GameManager │
└──────┬──────┘
โผ
┌─────┐
│Game │
└─────┘
│ │ state changed
โผ
┌──────────────┐
│ Connection │
│ Manager│
└──────┬───────┘
│
┌──────┴──────┐
โผ โผ
Player 1 Player 2
WebSocket WebSocket
That's probably the most valuable thing I got from the project. Not Tic-Tac-Toe. Not WebSockets. Learning to assign responsibilities.
Things that broke
Of course, it wasn't this clean while I was writing it. There were plenty of moments where I had to stop and ask: Who should actually be responsible for this? For example:
- Should the handler validate the game ID?
- Should the GameManager know about WebSockets?
- Should the Game know about connections?
- Where should winner detection happen?
- Who removes a disconnected WebSocket?
- What happens when a WebSocket dies?
- Should messages be strongly typed?
- What exactly should be broadcast?
- Should the client receive the entire game state or only the event?
These aren't syntax problems. They're design problems. And they're much harder to solve by memorizing Go syntax.
What I learned about Go
This project also gave me a much better feel for some Go fundamentals.
net/http
I got more comfortable with:
-
http.NewServeMux() - handlers:
func (h *GameHandler) Move(w http.ResponseWriter, r *http.Request)
Structs and composition
Instead of creating giant objects, I could make small components with focused responsibilities.
Errors
I started thinking more carefully about where errors should be created and where they should be translated into HTTP responses. For example, a domain error doesn't necessarily need to know anything about http.StatusBadRequest. The HTTP layer can make that decision.
Maps
Managing games and connections naturally led to maps.
Pointers
WebSocket connections and mutable game state made pointer semantics much more relevant.
Concurrency
This is probably the next area I want to go deeper into. A real multiplayer server means multiple requests can interact with the same game state. That immediately raises questions around:
- Race conditions
- Mutexes
- Concurrent map access
- Connection lifecycle
- Goroutines
And that's where a "toy" game starts becoming a useful systems exercise.
Why I didn't use a coding agent
This is probably the most important part of the project for me. We are in a time where you can describe:
"Build me a multiplayer Tic-Tac-Toe server in Go with WebSockets"
and get a surprisingly large amount of working code. That's incredibly useful. But I wanted to experience something different. I wanted to sit down with the problem and do:
Problem ↓
Think ↓
Design ↓
Write ↓
Compile ↓
Break ↓
Debug ↓
Understand ↓
Refactor
instead of:
Prompt ↓
Generated code ↓
Run ↓
Fix generated code
I didn't use an AI coding agent to implement this project. I wrote it myself. Not because I think using AI is bad. Quite the opposite. AI coding tools are going to be an important part of how software gets built. But I think there's value in knowing what it feels like to build something before handing the implementation over to an agent. Because when you eventually use an agent, understanding the underlying system changes the questions you ask it. You can look at generated code and ask:
"Why is this connection manager responsible for that?"
instead of:
"Does this compile?"
That distinction matters.
The uncomfortable part of building without an agent
The old way is slower. There's no point pretending otherwise. You spend 20 minutes figuring out something that an agent could probably produce in 20 seconds. You forget an API. You make stupid mistakes. You stare at an error. You rewrite something you thought was finished. You search documentation. You write a bad implementation and then realize why it's bad. But those mistakes are also where some of the learning happens. The first time you personally run into a race condition, "concurrency" stops being a topic from a textbook. The first time your WebSocket connection disappears unexpectedly, connection lifecycle becomes real. The first time your handler starts becoming 200 lines long, separation of concerns stops being an architectural buzzword.
What I would change next
This is still a small project. There are plenty of things I'd improve.
- Persistence: Currently the game state is in memory. A server restart means goodbye games. Eventually: PostgreSQL / Redis could be introduced depending on the requirements.
- Authentication: Players are currently identified at a very basic level. A real game would need player identity and authorization.
- Reconnection: What happens if someone closes their laptop for 10 seconds and comes back? The game shouldn't necessarily be over.
- Concurrency: The game state needs proper synchronization when multiple requests can modify it concurrently.
- WebSocket lifecycle: I'd like to make connection registration/removal and dead connection handling more robust.
- Tests: The game rules are a perfect candidate for unit tests. Things like:
- horizontal win
- vertical win
- diagonal win
- draw
- invalid move
- wrong player's turn
- game already finished
should all be tested.
What started as Tic-Tac-Toe
The funny thing is that I didn't really build Tic-Tac-Toe. I built a tiny environment where I could practice:
- API design
- Domain modeling
- Separation of concerns
- State management
- WebSockets
- Event broadcasting
- Error handling
- Concurrency
- Go
- Debugging
- Making architectural decisions
And that's probably why I enjoyed it. The game is simple. The engineering isn't.
Final thought
There is a temptation right now to measure productivity by how quickly we can get from an idea to working code. And AI makes that distance incredibly small. But sometimes I think it's worth deliberately taking the long route. Build something. Get stuck. Read the documentation. Make the wrong abstraction. Delete it. Build it again. Understand why it works. Then, when you bring an AI coding agent into the process later, you're not just someone who can generate software. You're someone who can understand software. That's a much more interesting skill to have.
Built with: Go · net/http · WebSockets
No AI coding agent was used to implement the project. If you're interested, the source code is here: ๐ GitHub repository
This is a personal learning project, and the architecture will probably evolve as I continue experimenting with it.
Comments
No comments yet. Start the discussion.