Robot Arm Tic-Tac-Toe, Step One: Teaching the Arm Where the Board Is

A maker at a workbench guiding a desktop robot arm over a three-by-three game board

In my last post I laid out the plan to teach my robot arm to play tic-tac-toe. The very first item on that plan was fixed positions: record the servo angles for each of the nine squares and the pick-up point. Before the arm can play anything, it has to know where everything is. So that’s where I’ve started, with a calibration routine.

Why calibration comes first

It’s tempting to jump straight to the fun stuff, the game logic or the camera. But none of that matters if the arm drops a piece halfway between two squares. Hobby servos aren’t precise, and the arm doesn’t know anything about the board in front of it. All it understands is six angles between 0 and 180 degrees.

Rather than trying to calculate those angles (that’s the kinematics rabbit hole I’m avoiding for now), the simpler approach is to teach by hand. Move the arm to a square, check it looks right, and record the angles. Do that nine times and you have a map of the board. It’s not clever, but it’s repeatable, and repeatable is what I need right now.

The firmware: positioning.ino

The code that runs on the board is positioning.ino. It turns the arm into a small web server on my Wi-Fi network, so I can drive it from a browser rather than editing and re-uploading code every time I want to nudge a joint. It uses the WiFiS3 library for the network side and the standard Servo library for the motors.

Everything is built around six motors and nine stored poses, one for each cell of the 3×3 board:

constexpr uint8_t motorCount = 6;
constexpr uint8_t boxCount = 9;
const uint8_t motorPins[motorCount] = {0, 1, 2, 3, 4, 5};
const int minimumAngles[motorCount] = {0, 0, 0, 0, 0, 0};
const int maximumAngles[motorCount] = {180, 180, 180, 180, 180, 180};
const int homeAngles[motorCount] = {180, 90, 90, 135, 180, 90};

A couple of design choices are worth pointing out.

First, the servos aren’t attached when the board powers up. The controller connects to Wi-Fi and waits. Nothing moves until I explicitly press Arm, which calls the arm endpoint. That stops the arm lurching into life the moment I plug it in.

Second, motion is gradual. Instead of slamming a servo straight to its target, each joint steps one degree at a time with a short pause between steps:

void moveMotorTo(uint8_t motorIndex, int targetAngle) {
targetAngle = constrain(targetAngle, minimumAngles[motorIndex], maximumAngles[motorIndex]);
while (currentAngles[motorIndex] != targetAngle) {
currentAngles[motorIndex] += currentAngles[motorIndex] < targetAngle ? 1 : -1;
motors[motorIndex].write(currentAngles[motorIndex]);
delay(15);
}
}

The target is also clamped to each motor’s allowed range, so a bad value can’t drive a joint past its limits.

A tiny API for teaching poses

The browser talks to the arm through a handful of simple GET endpoints:

  • / serves the control page.
  • /api/state returns the current angles, which poses are saved and the saved values, as JSON.
  • /api/arm attaches the servos.
  • /api/select?box=N picks which of the nine cells I’m about to record.
  • /api/jog?motor=M&angle=A moves one joint to an angle.
  • /api/save stores all six current angles into the selected cell.

Jogging and saving are refused until the servos are armed, and the motor number, angle and box number are all validated before they’re used. Saving is about as simple as it gets:

for (uint8_t motor = 0; motor < motorCount; motor++) {
boxAngles[selectedBox][motor] = currentAngles[motor];
}
boxSaved[selectedBox] = true;

So the routine is: arm, select a cell, jog each joint until the gripper sits where I want it, save, then move on to the next cell. One thing to be aware of is that the saved poses live in memory, so they’re gone if the board restarts. For now I’ll copy them out once I’m happy with them.

The web app: arm-controller-demo.html

The second file, arm-controller-demo.html, is a browser-only demo of the controller interface. It doesn’t connect to any hardware. It simulates the arm, so you can open it on any machine and get a feel for how the calibration workflow runs before you wire anything up. Saved poses disappear when you reload the page.

It includes:

  • A side-view diagram of the arm that updates as the angles change, with the six joints labelled base rotation, shoulder, elbow, wrist pitch, wrist roll and gripper.
  • A slider plus fine-adjust buttons for each motor, and a test button that sweeps a motor to 0°, then 180°, then back to where it started.
  • A 3×3 grid to choose which cell to map, plus an extra pickup target outside the board.
  • A saved poses table and a button to copy the results out as a C++ table.

That last feature is the bit I like most. Once the poses are recorded, the export is ready to paste back into code:

// Preview data only. -1 indicates an unsaved pose; do not command it.
const int cellAngles[9][6] = { ... };
// Outside-board pickup; keep the resting/home pose separate.
const int pickupAngles[6] = { ... };

The demo is a step ahead of the firmware in one respect: it has the pickup position, while the firmware currently stores just the nine board cells. Adding the pickup slot to the board code is on my list.

What I’ve learned so far

  • Teach, don’t calculate (yet). Recording positions by hand gets me a working board map far faster than maths would.
  • Safety first. Not attaching servos until asked, and moving one degree at a time, saves a lot of flinching at the bench.
  • A browser beats re-uploading. Jogging from a web page means I can nudge a joint and see the result straight away, without a compile and upload cycle for every tweak.

Next steps

With a way to record where every square is, the next job is reliable pick and place: grab a piece from the pickup spot and drop it cleanly into any cell. I’ll also look at keeping the saved poses when the board restarts. Both files are on GitHub if you want to take a look or adapt them for your own arm. As always, I’ll share how it goes, wobbles and all.

Leave a Reply