Why Not Overengineer? Tic-Tac-Toe’s AI Chaos

Picture this: it’s a quiet night, I’m nursing a coffee, and my code editor’s mocking me with its blank stare. Then, a wild idea strikes—why not take the simplest game ever, Tic-Tac-Toe, and juice it up with AI? Was it overkill? Absolutely. A sandbox for learning? You bet. We wrestled with quirky models, battled APIs, and laughed through the chaos. The result? A working game and a treasure chest of lessons. Curious? The code’s all here: github.com/altuix/new-gen-atari. This project was about exploring how a simple game could be transformed with AI, uncovering the quirks of modern tech stacks, and learning through trial and error. Let’s dive in! Live Demo Who’s This For? This is for tinkerers—coders who see a basic project and think, “How can I make this wild?” If you’re new to React, itching to mess with AI, or just love a challenge, this is your jam. Seasoned devs, you might chuckle at our detours, but stick around—there’s something here for you too. Whether you’re looking to dive into AI integration, master API handling, or enjoy a quirky coding adventure, this project offers hands-on lessons for all skill levels. What’s the Point? Why overcomplicate a game kids master in minutes? Because it’s a perfect playground to: Experiment with AI integration. Test modern frameworks like Next.js. Learn from epic fails and small wins. Here’s what we uncovered: Crafting Effective Prompts: Clear and specific prompts are crucial for meaningful AI responses. API Integration Challenges: Backend-frontend glue requires careful coordination. Handling Errors: Rate limits and debugging taught us patience and planning. Comparing AI Models: Free tools teased; paid ones delivered. The Tech Stack Our toolkit was a mix of modern and mad: Next.js: Chosen for seamless frontend and backend handling via API Routes. React: Enabled a responsive UI with efficient state management. Hugging Face: Provided a free playground for models like DistilBERT, GPT-2, and Mistral-7B. OpenAI: Delivered GPT-4o-mini for advanced reasoning. JavaScript: The backbone tying it all together. Step 1: The Humble Beginning Every epic starts small. Mine? A two-player Tic-Tac-Toe in Next.js and React. Goal: a 3x3 grid where X and O take turns. In gameBoard.tsx, I set it up: "use client"; import React, { useState } from "react"; import Cell from "./components/cell"; export default function GameBoard() { const [gameState, setGameState] = useState([ ["0", "0", "0"], ["0", "0", "0"], ["0", "0", "0"], ]); const [xTurn, setXTurn] = useState(true); const cellAction = (cellId) => { const [row, col] = cellId.split("_").map(Number); if (gameState[row][col] === "0") { const newState = [...gameState]; newState[row][col] = xTurn ? "x" : "o"; setGameState(newState); setXTurn(!xTurn); } }; return {/* Render cells */}; } Cells got clickable in Cell.tsx: import React from "react"; const Cell = ({ children, cellId, cellAction }) => ( cellAction(cellId)} > {children} ); export default Cell; And winCases.ts sealed the deal: export const checkWinner = (gameState) => { const winCombinations = [ [[0, 0], [0, 1], [0, 2]], [[1, 0], [1, 1], [1, 2]], [[2, 0], [2, 1], [2, 2]], [[0, 0], [1, 0], [2, 0]], [[0, 1], [1, 1], [2, 1]], [[0, 2], [1, 2], [2, 2]], [[0, 0], [1, 1], [2, 2]], [[0, 2], [1, 1], [2, 0]], ]; for (let combo of winCombinations) { const [[r1, c1], [r2, c2], [r3, c3]] = combo; if ( gameState[r1][c1] !== "0" && gameState[r1][c1] === gameState[r2][c2] && gameState[r1][c1] === gameState[r3][c3] ) { return gameState[r1][c1]; } } return gameState.flat().every((cell) => cell !== "0") ? "draw" : null; }; It worked like a charm—too easy, though. Time to spice it up. Step 2: AI Enters the Chat Next thought: “What if AI played O?” Next.js API Routes kept it all in-house. In opponent.ts, I hooked up an API call: const getOpponentMove = async (gameState) => { const response = await fetch("/api/OpenAi", { method: "POST", body: JSON.stringify({ gameState }), }); if (!response.ok) throw new Error(`Status: ${response.status}`); return response.json(); }; export default getOpponentMove; Next.js API Routes allowed us to manage backend logic within the same project, streamlining communication between the frontend and AI services. Then, in gameBoard.tsx, I triggered AI moves: const opponentAiMove = async () => { try { let move = await getOpponentMove(gameState); cellAction(move); } catch (error) { alert(`Opponent move failed: ${error.message}`); } }; useEffect(() => { if (!xTurn && vsAI) opponentAiMove(); }, [xTurn, gameState]); I was hyped—until I realized the AI part was the real puzzle. Step 3: Free Models, Big Flops “Let’s try free stuff first,” I thought, diving int

