2025-11-15Bruno Fernandes

Building an LLM-Powered Checkers Game

I wanted to play around with some potential use cases for the new CSS Anchor Positioning API that do not involve drop-down menus and tooltips. I immediately thought of a chess/checkers game. Previously, to make pieces animate and transition smoothly between squares, I would have had to either manually calculate positions with JavaScript or use a hacky CSS solution with magic numbers. But with anchor positioning, I can simply "anchor" each piece to its square, and the browser handles the rest. Combined with React 19's ViewTransition API, this makes for a really smooth experience with minimal code.

This is also a fun opportunity to play around with Vercel's AI SDK to implement the opponent's decision making. Checkers is a solved game (researchers proved the optimal outcome is a draw) and LLMs are definitely not the best tool for this use case, but this project is just for fun, to tinker with new APIs and see how far I can push a lightweight AI-driven loop.

Here's the complete project repo: llm-checkers on GitHub.

Stack

  • Next.js 16 (App Router) with server actions/functions for AI turns.
  • React 19, TypeScript, Zod, and Tailwind CSS 4.
  • Vercel AI SDK with OpenAI gpt-4o, using experimental_output for object generation with tool call support.
  • Bitboard representation + reducer + selectors so UI and AI share the same source of truth.

Data Structures: Bitboard + Reducer

I store the board in a Uint32Array bitboard where each playable square is an index. Bits encode piece color, king status, and ID—compact, cache-friendly, and easy to clone.

src/game/bitboard.ts

1const COLOR_MASK = 1 << 0;
2const KING_MASK = 1 << 1;
3const ID_SHIFT = 2;
4
5export function encodePiece(
6  id: number,
7  color: CheckersPlayerColor,
8  isKing: boolean,
9) {
10  let piece = id << ID_SHIFT;
11  piece |= color === "white" ? COLOR_MASK : 0;
12  piece |= isKing ? KING_MASK : 0;
13  return piece;
14}
15
16export function decodePiece(piece: BitboardPiece) {
17  const id = piece >> ID_SHIFT;
18  const color: CheckersPlayerColor = piece & COLOR_MASK ? "white" : "black";
19  const isKing = (piece & KING_MASK) !== 0;
20  return { id, color, isKing };
21}

Selectors sit on top of this to produce view-ready CheckersPiece objects and valid moves. The reducer enforces rules, handles captures/promotions, and swaps turns.

src/game/store.ts

1case "move": {
2  const piece = findPieceById(state.board, action.pieceId);
3  if (!piece) return state;
4
5  const decodedPiece = decodePiece(piece);
6  if (state.currentPlayer !== decodedPiece.color) return state;
7
8  const move = validateMove(state.board, piece, action.to, state.rows, state.columns);
9  if (!move.isValid) return state;
10
11  const newBoard = move.isCapture
12    ? captureAndMovePiece(state.board, piece, action.to, move.capture, move.isPromotion)
13    : movePiece(state.board, piece, action.to, move.isPromotion);
14
15  const canCaptureAgain = move.isCapture
16    ? getValidMoves(newBoard, piece, state.rows, state.columns).some((m) => m.isCapture)
17    : false;
18
19  const nextPlayer = canCaptureAgain
20    ? state.currentPlayer
21    : state.currentPlayer === "black"
22      ? "white"
23      : "black";
24
25  return {
26    ...state,
27    board: newBoard,
28    currentPlayer: nextPlayer,
29    captured: { ...state.captured, [decodedPiece.color]: [...state.captured[decodedPiece.color], ...(move.isCapture ? [move.capture] : [])] },
30    boardHistory: [...state.boardHistory, newBoard],
31    moveHistory: [...state.moveHistory, { pieceId: action.pieceId, to: action.to, isPromotion: move.isPromotion, isCapture: move.isCapture, canCaptureAgain }],
32  };
33}

The game logic is all in the reducer, so both the UI and AI share the same authoritative rules. No React dependency here—just pure functions, shared between server and client.

How the AI Decides Moves

