
This is the next instalment in my robot arm tic-tac-toe series. In the first post I set out the goal: an arm that plays a real game against me, picking up and placing its own pieces. In step one I built a calibration routine so the arm knows where each square is. Now it’s time to put it all together. The arm plays a full game, with a bit of help from Google’s Gemini AI.
All the code is on GitHub in the tic-tac-toe-gemini-arm folder. Here’s what it does, how it’s put together and how to get it running on your own bench.
What it does
The rules are simple. I play O and place my pieces on the physical board by hand. The arm plays X. For each of its moves, it picks up an X piece from a spot beside the board and drops it on the square it has chosen.
The interesting part is how it decides. A minimax search on the board works out every move that’s equally good. Gemini then picks one of those moves, and any reply that isn’t on the list is rejected. More on why I did it that way shortly.
The hardware
From the README:
- Arduino UNO R4 WiFi
- Keyestudio Sensor Shield V5.2
- Six arm servos on D0 to D5, with M0 (D0) as the gripper
- A separate test servo on D11, used only from the calibration page. It isn’t part of the arm or the stored poses
- An external servo supply of about 6 V into the shield, with its ground tied to the UNO ground
That last point matters. I learned early on that the board can’t power a bank of servos, and the code is explicit about it: the UNO’s 5 V pin is for logic only. Because the serial monitor runs over USB, D0 and D1 are free to use as servo signals.
How the project is structured
It’s a PlatformIO project rather than a single Arduino sketch, which makes the pieces easier to keep apart:
- platformio.ini targets the
uno_r4_wifiboard and pulls in the Servo, ArduinoJson and ArduinoHttpClient libraries. - src/main.cpp is the main program: motion control, the game, the two web pages, serial commands and pose storage.
- include/tic_tac_toe_rules.h holds the board, the win and draw checks, the minimax search and a self-test.
- include/gemini_connection.h makes one call to the Gemini API and returns the text reply.
- include/wifi_connection.h joins the Wi-Fi network.
- include/wifi_secrets.example.h and include/gemini_config.example.h are templates for your credentials. The real copies are listed in .gitignore so they never end up on GitHub.
The brains: minimax first
Tic-tac-toe is small enough to solve completely, so there’s no need for an AI to work out the best move. The rules header does that with an alpha-beta minimax search, scored from X’s point of view. A faster win scores higher, and a slower loss is less bad:
char winner = board.winner();if (winner == 'X') return 10 - depth;if (winner == 'O') return depth - 10;if (board.full()) return 0;
Rather than returning one best square, bestMovesForX() returns every square that ties for the best score, stored as a bitmask. On an empty board, for example, all nine squares come back as equally good, because perfect play from both sides is a draw.
There’s also a selfTest() that runs at startup. It checks illegal moves, every winning line, a draw, a one-move win, a forced block, the classic corner fork and the reply to a centre opening. If any check fails, the arm refuses to move. You can rerun it at any time with the TEST serial command.
Where Gemini fits in
So if minimax already knows the answer, why use Gemini? Because there’s often more than one right answer, and I wanted to experiment with letting an AI model make that choice while keeping it on a very short leash.
In robotMove(), the code builds a prompt listing the equally optimal squares and the current board, and asks for a JSON reply:
String prompt = "Play tic tac toe as X against human O. Either player may start. ";prompt += "A complete minimax search has identified these equally best moves: [" + optimalSquares + "]. ";prompt += "Choose ONLY from this set. For an empty board prefer a corner from the set, to create fork opportunities. ";prompt += "Return only JSON {\"square\":N} with one integer from the optimal set. Board: ";
The reply then has to pass three checks before anything moves. It must parse as JSON with an integer square, the square must be in the minimax set, and it must be a legal move on the board:
int square = choice["square"].as<int>();if (!best.contains(square)) { say("[Minimax] Rejected Gemini move: not in the optimal set. No motion. Send RETRY."); return;}
If any check fails, or the request itself fails, the board is left unchanged and I can send RETRY. Gemini can choose, but it can’t make a bad move. To me that’s a nice small example of a bigger idea: let the AI make the call, but have plain code check its work.
The connection helper keeps things tidy too. It sends the API key in the x-goog-api-key header rather than in the URL, insists on HTTPS, asks for a JSON response type and caps the output at 256 tokens. It also skips any “thought” parts that thinking models return, so only the visible answer is used. And it won’t call Gemini at all while the key is still the placeholder.
Because the Gemini call blocks (the code allows up to 20 seconds for the response), it isn’t made from inside a web request. The move is queued, and the main loop runs it only once the arm is idle, so the browser gets an immediate reply.
How it drives the arm
This is a big step up from the one-degree-at-a-time stepping in the calibration post. Motion is now a short plan of eased segments on a shared clock, and a 200 Hz hardware timer writes the servo pulses. That means Wi-Fi work in the main loop doesn’t stall a move that’s already under way.
Each X move follows the same sequence:
- Open the gripper at home.
- Travel to the off-board pickup point.
- Close the gripper on the piece and let it settle.
- Lift off the pile and return home, still holding the piece.
- Carry it to the chosen square and pause.
- Open the gripper to drop the piece.
- Lift clear of the piece and return home.
M3 is treated as the lift joint. During a transfer it rises towards a clearance angle first, and the other joints start moving once it’s 60% of the way up. On the way back from placing a piece, the lift finishes completely before anything else swings, so the gripper doesn’t clip the piece it just put down. And if X wins, the arm does a small victory jig at home.
The servos stay detached until you arm them. The first arming move jumps straight to the home pose, so support the arm and clear its travel first. After that, everything is eased.
Calibration, now with memory
In step one the saved poses lived in RAM and disappeared on a restart. That’s fixed here. The calibration page at /calibrate lets you choose a target (squares 1 to 9, the off-board pickup or home), jog M0 to M5, save the angles and then press Store to flash. All 11 poses are written to EEPROM with a checksum, so they survive a reset. Until you calibrate, compiled default poses are used.
The same page sets how far the gripper closes and which direction opens it (stored separately), lets you preview a saved pose and can jog or sweep the D11 test servo.
Playing a game
Once Wi-Fi connects, the serial log prints the device address. Browse to http://<device-ip>/ for the game page. Arm the servos, choose who starts, place your O on the physical board, then tap the same square on the page. An output panel shows the same log as the serial monitor, so you can watch the minimax result and Gemini’s reply as they happen.
Everything also works from the serial monitor at 115200 baud. Send ? for the full list. The useful ones are ARM, FIRST or SECOND to choose who starts, 1 to 9 to play your O, RETRY, NEW, BOARD and STATUS. STOP or ! cancels motion and the game but keeps the servos holding, while RELEASE detaches every servo, so support the arm first.
Setting it up
- Copy the two example headers:
cp include/wifi_secrets.example.h include/wifi_secrets.handcp include/gemini_config.example.h include/gemini_config.h. - Put your 2.4 GHz Wi-Fi name and password in wifi_secrets.h.
- Put your Gemini API key and model URL in gemini_config.h. The URL is the full https address, including the generateContent path.
- Build, upload and open the monitor with
pio run -e uno_r4_wifi,pio run -e uno_r4_wifi -t uploadandpio device monitor -b 115200. - Calibrate every target on /calibrate and store to flash.
- Put an X piece on the pickup point before each robot move, then play.
The example config shows the shape of the URL:
inline constexpr const char *GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/models/YOUR_MODEL:generateContent";
What I take away from it
The game logic was never the hard part. The real work was in the bits around it: safe arming, smooth motion that doesn’t knock pieces over, poses that survive a restart, and treating the AI’s answer as a suggestion to be checked rather than an order to be followed.
From the original plan, the arm can now play a full game with me entering my moves. The next big step is closing the loop, so it can see my move on the board without me tapping a square. As always, I’ll share how it goes. If you build one, let me know how yours plays.

One thought on “Robot Arm Tic-Tac-Toe: Playing a Full Game with Minimax and Gemini”