Coding TicTacToe: Game Loop and Controller

How to implement a clean game loop and a "Thin Controller" that delegates logic to models without leaking implementation details.

April 27, 20263 min read8 / 9

The design is finalized. The models are written. Now comes the part that separates knowing the design from actually implementing it: writing the code that drives the game. Where does control begin? Who calls what? Here is how I wire up the game loop.

The Essentials

  1. The main class is the client: In a command-line game, main runs the loop that prints state, checks for undo, and prompts for moves.
  2. The GameController is the abstraction: All methods the main class calls go through the controller. The controller delegates to models.
  3. The loop condition is state-based: Never use a move count. Use game.getStatus() == IN_PROGRESS.
  4. Player.makeMove handles both humans and bots: Polymorphism ensures the main class never needs to check the player type.
  5. Validation belongs in the game, not the client: Never trust external input. The game class must validate every move internally.

The Main Class as Client

In a desktop game, we write the client ourselves in the main method. The main method is intentionally "dumb." It does exactly three things in a loop:

  1. Print the current board.
  2. Ask if anyone wants to undo.
  3. Ask the current player to make a move.

Everything else -- validation, board updates, winner detection -- happens inside the game layer.

function main(): void { const controller = new GameController(); const game = controller.createGame(dimension, players, winningStrategies); while (controller.getGameStatus(game) === GameStatus.IN_PROGRESS) { controller.displayBoard(game); const wantsUndo = prompt("Do you want to undo? (yes/no): "); if (wantsUndo === "yes") { controller.undo(game); continue; } controller.makeNextMove(game); } controller.printResult(game); }

The Stateless Controller

Notice that the GameController has no state. It doesn't store a game object as a field. Instead, the main method passes the game object as a parameter to every controller call.

This means the same controller can serve multiple games simultaneously. This is the standard pattern for controllers in both desktop and web applications.

Creating a Game: The Builder Pattern

In the controller, createGame doesn't just call new Game(). It uses the builder we designed to ensure all validation rules (e.g., distinct symbols) are checked before construction.

class GameController { createGame(dimension: number, players: Player[], strategies: WinningStrategy[]): Game { return Game.getBuilder() .setDimension(dimension) .setPlayers(players) .setWinningStrategies(strategies) .build(); } }

Why Validation Stays in the Model

If validation lived in the client (the main loop), it could be skipped or forgotten. By putting validateMove inside the Game class, we ensure it runs on every move, regardless of who calls it.

The controller and game loop are in place, but several methods still return stubs. In the final post, we will finish the bot strategy, the O(1) winning logic, and the state-reversal undo.

Further Reading and Watching

Practice what you just read.

Main Loop Skeleton
1 exercise