Apr 15, 2025 - 11:03
 0
Why Not Overengineer? Tic-Tac-Toe’s AI Chaos

Picture this: it’s a quiet night, I’m nursing a coffee, and my code editor’s mocking me with its blank stare. Then, a wild idea strikes—why not take the simplest game ever, Tic-Tac-Toe, and juice it up with AI? Was it overkill? Absolutely. A sandbox for learning? You bet. We wrestled with quirky models, battled APIs, and laughed through the chaos. The result? A working game and a treasure chest of lessons. Curious? The code’s all here: github.com/altuix/new-gen-atari. This project was about exploring how a simple game could be transformed with AI, uncovering the quirks of modern tech stacks, and learning through trial and error. Let’s dive in! Live Demo

Who’s This For?

This is for tinkerers—coders who see a basic project and think, “How can I make this wild?” If you’re new to React, itching to mess with AI, or just love a challenge, this is your jam. Seasoned devs, you might chuckle at our detours, but stick around—there’s something here for you too. Whether you’re looking to dive into AI integration, master API handling, or enjoy a quirky coding adventure, this project offers hands-on lessons for all skill levels.

What’s the Point?

Why overcomplicate a game kids master in minutes? Because it’s a perfect playground to:

  • Experiment with AI integration.
  • Test modern frameworks like Next.js.
  • Learn from epic fails and small wins.

Here’s what we uncovered:

  • Crafting Effective Prompts: Clear and specific prompts are crucial for meaningful AI responses.
  • API Integration Challenges: Backend-frontend glue requires careful coordination.
  • Handling Errors: Rate limits and debugging taught us patience and planning.
  • Comparing AI Models: Free tools teased; paid ones delivered.

The Tech Stack

Our toolkit was a mix of modern and mad:

  • Next.js: Chosen for seamless frontend and backend handling via API Routes.
  • React: Enabled a responsive UI with efficient state management.
  • Hugging Face: Provided a free playground for models like DistilBERT, GPT-2, and Mistral-7B.
  • OpenAI: Delivered GPT-4o-mini for advanced reasoning.
  • JavaScript: The backbone tying it all together.

Step 1: The Humble Beginning

Every epic starts small. Mine? A two-player Tic-Tac-Toe in Next.js and React. Goal: a 3x3 grid where X and O take turns. In gameBoard.tsx, I set it up:

"use client";
import React, { useState } from "react";
import Cell from "./components/cell";

export default function GameBoard() {
  const [gameState, setGameState] = useState([
    ["0", "0", "0"],
    ["0", "0", "0"],
    ["0", "0", "0"],
  ]);
  const [xTurn, setXTurn] = useState(true);

  const cellAction = (cellId) => {
    const [row, col] = cellId.split("_").map(Number);
    if (gameState[row][col] === "0") {
      const newState = [...gameState];
      newState[row][col] = xTurn ? "x" : "o";
      setGameState(newState);
      setXTurn(!xTurn);
    }
  };

  return <div className="grid grid-cols-3 grid-rows-3">{/* Render cells */}</div>;
}

Cells got clickable in Cell.tsx:

import React from "react";

const Cell = ({ children, cellId, cellAction }) => (
  <div
    className="flex bg-white box-border size-20 border-2 border-black font-black text-5xl uppercase items-center justify-center text-black cursor-pointer"
    onClick={() => cellAction(cellId)}
  >
    {children}
  </div>
);

export default Cell;

And winCases.ts sealed the deal:

