Introduction
Instead of only delegating work to AI, I started wondering: how do you let an AI slack off and play?
That idea became AI-only Minesweeper.
Humans are not allowed to operate it. Only the AI can interact with the game via an API.
In other words, the “UI” is the API itself—and the whole system is designed around that premise.
This article is the API edition, so I’ll save the MCP side for another post. Here, I’ll focus on the API design and the rules that make it possible for an AI to actually play.
Goals of the API Design
The primary goal is simple: make “AI-only operation” work cleanly.
Humans are spectators, so the API behavior is the game experience. That’s why the flow must be:
- simple,
- predictable,
- and explicit in its state transitions.
Concretely, the API is designed as a single path:
-
startcreates a new game every time, -
open / flag / chord / endprogress the game.
Game state transitions are explicit:
waiting -> playing -> won/lost -> ended
This helps the AI predict what will happen next.
Secret info (mine positions) is stored in a separate table. The public API only returns what’s necessary by converting internal state into a board representation.
The public board rendering is centralized in GameState#render_board.
The API only returns the transformed 2D array—internal state stays hidden.
def render_board(status:)
mines_set = mine_set
opened = opened_set
flagged = flagged_set
Array.new(Game::BOARD_SIZE) do |y|
Array.new(Game::BOARD_SIZE) do |x|
cell_display([x, y], status: status, mines: mines_set, opened: opened, flagged: flagged)
end
end
end
Endpoint Design
The flow is intentionally straightforward:
start -> state -> open/flag/chord -> end
It’s a single “happy path” so the AI doesn’t get lost.
state is public (no auth), which is quietly convenient: spectators and debugging tools can use the exact same endpoint.
The star of the show is open:
- opens an unopened cell
- returns 422 if the cell is flagged
- idempotent if the cell is already open
- supports zero-expansion (flood fill), so the AI can easily “probe” to gain information
The controller just catches exceptions and maps them to 422, while the game logic lives in the model.
def open
result = @game.open_cell!(params[:x], params[:y])
broadcast_updates(result[:changed_cells])
render json: { game: @game.public_state }
rescue Game::InvalidMove
render json: { error: "invalid_move" }, status: :unprocessable_entity
end
flag is a toggle with a maximum of 10 flags. That constraint forces the AI to commit: it can’t spam flags everywhere.
chord is only allowed when:
- the selected cell is a number cell, and
- the number of adjacent flags matches that number
When it works, progress accelerates. When the condition isn’t met, it returns 422—so it’s a great “decision-making checkpoint” for the AI.
Request bodies are kept minimal (e.g., POST /games/:public_id/open { x, y }).
More detailed specs live in docs/api.md.
Board Representation and State Transitions
The board is returned as a 2D array: board[y][x].
Each cell is represented as a string:
-
#= unopened -
F= flag -
0-8= opened number
Only on defeat do special symbols appear:
-
X= the mine that was triggered -
M= mines that were not triggered -
!= incorrect flags
State transitions are:
waiting -> playing -> won/lost -> ended
Win conditions are either:
- open all non-mine cells, or
- place flags on all mines
The API owns this logic, so the AI only needs the board strings to decide the next move.
Authentication and Safety
All action endpoints require:
Authorization: Bearer <token>
The token is displayed only once at user creation time. No re-display, no re-issue.
Basically: lose it and it’s over.
Authentication is centralized in ApplicationController so every action can share it.
def authenticate_user!
header = request.authorization.to_s
unless header.start_with?("Bearer ")
render json: { error: "missing_token" }, status: :unauthorized
return
end
token = header.delete_prefix("Bearer ").strip
digest = UserToken.digest_token(token)
user_token = UserToken.find_by(token_digest: digest)
unless user_token
render json: { error: "invalid_token" }, status: :unauthorized
return
end
@current_user = user_token.user
end
For basic bot resistance, I added:
- a honeypot, and
- a simple “time trap” that rejects submissions made in under 3 seconds
This isn’t meant to be hardcore security—just a lightweight guardrail against lazy mass signups.
Wrap-up
When you push the entire game UI into the API, the AI ends up with a clean equivalence:
Calling the API = playing the game
Humans can stay in spectator mode, and the AI can make decisions purely from the board it receives. That keeps both implementation and usage pleasantly simple.
Next time (the MCP edition), I’ll write about how to actually “let the AI play” using this API.
If you’re curious, check out docs/api.md too.
mcp: https://dev.to/geeknees/i-built-minesweeper-so-an-ai-can-slack-off-and-play-mcp-edition-4gc4
🇯🇵 This article is also available in Japanese:
https://zenn.dev/geeknees/articles/634ad69d7c347e

Top comments (0)