ULTIMATE_TIC_TAC_TOE
An online multiplayer Ultimate Tic-Tac-Toe game with a Go WebSocket backend, Next.js frontend, real-time board broadcasting, spectator support, in-game chat, and a single-player mode against a random AI opponent.
The Problem
Ultimate Tic-Tac-Toe (also called Super Tic-Tac-Toe) is a two-player game built on top of the classic 3x3 grid. The meta-board is a 3x3 arrangement of smaller 3x3 tic-tac-toe boards. You win by claiming three small boards in a row on the meta-board. The catch is the routing rule: wherever you play within a small board determines which small board your opponent must play in next. If you play in the top-right cell of your current board, your opponent’s next move must be made on the top-right board of the meta-grid. That constraint is what gives the game its depth.
The goal here was to build a playable online version, with real-time move syncing between players over WebSockets, a shared spectator view, and a single-player mode against a basic AI opponent.
System Architecture
When a player connects, the server upgrades their HTTP connection to a WebSocket (a protocol that keeps a persistent bidirectional connection open between browser and server, unlike standard HTTP request/response), assigns them a UUID, and creates a new GameSession. The session holds the board state, both player connections, any spectators, and the turn counter.
Every outgoing message has a MessageType field. The server dispatches on this field to handle name updates, game settings, moves, chat, and multiplayer status changes. All game state changes are broadcast immediately to both players and any connected spectators using the same JSON message.
Active board enforcement: After each move, the server sets activeGame on the board to the sub-board index corresponding to where the last piece was played within its local grid. Subsequent move validation checks that the incoming move is in the right sub-board. If that board is already won, activeGame is set to -1, allowing the next player to move anywhere.
Development Notes
The board is represented as two layers: a TicTacToeBoard struct (a single 3x3 board) and an UltimateGameBoard that holds a 3x3 grid of those smaller boards plus a winner board tracking which sub-boards have been claimed. All 81 cells are addressed using a single integer 0–80, where the row, column within the meta-grid, and position within the local board are all encoded into that one number. The decoder is:
Flattening the address like this makes it easy to pass a single move value over the wire without a more complex coordinate object.
Used gorilla/websocket for connection handling (Go’s standard library doesn’t include WebSocket support). The message loop reads JSON off the connection, unmarshals to a base standardMessage struct to read the type field, then unmarshals again into the appropriate typed struct for that message. Sessions are stored in a global map keyed by session UUID. UUID verification is done on every message that modifies state: the UUID sent in the message is checked against the UUID registered for that WebSocket connection. Mismatches are logged and the message is dropped.
The frontend is a Next.js app with a single game page rendered per session. The board renders 9 sub-boards, each with 9 clickable cells. The active sub-board is highlighted so it’s clear where moves are constrained. Incoming boardUpdate messages carry the full flattened 81-element board array and the winner board, which the client uses to rebuild the display from scratch on each update rather than attempting incremental patching.
Single-player mode uses a pluggable AI algorithm interface. The only implementation is a random move picker: it reads the list of available moves given the active board constraint and picks one at random. After a human player makes a move, if the session is in single-player mode and the game isn’t over, the server immediately generates and applies the AI move before responding. Spectators connect via the join endpoint, get added to the session’s spectator map, and receive the same board broadcast messages as active players without being able to make moves.
Key Technical Decisions
Why WebSockets over polling? The game needs both players to see moves in real-time. Polling would work, but you’d be making an HTTP request every fraction of a second for the lifetime of the game to check for updates. WebSockets keep the connection alive and push updates the moment they happen, which is the right fit for a live turn-based game.
Why Go stdlib over a framework? Same reasoning as the Nightrein project: the backend is simple enough that a framework would add more ceremony than value. The handler logic is a straightforward message dispatch loop. gorilla/websocket handles the WebSocket upgrade; everything else is plain Go.
Why send the full board state on every update instead of just the last move? It’s simpler and more correct. Sending incremental diffs means the client has to correctly apply every diff in order to stay consistent. Sending the full board means a client that reconnects or misses a message can recover by just using the next update it receives. For a 81-cell board, the payload size difference doesn’t matter.
Lessons Learned
The hardest part of the game logic was getting the active board routing right at edge cases: what happens when a player’s move would send the opponent to an already-won or drawn sub-board. The answer (let them play anywhere) sounds simple, but it has to be enforced correctly in setActiveBoard and checked in MakeUltimateMove, and the condition needs to account for both won and drawn sub-boards since both make that board unavailable.
The double-unmarshal pattern for typed messages (unmarshal once to read the type, then again into the typed struct) is a common Go pattern for discriminated union messages over a single channel. It’s not elegant but it’s clear. The alternative is a fully generic message with a json.RawMessage payload field, which delays the type-specific parsing but adds its own boilerplate. The double-unmarshal won because it’s more self-contained per message type.