I use a server function to leverage the AI SDK (generateText). I set it up so that it uses tools to figure out the context of the board (current player, pieces, valid moves) and produce structured output validated by Zod corresponding to a move (pieceId and target coordinate). This keeps it inside legal move space and prevents prompt-injection chaos. The prompt itself is fairly straightforward, no string interpolation—just a description of the game and instructions to use the tools.

src/server-functions/ai.ts

1const prompt = `You are a player in a checkers game.
2  Given the game state, once you determine which player color you are, decide the best move to play.
3  Use as many tools as needed to analyze the current state of the game and determine your move.
4
5  You may play as offensively (attempt to capture pieces) or defensively (avoid getting your pieces captured) as you see fit.
6
7  American checkers rules apply:
8    - Pieces can only move diagonally on dark (playable) squares.
9    - Regular pieces can only move forward (towards the opponent's side). Kings can move both forward and backward.
10    - Capturing is mandatory. If a capture is available, you must take it.
11    - Multiple/chain captures (using the same piece) are allowed in a single turn if possible (you will be prompted to make additional moves after each capture if applicable).
12    - A piece is promoted to a king when it reaches the opponent's back row.
13
14  The board information and pieces can be obtained using the provided tools. Always check if a move is valid before suggesting it.
15  The position of each square on the board is represented as a single integer index, starting from 0 at the top-left playable square and increasing left to right, top to bottom, only counting playable squares.
16  `;
17
18export async function decideMove(state: CheckersGameState) {
19  const result = await generateText({
20    model: openai("gpt-4o"),
21    prompt,
22    experimental_output: Output.object({
23      schema: z.object({ pieceId: z.number(), to: z.number() }),
24    }),
25    tools: {
26      getCurrentPlayerColor: {
27        /* ... */
28      },
29      getBoardInfo: {
30        /* ... */
31      },
32      getValidMovesForPiece: {
33        async execute({ pieceId }) {
34          return selectValidMoves(state, pieceId);
35        },
36      },
37    },
38    stopWhen: stepCountIs(10),
39  });
40
41  return result.experimental_output;
42}

Because the selector layer is shared, the AI cannot propose illegal moves the reducer would reject. The UI then dispatches the validated move back into the reducer, updating the state of the game.

UI Tricks

The board uses CSS Anchor Positioning so each piece is tethered to its square without hand-rolled coordinates.

Each square has an anchor-name set to its position on the board:

src/components/checkers-board.tsx

1const squarePosition = isDarkSquare ? Math.floor(index / 2) + 1 : null;
2
3return (
4  <div
5    key={index}
6    ...
7    style={{
8      anchorName: squarePosition
9        ? `--pos-${squarePosition}`
10        : undefined,
11    }}
12  >
13  ...
14  </div>
15);

Then, each piece uses position-anchor to stick to its corresponding square based on the stored position. ViewTransition wraps each piece so moves animate smoothly when the state changes.

src/components/checkers-piece.tsx

1<ViewTransition>
2  <button
3    className={cn(
4      "absolute top-[anchor(top)] right-[anchor(right)] bottom-[anchor(bottom)] left-[anchor(left)] ...",
5      isSelected ? "ring-4" : "hover:scale-105",
6    )}
7    style={{ positionAnchor: `--pos-${piece.position}` }}
8    disabled={!isActivePlayer || piece.color === AI_PLAYER_COLOR}
9  >
10    {piece.isKing ? <span>♔</span> : <span>●</span>}
11  </button>
12</ViewTransition>

The pieces also adjust their size in a responsive manner using the top/right/bottom/left anchor offsets, ensuring they always fit nicely within their squares.

Potential Improvements

  1. Add difficulty knobs by tweaking tool limits, swapping models, or adjusting heuristic prompts. Reasoning models might do better at planning multiple moves ahead.
  2. Persist game history and per-move reasoning for replay.
  3. Try alternate models or providers via the Vercel AI SDK’s swappable interface.
  4. Implement a multiplayer mode or different AI personalities/styles.

Even though the game is solved, building this was a satisfying playground for modern React, Next.js server functions, and structured AI calls. If you want to explore or fork it, the repo is ready to run with pnpm dev/npm run dev after setting OPENAI_API_KEY. Have fun—and expect the AI to play by the book.