DEV Community

Cover image for I Built Tic-Tac-Toe the Old Way — Before Coding Agents
Satyam Shree
Satyam Shree

Posted on

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

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

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
)
Enter fullscreen mode Exit fullscreen mode

And:

type Board struct {
    board [][]boardStatus
}
Enter fullscreen mode Exit fullscreen mode

Creating the board:

func NewBoard() Board {
    board := make([][]boardStatus, 3)

    for i := range 3 {
        board[i] = make([]boardStatus, 3)
    }

    return Board{
        board: board,
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The manager became responsible for things like:

CreateGame()
GetGame()
Enter fullscreen mode Exit fullscreen mode

The HTTP layer doesn't need to know how games are stored.

It just asks:

game, err := h.gameManager.GetGame(gameID)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

One thing I liked here was being able to use method-aware routes directly:

"POST /games/{gameId}/moves"
Enter fullscreen mode Exit fullscreen mode

instead of having one handler manually inspect:

r.Method
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

how does Player 2 know?

They could repeatedly call:

GET /games/{gameId}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The important thing I learned is that a WebSocket starts as an HTTP request and then gets upgraded into a persistent connection.

Conceptually:

HTTP Request
     │
     ▼
WebSocket Upgrade
     │
     ▼
Persistent Connection
     │
     ├── Server → Client
     └── Client → Server
Enter fullscreen mode Exit fullscreen mode

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.

Something conceptually like:

type ConnectionManager struct {
    connections map[string][]*websocket.Conn
}
Enter fullscreen mode Exit fullscreen mode

The key is the gameID.

So:

game-123
   ├── Player 1 connection
   └── Player 2 connection

game-456
   ├── Player 1 connection
   └── Player 2 connection
Enter fullscreen mode Exit fullscreen mode

And then:

func (cm *ConnectionManager) Broadcast(gameID string, message any) {
    // send message to all connections for this game
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

or:

player moved
Enter fullscreen mode Exit fullscreen mode

or:

game over
Enter fullscreen mode Exit fullscreen mode

It should just deliver the message.


any or a common WebSocket message?

I also had to think about how WebSocket messages should be represented.

One option:

func (cm *ConnectionManager) Broadcast(
    gameID string,
    message any,
)
Enter fullscreen mode Exit fullscreen mode

That keeps the connection manager generic.

Then the caller can send:

cm.Broadcast(gameID, message)
Enter fullscreen mode Exit fullscreen mode

Another option is to define a common envelope:

type WSMessage struct {
    Type string `json:"type"`
    Data any    `json:"data"`
}
Enter fullscreen mode Exit fullscreen mode

So the client receives:

{
    "type": "game_update",
    "data": {
        "currentPlayer": "player2"
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

and handlers:

func (h *GameHandler) Move(
    w http.ResponseWriter,
    r *http.Request,
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

instead of:

Prompt
   ↓
Generated code
   ↓
Run
   ↓
Fix generated code
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)