export const checkWinner = (gameState) => {
  const winCombinations = [
    [[0, 0], [0, 1], [0, 2]],
    [[1, 0], [1, 1], [1, 2]],
    [[2, 0], [2, 1], [2, 2]],
    [[0, 0], [1, 0], [2, 0]],
    [[0, 1], [1, 1], [2, 1]],
    [[0, 2], [1, 2], [2, 2]],
    [[0, 0], [1, 1], [2, 2]],
    [[0, 2], [1, 1], [2, 0]],
  ];

  for (let combo of winCombinations) {
    const [[r1, c1], [r2, c2], [r3, c3]] = combo;
    if (
      gameState[r1][c1] !== "0" &&
      gameState[r1][c1] === gameState[r2][c2] &&
      gameState[r1][c1] === gameState[r3][c3]
    ) {
      return gameState[r1][c1];
    }
  }

  return gameState.flat().every((cell) => cell !== "0") ? "draw" : null;
};

It worked like a charm—too easy, though. Time to spice it up.

Step 2: AI Enters the Chat

Next thought: “What if AI played O?” Next.js API Routes kept it all in-house. In opponent.ts, I hooked up an API call:

const getOpponentMove = async (gameState) => {
  const response = await fetch("/api/OpenAi", {
    method: "POST",
    body: JSON.stringify({ gameState }),
  });
  if (!response.ok) throw new Error(`Status: ${response.status}`);
  return response.json();
};

export default getOpponentMove;

Next.js API Routes allowed us to manage backend logic within the same project, streamlining communication between the frontend and AI services.

Then, in gameBoard.tsx, I triggered AI moves:

const opponentAiMove = async () => {
  try {
    let move = await getOpponentMove(gameState);
    cellAction(move);
  } catch (error) {
    alert(`Opponent move failed: ${error.message}`);
  }
};

useEffect(() => {
  if (!xTurn && vsAI) opponentAiMove();
}, [xTurn, gameState]);

I was hyped—until I realized the AI part was the real puzzle.

Step 3: Free Models, Big Flops

“Let’s try free stuff first,” I thought, diving into Hugging Face with huggingface/route.ts:

const promptTemplate = () => "hey";
const model = new HuggingFaceInference({
  model: "distilbert-base-uncased-finetuned-sst-2-english",
  apiKey: process.env.HUGGINGFACE_API_TOKEN,
  maxTokens: 10,
  temperature: 0.5,
});

const getAIMove = async (maxAttempts = 5) => {
  let attempt = 1;
  while (attempt <= maxAttempts) {
    const response = await model.invoke(promptTemplate());
    const move = extractMove(response?.trim() ?? "");
    if (move) {
      const [row, col] = move.split("_").map(Number);
      if (isValidMove(row, col, gameState)) return move;
    }
    attempt++;
  }
};

We tested a few free models from Hugging Face, hoping they’d play Tic-Tac-Toe. Here’s what we tried and what happened:

  • DistilBERT (distilbert-base-uncased-finetuned-sst-2-english): Prompted with “Pick an empty cell,” it responded with “positive.” Sentiment analysis? Sure. Game moves? Nope.
  • GPT-2: Asked to “Choose ‘row_column’,” it gave “3_4” on a 3x3 grid. Out of bounds and out of luck.
  • Mistral-7B: Told to “Pick a spot,” it spat out random gibberish every time. Consistency? Not found.

These models struggled because they’re optimized for tasks like sentiment analysis or text generation, not the structured logic required for a game like Tic-Tac-Toe.

Hours of tweaking prompts—“Stay in bounds!” “Empty cells only!”—led nowhere. We left promptTemplate as "hey", a sarcastic surrender. The results were hilarious but useless.

Why the Flops? A Quick Reflection

Let’s be fair: these models aren’t bad—they’re just not made for this. DistilBERT excels at sentiment analysis, not grid-based logic. GPT-2 generates text, not strategies. Mistral-7B handles general tasks, but Tic-Tac-Toe is a stretch. The problem wasn’t the models—it was me, trying to force them into something weird with vague prompts. My lack of prompt-engineering finesse didn’t help either. They’re great in their own domains; my odd demands were the real flop here.

Step 4: OpenAI Saves the Day

Done with freebies, we tapped OpenAI’s GPT-4o-mini in open-ai/route.ts:

const promptTemplate = (attempt, invalidMoves) => `
You are playing Tic-Tac-Toe as O on a 3x3 grid (rows and columns: 0-2). 
'X' is your opponent, 'O' is you, '0' is empty. 
Your move MUST be an empty cell ('0') with coordinates 'row_column'.
Do NOT suggest occupied cells or invalid coordinates.
Current board: ${JSON.stringify(GAME_STATE)}
Respond with 'row_column' only.
${attempt > 1 ? `Previous invalid: ${invalidMoves.join(",")}.` : ""}
`;

async function getAIMove(maxAttempts = 5) {
  let attempt = 1;
  let invalidMoves = [];
  while (attempt <= maxAttempts) {
    const prompt = promptTemplate(attempt, invalidMoves);
    try {
      const response = await client.chat.completions.create({
        model: "gpt-4o-mini",
        messages: [{ role: "user", content: prompt }],
        temperature: 0.3,
        max_tokens: 10,
      });
      const move = response?.choices?.[0]?.message?.content;
      if (move && isValidMove(...move.split("_").map(Number), GAME_STATE)) {
        return move;
      }
      invalidMoves.push(move);
      attempt++;
    } catch (error) {
      console.error("AI move error:", error);
      return null;
    }
  }
  return null;
}

It wasn’t flawless:

  • Early moves hit occupied cells.
  • Costs crept up (pennies, but still).
  • A 429 “Too Many Requests” error forced a pay-as-you-go switch. We hit the free tier’s rate limit, so we added card details and switched to a pay-as-you-go plan, which resolved the issue instantly.

Once sorted, GPT-4o-mini played like a pro—reasoning, adapting, winning.

Step 5: The Road Not Taken

Here’s the twist: we could’ve used Minimax, a slick algorithm for perfect Tic-Tac-Toe moves. Minimax evaluates every possible move to guarantee the best outcome, making it ideal for games like Tic-Tac-Toe:

function minimax(board, isMaximizing) {
  const winner = checkWinner(board);
  if (winner === "x") return -1;
  if (winner === "o") return 1;
  if (board.flat().every((cell) => cell !== "0")) return 0;

  if (isMaximizing) {
    let best = -Infinity;
    for (let i = 0; i < 3; i++) {
      for (let j = 0; j < 3; j++) {
        if (board[i][j] === "0") {
          board[i][j] = "o";
          best = Math.max(best, minimax(board, false));
          board[i][j] = "0";
        }
      }
    }
    return best;
  } else {
    let best = Infinity;
    for (let i = 0; i < 3; i++) {
      for (let j = 0; j < 3; j++) {
        if (board[i][j] === "0") {
          board[i][j] = "x";
          best = Math.min(best, minimax(board, true));
          board[i][j] = "0";
        }
      }
    }
    return best;
  }
}

Minimax would’ve crushed it in a day. Why skip it? This was about exploration, not efficiency. The scenic route taught us more.

Step 6: Game On!

Tying it together in gameBoard.tsx:

const opponentAiMove = async () => {
  try {
    let move = await getOpponentMove(gameState);
    cellAction(move);
  } catch (error) {
    alert(`Opponent move failed: ${error.message}`);
  }
};

It worked! Not flawless—AI lagged sometimes—but playable. We tested the game with friends, tweaking the AI’s response time to make it feel more natural, and celebrated every hard-fought win. Victory tasted sweet after the struggle.

What We Learned

This wild ride left us wiser:

  • Prompts Matter: Clear, specific prompts are crucial for getting meaningful AI responses.
  • APIs Bite Back: Rate limits and errors require careful planning and debugging.
  • Free vs. Paid: Free models are great for experimenting, but paid ones offer reliability.
  • Overkill Wins: Tackling complex challenges builds skills you won’t gain from simple tasks.

What’s Next?

We built an AI Tic-Tac-Toe—was it nuts? Yes. Awesome? Absolutely. I’m hooked—next up, maybe an over-the-top Pong or a neural-net Snake. Imagine an AI-driven Pong where the paddle predicts your moves or a Snake game that learns your playstyle—any takers for those? Got ideas? Hit the comments—let’s overcomplicate something else together!