Robot Arm Tic-Tac-Toe: Wrapping Up the Project (For Now)

Prussian-blue cyanotype still-life of a tic-tac-toe board and physical ring and cross game pieces

It’s time to put a full stop on my tic-tac-toe robot arm project, at least for now. Over the last few weeks it has grown from “can I get the arm to touch nine squares?” into a working game. An Arduino drives the arm, an AI picks the moves, a camera watches the board, and a set of 3D-printed pieces is designed to be gripped and recognised by colour. Everything now lives in two projects in my GitHub repo, 44-tic-tac-toe-arm and 45-board-camera, along with the board and piece models.

This post wraps the whole thing up in one place. I’ll cover what was built, how it fits together end to end, how it was wired, what I learned and what’s still unfinished. If you want to build your own, every file I mention links straight to the source.

Catching up on the series

If you’re new to the project, here’s the trail of earlier posts on CIAOPS Labs:

The arm itself goes back further. I covered building it in Building a 6DOF Programmable Robot Arm and Robot Arm with Multi Motor Movement.

What was built

At the highest level there are three pieces, and the top-level README lays them out the same way:

  • The arm (project 44). An Arduino UNO R4 WiFi drives six servos directly. It runs the game, hosts its own web pages and talks to Gemini.
  • The camera (project 45). An AI-Thinker ESP32-CAM looks down on the board, works out which cells hold a red X, a yellow O or nothing, and serves that as a nine-character string.
  • The playing field (3d-models/tictactoe). A 180×180 mm printed board and weighted counters designed for the gripper and the camera.

The human always plays yellow O and the robot always plays red X. That isn’t just a style choice. The camera tells the pieces apart by colour, so it matters.

How it was assembled: the component layout

The diagram below is the full layout and connection map, drawn only from what the published firmware and READMEs actually state. Click it for the full-resolution version.

Component layout and connection diagram for the tic-tac-toe robot arm
Component layout and connections, drawn from 44/src/main.cpp, 45/src/main.cpp and the project READMEs. Click for full size.

There are really two kinds of connection here. The first is physical. Six servo signal wires run from the UNO, plus an optional test servo. A USB cable connects the PC to the UNO, and a USB-to-serial adapter is used to program the ESP32-CAM. The second is over the network. The UNO and the camera join the same Wi-Fi, and the UNO asks the camera for the board with a plain HTTP request. There are no signal wires between the UNO and the camera at all.

Here is the servo wiring as it is defined in src/main.cpp (motorPins[] = {0, 1, 2, 3, 4, 5}, auxPin = 11):

MotorUNO R4 pinRole in the firmwareNotes
M0D0Gripper (gripMotor)Home pose = gripper open; closed = open − gripAmount (15°)
M1D1Wrist (wristMotor)Per-square wrist compensation applied at clearance height
M2D2Arm jointName not given in the source
M3D3Lift (liftMotor)Clearance angle 90°; lifts first, lowers last
M4D4Arm jointName not given in the source
M5D5Arm jointName not given in the source
AuxD11Stand-alone test servoNot part of the arm poses; jog it from /calibrate
Servo-to-pin map from the arm firmware.

And the camera side. These are the AI-Thinker module’s own on-board camera connections, set in 45-board-camera/src/main.cpp. You don’t wire them yourself, but they’re worth knowing if you swap boards:

SignalGPIOSignalGPIO
D0 / D15 / 18XCLK (20 MHz)0
D2 / D319 / 21PCLK22
D4 / D536 / 39VSYNC / HREF25 / 23
D6 / D734 / 35SCCB SDA / SCL26 / 27
PWDN32RESETnot used (−1)
AI-Thinker ESP32-CAM pin map used by the camera firmware.

One thing to be upfront about: the repo doesn’t document the servo power supply. It doesn’t say how the servos get their power, whether a shield or external supply sits between the UNO and the servos, or how the camera is mounted above the board. I’ve deliberately left those off the diagram rather than guess. My earlier arm builds used a Keyestudio sensor shield, but this project’s source doesn’t confirm that, so treat power as something you’ll need to size for your own servos.

How it works end to end

A robot turn runs through a fixed sequence, and each step lives in a specific file:

  • Park and look. After the human places a yellow O, the arm moves to its home pose so it’s out of the camera’s view. It waits about 500 ms to settle, then calls GET /board on the ESP32-CAM using board_camera.h. It tries up to three times and needs two identical readings in a row before it trusts the result.
  • Check what changed. camera_board_diff.h compares the camera’s view with the board the arm already knows about. Exactly one new O is accepted as the human’s move. A missing X, no change, or several changes are rejected with a message rather than guessed at.
  • Minimax finds the best moves. tic_tac_toe_rules.h runs a full minimax search and returns every square that is equally best for X, not just one.
  • Gemini picks one. gemini_connection.h sends the board and that list of equal-best squares to Gemini over HTTPS and asks for JSON back, in the form {"square":N}. If Gemini returns a square that isn’t in the minimax set, the firmware rejects it and the arm doesn’t move. The AI adds variety, but it can never make a bad move.
  • The arm moves. It opens the gripper at home, goes to the off-board pickup point and grabs the piece. Then it lifts to clearance, travels, sets the wrist angle for that square, lowers straight down, releases and goes home. If it wins, there’s a victory jig.

The bit I’m happiest with is how the motion is timed. Servo positions are recalculated in a 200 Hz FspTimer interrupt, so a slow Wi-Fi call to Gemini or the camera never freezes the arm halfway through a move. Calibrated poses are stored in EEPROM, so the arm remembers them through a power cycle.

The UNO also serves two web pages. The game page at / is where you play and watch the live output. At /calibrate you can jog each servo, test poses and store the 11 targets: cells 1 to 9, the pickup point and home. The serial commands from the earlier tic_tac_toe.cpp still work too.

The camera

The camera firmware is compact, and you can read the whole thing in one sitting. It captures a 320×240 JPEG, decodes it and averages the colour over the middle half of each of the nine cells, which keeps the grid lines out of the sample. It then sorts each cell into one of three groups:

  • Empty when red minus blue is under 30.
  • Yellow O when green is more than 0.8 times red.
  • Red X otherwise.

It exposes /capture (the photo), /debug (the photo with sample boxes drawn on), /cells (JSON with the colour values) and /board (the nine characters the arm reads, for example X...O....).

Two hardware quirks cost me time and are now written into the code. First, fmt2rgb888 on this board writes bytes in blue-green-red order, not red-green-blue, so byte 2 is red. That was checked on the actual hardware. Second, the camera sees the board rotated 180 degrees, so image index 0 is reported as cell 9. Get either one wrong and the arm will confidently respond to a game nobody is playing.

The printed board and pieces

Repository render of the board with flat counters
Board with flat counters. Repo render: preview.png.

The models README has the details. The board is board.stl / board.3mf at 180×180 mm with 54.67 mm cells. tictactoe_all.3mf packs the board with one X and one O. The one-piece counters (counter_X.stl, counter_O.stl) have a sealed pocket for an M8 washer. You pause the print at Z 5.4 mm, drop the washer in and resume. The extra weight helps the pieces sit flat and release cleanly from the gripper.

Board with tower counters
Tower counters give the gripper something taller to hold. Repo render: preview_tower.png. Files: counter_X_tower.stl, counter_O_tower.stl.
Counters as coloured parts
Print X in red and O in yellow. Repo render: counters_parts.png.

If you’d rather not pause mid-print, the two_part folder has glue-together base and cap versions, including ones sized for a 10 mm washer. The editable source is tictactoe.solidcraft.

Exploded two-part counter
Two-part counter: base, M8 washer and cap. Repo render: preview_exploded.png.

Building it yourself

Both projects are PlatformIO projects with their own platformio.ini (arm, camera). The short version:

  • Copy the templates in 44-tic-tac-toe-arm/include: wifi_secrets_example.h, gemini_config_example.h and camera_config_example.h. Fill in your own Wi-Fi details, Gemini key and URL, and the camera’s address. None of these are committed to the repo.
  • Build and upload the arm with pio run -e uno_r4_wifi -t upload.
  • Copy wifi_secrets_example.h in 45-board-camera/include, then build and upload the camera with pio run -e esp32cam -t upload. The module has no USB port, so use a USB-to-serial adapter and hold IO0 low while resetting.
  • Open /capture and adjust the crop values boardX, boardY, boardW and boardH until the board fills the frame. Then put the camera’s IP address into camera_config.h.
  • Calibrate the 11 poses on /calibrate. This is the step that takes the most patience.

The arm project also has smaller test environments: cell_sequence for a motion-only demo, plus mapping, gemini and tictactoe. They’re handy for testing one layer at a time (cell_sequence.cpp, mapping.cpp, gemini_test.cpp).

What got finished in projects 44 and 45

  • A camera-integrated game firmware, built from main.cpp by default, with a web game page and a calibration page.
  • Motion driven by a timer interrupt, so network calls don’t stall the servos.
  • Minimax as a guard on Gemini’s choices, with a built-in self-test covering wins, draws, invalid moves, openings, forced wins and blocks, and fork defence.
  • Wrist compensation for each square, so pieces arrive straight instead of rotated. README_WRIST_COMPENSATION.md and CALIBRATION_GUIDE.md explain it.
  • Perpendicular descent, so the arm lowers straight down onto a square instead of swinging in at an angle (PERPENDICULAR_DESCENT_FIXED.md).
  • A camera that reads the board, debounced readings and a strict rule for accepting a move.
  • Print-ready board and counters, plus the editable SolidCraft source.
  • A clean public release, with secrets moved into gitignored local headers.

Lessons learned

  • The logic is the easy part. Minimax for tic-tac-toe is a few dozen lines. Getting a hobby servo arm to grab a small piece the same way every time is where most of the hours went.
  • Let the AI choose, but check its answer. Giving Gemini a list of moves minimax has already approved, and rejecting anything outside it, keeps the game interesting without trusting a language model to play perfectly.
  • Keep timing-critical work away from the network. Moving servo updates into a timer interrupt was what made Wi-Fi and the arm work together.
  • Write down your hardware quirks. The BGR byte order and the 180-degree rotation are each one line in the code, and each one would have broken the game without anyone noticing.
  • Small bugs hide in plain sight. For a while the wrist compensation never ran, because the code reset a variable to zero before checking it (CRITICAL_BUG_FOUND_AND_FIXED.md).
  • Never assume the camera is right. Requiring two identical readings and exactly one new piece stops a shadow or a bumped piece from being taken as a move.

What’s left

I said this wraps the project up “for now”, and I meant it. A few things are still open:

  • The camera is still a work in progress. The firmware reads the board, but the colour thresholds and crop are tuned for my lighting and my mount. It needs more testing before I’d call it reliable.
  • Piece holders. The arm currently picks every X from a single pickup point outside the board. The next step is a set of fixed holders off the board, so the arm can collect from several slots in turn without me reloading it.
  • Calibration polish. The wrist compensation values in the code are a calibrated estimate. Each square could be fine-tuned further, and gripper settings are still the main cause of the occasional missed pickup.
  • Power and mounting notes. The repo should eventually document the servo supply and the camera mount, so someone else can reproduce the build without guessing.

What could come next

A few ideas I’d like to try when I come back to it. One is letting the arm clear the board itself at the end of a game. Another is moving from fixed colour thresholds to something that adapts to the lighting. I’d also like to use the camera to check that the arm actually dropped its piece in the right square. And I want to try the same arm, camera and game-logic pattern on a different game. The structure is general: a camera that turns the physical board into a string, a rules engine that defines what’s legal, an AI that chooses within those rules, and an arm that makes it happen.

That’s the real takeaway for me. Each layer is simple on its own. The value comes from joining them up carefully, with checks between each step. If you build your own version, I’d love to see it. Grab the code from my tic-tac-toe folder on GitHub and let me know how you go.

Robot Arm Tic-Tac-Toe: Now Picking Its Own Pieces

Thumbnail from the Robot arm plays tic tac toe video on the CIAOPS Labs YouTube channel

I’ve posted a short video of my robot arm playing tic-tac-toe, and it’s a good snapshot of where the project sits right now. It runs a little over three minutes and shows the arm playing crosses against a human. You can watch it here:

What the arm is doing now

The big step since my earlier posts is that the arm now picks up each of its pieces from a tray at the side of the board. For each move it collects its own piece from that tray, carries it over and places it in the chosen square.

The rest of the setup will be familiar if you have followed along:

  • An Arduino UNO R4 runs the arm code and drives the servos.
  • A web page served by the arm shows each move as the game progresses, and lets the human pick their move.
  • Gemini handles the decision about where the arm moves next.
  • Calibration options on the same web page let me fine-tune positions when the arm isn’t quite where it should be.

I covered the early groundwork in teaching the arm where the board is and the decision logic in playing a full game with minimax and Gemini. In the current project code, minimax works out the equally best moves and Gemini chooses among those. That keeps the move choice sensible. It doesn’t make the mechanics perfect, as the video shows.

The web page matters more than it might seem. It gives the human a simple way to choose their move, and it shows each move as the game goes along. When something goes wrong on the board, having the game state visible on a screen makes it much easier to tell whether the problem was the decision or the mechanics.

The missed pickup

If you watch closely, you’ll notice the arm misses picking up the last piece from the tray. That’s the honest state of a hobby build. The game logic can choose a perfectly good square and the arm can still come up empty if the gripper doesn’t close on the piece properly.

The fix is a minor adjustment so the gripper actually grabs the piece. This is exactly why the calibration options exist on the arm’s web page. Small changes to positions and gripper settings are what turn a mostly working arm into a reliable one.

Why the tray matters

Picking from a tray changes the arm’s job. Placing a piece on the board is only half the move. The arm also has to reach the piece, grip it securely, lift it clear and carry it across without dropping it. Each of those steps adds another place where a few millimetres or a few degrees matter.

That is also what makes it satisfying. When the arm collects its own piece and drops it into the right square, the whole loop feels much closer to playing a real opponent.

Code and next steps

The firmware, the companion camera project and the 3D models for the board and pieces are all in my tic-tac-toe project on GitHub. The immediate job is tuning that gripper so every pickup from the tray is clean. After that, it’s about running more games and seeing where else small calibration tweaks pay off.

If you are building something similar, my advice is simple: expect the logic to be the easy part. Getting a physical arm to grab a small piece the same way every time is where most of the real work hides.

Tic-Tac-Toe Robot Arm Update: Board, Pieces and the Next Pickup Step

Repository render of the board and X/O pieces
Repository render of the board and X/O pieces. This is a model preview, not a photograph of the printed build. Full-resolution image.

The board and playing-piece models are now included in my tic-tac-toe robot arm project on GitHub. The project brings together the arm firmware, the ESP32-CAM companion and the printable game components. The camera is still a work in progress. My next step is to give the arm a set of fixed off-board positions from which it can collect its pieces and place them on the board.

What is in the project

The top-level README groups the work into three folders:

  • 44-tic-tac-toe-arm/ — PlatformIO project for an Arduino UNO R4 WiFi. Six arm servos, with motor 0 as the gripper, drive the poses. Motion is updated on a timer so Wi-Fi and camera HTTP calls do not freeze the servo plan mid-move. The sketch serves game and calibrate pages on the board, and it can ask the camera for a board reading.
  • 45-board-camera/ — Separate PlatformIO project for an AI-Thinker ESP32-CAM with PSRAM. It is meant to serve the board state the arm consumes, including GET /board and related paths such as /capture, /debug and /cells.
  • 3d-models/tictactoe/ — Board and piece meshes, preview images and the SolidCraft source. The print files live here rather than inside the arm firmware folder.

Open the arm and camera folders as separate PlatformIO projects — each has its own platformio.ini. Wi-Fi details, API keys and the camera address stay on the machine as local copies of the *_example.h templates. Those filled-in headers are gitignored.

Board and piece models

The models folder README names the print files and how they fit together:

  • Board: board.3mf and board.stl — 180×180 mm. The 3MF has Board_Base and Board_Grid. Each cell is 54.67 mm square. tictactoe_all.3mf packs the board with one flat X and one flat O.
  • One-piece counters: counter_X.stl and counter_O.stl for the flat pieces, plus tower variants counter_X_tower.stl and counter_O_tower.stl, and matching *_tower_parts.3mf files. The one-piece counters include a sealed pocket for an M8 washer; the README says to pause at Z 5.4 mm, drop the washer, then resume so the top layers close the pocket.
  • Two-part counters: under two_part/, glue-together base and cap STLs and 3MFs, including versions sized for a 10 mm washer.
  • Editable source and previews: tictactoe.solidcraft, plus preview.png (board with flat counters — the image above), preview_tower.png, counters.png, counters_parts.png and the exploded two-part previews.

Print colour matches the game roles in the firmware: the human plays yellow O, the robot plays red X. The camera sketch is written to tell those colours apart, so printing in the right colours matters even while vision is unfinished.

How the current software plays a move

The default arm environment builds src/main.cpp, the camera-integrated game. Other environments in the same project cover a cell-sequence motion demo, board mapping, a Gemini connectivity test and a serial-only tic-tac-toe sketch without the camera client.

For each robot X, the documented sequence is: open the gripper at home, move to the pickup pose, close on the piece, lift clear, travel above the chosen square, orient the wrist while raised, lower, open to drop, lift clear and return home.

Move choice works in two stages. On-device minimax evaluates every legal X move and lists the equally best squares. Gemini then chooses among that list. If the returned square is not in the optimal set, or is occupied or invalid, the firmware rejects it and no motion runs. That check is about which square the robot is allowed to play. It does not, on its own, guarantee gripper grip, wrist calibration or a clean physical place.

Supply is still simple. Alongside the nine cell poses and the home/rest pose, the firmware keeps a single outside-board pickup pose. Before each robot move you need to have an X piece ready at that spot by hand. That is enough to exercise pick-and-place onto the board. It is not yet a set of fixed holders the arm can work through on its own.

Camera: still unfinished

The ESP32-CAM companion is in the tree with an AI-Thinker pinout, documented BGR colour handling, a 180-degree cell remap so image index 0 reports as cell 9, and the endpoints above. When the arm reads the board it parks first, waits briefly, then calls GET /board.

That path is still unfinished. Crop values (boardX, boardY, boardW, boardH) need adjusting after looking at /capture, and the game UI still lets you tap a square yourself if the camera reading is unsure. Treat vision as a work in progress, not a finished autonomous board reader.

Next: fixed off-board piece holders

The next mechanical step is to create designated piece holders at fixed positions off the board. Those holders are not in the models folder yet. The idea is that during play the arm collects each red X from one of those known off-board locations and places it on the relevant board square.

The nine board poses stay for dropping pieces into cells. The new holder poses are separate supply locations. With fixed holders, each robot move can come from a repeatable place: hover above a stocked holder, descend, close the gripper, lift with clearance, travel to the target cell hover pose, descend, open and retreat. Stock is no longer a single manually replenished spot.

When I build that stage I will need to work through the practical details:

  • Keep the board and holders physically fixed relative to the arm base so calibrated poses stay meaningful.
  • Define repeatable poses for each holder and each cell — approach and hover, grasp or drop height, and a lift height that clears the grid walls and other pieces.
  • Calibrate gripper open and close for the printed counters (flat or tower) so the jaws clear both the holder and the board grid.
  • Check reach and collision paths from each holder to each cell, not only the easy square in front of the arm.
  • Track which holders still contain pieces so the next robot move uses a stocked slot.
  • Keep the human’s yellow O pieces out of the robot’s red X supply.

I have not settled holder count or exact geometry yet. A sensible build-and-test order, once the holders exist on the bench, would be: mount the board and holders, extend the calibrate path so each holder pose can be configured, dry-run open and close at each holder without a piece, run real pick-and-place into empty cells from each holder, and only then fold multi-holder supply into a full game. Until that work lands, the single outside-board pickup remains the supply model in firmware, and the camera remains unfinished.

Earlier Labs posts

This sits on top of the earlier series: announcing the tic-tac-toe project, teaching the arm where the board is, playing with minimax and Gemini, and a worked minimax guide.

Links

With the board and piece models in the repo, the next bench job is those fixed holders — so the arm can collect its red Xs from known off-board positions and place them on the board while it plays.

How Minimax Plays Perfect Tic-Tac-Toe: A Worked Guide

A maker at a workbench sketching game trees beside a tic-tac-toe board

In my last post I showed my robot arm playing a full game of tic-tac-toe. A minimax search works out the best moves, Gemini picks one of them, and the arm places the piece. I skimmed over the minimax part there. This post digs into it properly, with worked examples and the real code from the GitHub repo.

To keep myself honest, I wrote a small Python version of the same algorithm, using the same scoring as the arm’s code, and ran it to produce every number in this post.

Numbering the board

The arm’s code numbers the squares 1 to 9, left to right, top to bottom. I’ll use the same layout throughout:

 1 | 2 | 3
---+---+---
 4 | 5 | 6
---+---+---
 7 | 8 | 9

The arm always plays X and I play O. Either of us can go first.

The game tree

Minimax starts from a simple idea. From any position, list every legal move. For each of those moves, list every reply. Keep going until every line ends in a win, a loss or a draw. What you get is a game tree: the current position at the root, every possible future spreading out below it, and finished games at the leaves.

Once you have the tree, you can work backwards from the leaves to the root and find the best move, provided you assume both players always play their best.

The maximiser and the minimiser

Every finished game gets a score from X’s point of view. Positive is good for X, negative is good for O and zero is a draw.

X is the maximiser. On X’s turn, it picks the move with the highest score. O is the minimiser. On O’s turn, it picks the move with the lowest score, because the lowest score for X is the best result for O.

That’s the whole trick. A position’s score is the best score the player to move can guarantee, assuming the opponent also plays perfectly. Scores flow up the tree, alternating between max and min at each level.

Scoring finished games, with a depth twist

If every win scored the same, the search wouldn’t care whether it wins now or in five moves. It would also see no difference between losing next turn and losing much later. So the arm’s code in tic_tac_toe_rules.h adjusts each score by depth, the number of moves played in the search so far:

inline int minimax(Board &board, char player, int depth, int alpha, int beta) {
char winner = board.winner();
if (winner == 'X') return 10 - depth;
if (winner == 'O') return depth - 10;
if (board.full()) return 0;

An X win scores 10 minus the depth, so a faster win scores higher. An O win scores the depth minus 10, so a slower loss is less bad. A full board with no winner scores 0.

The candidate X move itself counts as depth 1. So an immediate win scores 10 − 1 = 9. If O wins straight after, that’s depth 2 and scores 2 − 10 = −8. A loss two moves further away scores −6, and so on. Keep those numbers in mind for the examples.

The algorithm in pseudocode

function minimax(board, player, depth):
if X has won: return 10 - depth
if O has won: return depth - 10
if board is full: return 0
if player is X:
best = -infinity
for each empty square:
play X there
best = max(best, minimax(board, O, depth + 1))
undo the move
else:
best = +infinity
for each empty square:
play O there
best = min(best, minimax(board, X, depth + 1))
undo the move
return best

To pick X’s move, you try each empty square, score it by calling minimax for O’s reply at depth 1, and keep the highest score. The real code also uses alpha-beta pruning, which I’ll come to shortly, but it returns the same answers.

Example 1: the immediate win

X has squares 1 and 2. O has 4 and 5. It’s X’s turn:

 X | X | 3
---+---+---
 O | O | 6
---+---+---
 7 | 8 | 9

Here’s what minimax gives each empty square:

X playsScoreWhy
39Completes the top row. X wins at depth 1.
60Blocks O’s middle row, but X misses its own win. Perfect play from there is a draw.
7−8O plays 6 and wins straight away at depth 2.
8−8Same: O plays 6 and wins.
9−8Same again.

Square 3 scores highest, so X takes the win. This is one of the cases the code’s own self-test checks: the score must be 9, and square 3 must be the only best move.

Look at square 6 too. Without minimax you might call it a “safe” move. Minimax shows it throws away a win.

Example 2: the forced block

O has squares 1 and 2. X has the centre. It’s X’s turn:

 O | O | 3
---+---+---
 4 | X | 6
---+---+---
 7 | 8 | 9
X playsScoreWhy
30Blocks the top row. Perfect play from there is a draw.
4, 6, 7, 8 or 9−8O plays 3 and wins at depth 2.

X has no winning threat of its own, so every move except 3 loses immediately. Minimax doesn’t need a special rule for blocking. It simply sees that every other line ends in a loss.

Example 3: the fork trap

This one surprised me the first time. O has taken opposite corners, 1 and 9. X has the centre. It’s X’s turn:

 O | 2 | 3
---+---+---
 4 | X | 6
---+---+---
 7 | 8 | O

The natural instinct is to grab another corner. Minimax says otherwise:

X playsScoreWhy
2, 4, 6 or 8 (an edge)0Creates an X threat O must answer. Perfect play draws.
3 or 7 (a corner)−6X threatens the diagonal, so O must block in the other corner. That block gives O two winning lines at once (a fork). X can only block one, and O wins at depth 4.

A −6 rather than −8 tells you the loss isn’t immediate. It’s two moves further away, but just as certain. The code’s self-test checks this position too, expecting the four edge squares and only those.

Openings, and why perfect play draws

On an empty board, all nine squares score 0. Whatever X does first, a perfect O can force a draw, and a perfect X can always avoid losing. That’s why the game is famously a draw between two good players.

The replies to an opening are less even. Here are the results for X’s reply when O opens:

O opens onX replies that draw (score 0)X replies that lose
5 (centre)1, 3, 7, 9 (corners)2, 4, 6, 8 (edges), each −4
1 (corner)5 (centre) onlyevery other square, each −4

A −4 means O can force a win at depth 6, three moves deep for each side. Those are exactly the traps that catch casual players. Minimax sees them because it looks all the way to the end of every line.

How big is the tree?

I counted the full tree from an empty board with the Python script:

  • 255,168 possible complete games, if play stops as soon as someone wins.
  • Of those, 131,184 are won by the player who moves first, 77,904 by the second player, and 46,080 are draws.
  • 549,946 positions visited in total, counting the empty board, if you walk every branch without shortcuts.
  • But only 5,478 distinct legal positions, because many move orders reach the same board.
  • For comparison, 9! = 362,880 is the number of ways to fill all nine squares in order. The real game count is lower because many games end early.

Half a million positions is nothing for a PC. On a microcontroller it’s worth trimming, which brings us to pruning.

Alpha-beta pruning, briefly

Alpha-beta pruning gives the same answer as plain minimax while skipping branches that can’t change the result. It tracks two numbers as it searches. Alpha is the best score X can already guarantee, and beta is the best score O can already guarantee. If a branch can’t beat what a player already has elsewhere, there’s no point exploring it. Here’s how the arm’s code does it:

    if (player == 'X') {
      if (score > best) best = score;
      if (best > alpha) alpha = best;
    } else {
      if (score < best) best = score;
      if (best < beta) beta = best;
    }
    // The opponent already has a better line elsewhere. Stop exploring.
    if (alpha >= beta) break;

When I ran the same pruned search in Python, scoring every opening move from the empty board, it visited about 34,000 positions instead of roughly 550,000. Each candidate move still gets its exact score, because the code starts each one with a fresh window of −100 to 100.

Returning every equally good square

This is where the arm’s code differs from most textbook versions. Instead of returning a single best move, bestMovesForX() returns every square that ties for the top score, packed into a bitmask:

    int score = minimax(board, 'O', 1, -100, 100);
    board.cells[square] = 0;
    if (score > result.score) {
      result.score = score;
      result.squares = 0;  // a strictly better score drops the previous ties
    }
    if (score == result.score) result.squares |= uint16_t(1) << square;

Some examples from above:

  • Empty board: all nine squares, mask 0x1FF.
  • Example 1: square 3 only.
  • Example 3: squares 2, 4, 6 and 8.
  • O opens in the centre: squares 1, 3, 7 and 9.

In robotMove() in main.cpp, that list goes to Gemini in the prompt with an instruction to choose only from it. Then the reply is checked: if Gemini names a square that isn’t in the set, the move is rejected and the arm doesn’t budge. When there’s only one right answer, Gemini has nothing to decide. When there are several, it gets some freedom, but it can never pick a losing move.

Wrapping up

Minimax is one of those ideas that looks almost too simple: score the endings, then take turns choosing the max or the min on the way back up. Tic-tac-toe is small enough to search completely, which makes it a great way to see the idea working end to end. The depth adjustment makes the arm win quickly and fight on when it’s behind, and alpha-beta pruning makes it practical on a small board.

If you want to experiment, the code is in the repo. Try changing the scoring, or set up the fork position on your own board and see if you can beat it.

Robot Arm Tic-Tac-Toe: Playing a Full Game with Minimax and Gemini

A maker at a workbench watching a desktop robot arm place a piece on a tic-tac-toe board

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:

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:

  1. Open the gripper at home.
  2. Travel to the off-board pickup point.
  3. Close the gripper on the piece and let it settle.
  4. Lift off the pile and return home, still holding the piece.
  5. Carry it to the chosen square and pause.
  6. Open the gripper to drop the piece.
  7. 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

  1. Copy the two example headers: cp include/wifi_secrets.example.h include/wifi_secrets.h and cp include/gemini_config.example.h include/gemini_config.h.
  2. Put your 2.4 GHz Wi-Fi name and password in wifi_secrets.h.
  3. Put your Gemini API key and model URL in gemini_config.h. The URL is the full https address, including the generateContent path.
  4. Build, upload and open the monitor with pio run -e uno_r4_wifi, pio run -e uno_r4_wifi -t upload and pio device monitor -b 115200.
  5. Calibrate every target on /calibrate and store to flash.
  6. 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.

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.

Next Project: Teaching My Robot Arm to Play Tic-Tac-Toe

Human hand and a desktop robot arm placing pieces on a tic-tac-toe board

It’s time to dust off the robot arm. If you’ve been following along, you’ll know I built the 6DOF Programmable Robot Arm Kit from IDY Production, then spent a fair while getting it to actually work. My next project is to have that arm play tic-tac-toe against me, physically picking up and placing its own pieces on a real board.

The goal

Simple to say, harder to do. I want to sit down at the bench, make my move, and have the arm work out its reply and place its piece in the right square. No screen, no app. Just me, a board and an arm that (hopefully) doesn’t knock everything onto the floor.

Where I’m starting from

The arm has six servos, aluminium plates and a gripper, driven by a Keyestudio UNO R3 board. I learned early that the board can’t power a bank of servos, so they run from my bench top power supply, and I tidied the wiring with a Keyestudio sensor shield. Last time I got it doing a repeating pick-and-place routine, one servo at a time, and then moved on to multi-motor movement, which sent me down the rabbit hole of kinematics. Tic-tac-toe is a good excuse to finally climb back out of that hole with something working.

Why it’s an interesting challenge

Tic-tac-toe is a trivial game for a computer. Everything around it isn’t. Here’s what I think makes it worth doing:

  • Knowing the board. The arm needs to know where I’ve played. That could start as me typing my move in, but the real goal is for it to sense the board itself.
  • Game logic. The game is small enough to solve completely, so the arm should never lose. Whether I write a classic algorithm or experiment with an AI model to choose moves is part of the fun.
  • Pick-and-place accuracy. Nine squares, each only a few centimetres wide. The piece has to land in the middle of the right one, not halfway into the next.
  • Calibration. Hobby servos aren’t precise, and small errors at the shoulder become big errors at the gripper. I’ll need a repeatable way to map squares to arm positions.
  • Vision. Possibly a camera to read the board. I finally had success with the Arducam, so that’s an obvious candidate, though I’m not committing yet.

The rough plan

  1. Fixed positions first. Build a board and a piece supply in fixed spots, then record the servo angles for each of the nine squares and the pick-up point.
  2. Reliable pick and place. Get the arm to move any piece to any square, smoothly and repeatably, using the multi-motor movement I already have.
  3. Add the brain. Write the game logic so the arm can play a full game with me entering my moves manually.
  4. Close the loop. Add sensing, ideally a camera, so the arm can see my move without any help.
  5. Revisit kinematics. If time allows, swap recorded positions for inverse kinematics so the board can move without recalibrating everything.

No doubt each step will take longer than I think. That’s been the pattern with every project here so far.

What I’ll share

As always, I’ll post the wins and the failures as I go: wiring changes, calibration tricks, videos of the arm in action and my code on GitHub alongside the earlier routines. If you’ve built something similar, or have tips on cheap board sensing, let me know. Stay tuned.

Clearing a Blocked Snapmaker Artisan Nozzle: When You Have to Take the Hot End Apart

A maker carefully disassembling a 3D printer hot end on a workbench

There’s a particular sinking feeling when the Snapmaker Artisan starts a print, the nozzle moves happily across the bed, and nothing comes out. I’ve had it happen, and the temptation is to grab tools and start pulling things apart straight away. Don’t. Disassembly is the last resort, and Snapmaker says so itself. But when you genuinely need to go inside the hot end, you want the official steps, in the right order, without improvising. That’s what this post covers.

As I wrote in Why Everyone Needs Basic Technology Troubleshooting Skills, the simple checks come first. That applies to a 3D printer just as much as a laptop.

Symptoms of a blocked nozzle

Snapmaker’s How to replace the nozzle page lists the situations where the nozzle needs attention:

  • The nozzle is clogged and filament can’t be extruded.
  • Filament has leaked and filled the space between the nozzle and the heating block.
  • The nozzle is wrapped in a blob of filament that can’t be cleaned off.

Try the simple fixes first

Before reaching for a screwdriver, try unloading and reloading the filament. The official Dual Extrusion Module quick start guide describes the process: tap Control > Nozzle, select the nozzle, set the temperature and tap Heat. When it’s heated, tap Unload and pull the filament out, then insert the filament again and tap Load until it extrudes.

If filament has wrapped the heating block, Snapmaker’s Remove extruder blob guide says to heat the hot end, remove the blob with tweezers, wipe the block with a clean cloth and replace the silicone sleeve. A note of honesty: I couldn’t find an official Snapmaker cold pull procedure for the Artisan, so I won’t give you one here.

When disassembly is needed

Snapmaker’s How to disassemble the hot end of Dual Extrusion Module guide is for when filament is stuck in the nozzle, heat break or heat sink of the Artisan standard hot end and normal methods won’t clear it. Snapmaker rates it Difficult and warns that delicate parts can be damaged if you get the procedure wrong. Take your time.

Tools you’ll need

  • H1.5 screwdriver (the gray handled wrench provided by Artisan worked well for me) handled
  • 4″ adjustable wrench
  • 8″ adjustable wrench
  • A lighter (used in step 6 of the official guide)
  • High-temperature protective gloves, which the extruder blob guide recommends

Before you start

  1. Power off the machine before removing or refitting anything on the Dual Extruder. The official top cover kit guide says not to plug or unplug anything while the machine is on.
  2. Unload and pull the filament out of the module.
  3. Detach the hot end from the module. According to the extruder blob guide, you remove the print head bottom cover and front door panel, then loosen the bolts securing the lower hot end throat.

Disassembly: the hot side (heat break, heat block and nozzle)

  1. Remove the thermal insulating casing.
  2. Unplug the wires.
  3. Use the H1.5 screwdriver to loosen the two set screws on the heat block.
  4. Pull the heating rod and the thermistor sensor out of the heat block.
  5. Use the H1.5 screwdriver to loosen the set screw on the heat sink, then pull out the heat break.
  6. Clamp the heat block with the 8″ wrench and heat the nozzle with a lighter to melt any filament inside. If you skip this, Snapmaker warns the nozzle may be damaged when you unscrew it.
  7. Keep the heat block clamped with the 8″ wrench and use the 4″ wrench to unscrew the nozzle counterclockwise.
  8. Keep the heat block clamped with the 8″ wrench and use the 4″ wrench to unscrew the heat break counterclockwise.

Disassembly: the cold side (heat sink)

Only go this far if the blockage is higher up.

  1. Use the H1.5 screwdriver to loosen the set screw shown in the official guide.
  2. Clamp with the 8″ wrench and use the 4″ wrench to unscrew the upper throat tube counterclockwise.

The official page has photos for every step, and it’s worth keeping it open on a tablet beside you as you work.

Clearing the blockage

Snapmaker’s guide just says to carefully clean away the blockage once the parts are apart. It doesn’t specify any cleaning tools or methods, so I’d be careful and avoid forcing anything through the delicate parts. If a part is worn or damaged, the blob guide says to contact Snapmaker Support for a replacement rather than refitting it.

Reassembly

Snapmaker doesn’t publish separate reassembly steps for this guide. Its other module guides simply say to reinstall each part following the disassembly steps, so work backwards. For the nozzle itself, the nozzle replacement page (dual extruder method) does give specifics:

  1. Refit the upper throat tube and tighten its set screw, if you removed it.
  2. Screw the heat break back into the heat block.
  3. Point the heat block upwards. Align the nozzle with the hole in the heating block and tighten it clockwise.
  4. Clamp the heat block with one adjustable wrench and the nozzle with the other, then tighten the nozzle again.
  5. Slide the heat break back into the heat sink and tighten its set screw with the H1.5 screwdriver.
  6. Insert the heating rod and thermistor into the heat block and tighten the two set screws.
  7. Plug the wires back in, refit the thermal insulating casing, and refit the silicone cover.
  8. Refit the hot end in the module, tighten the lower throat bolts, and replace the bottom cover and front door panel. Then power on.

Snapmaker doesn’t publish torque values. Its official nozzle replacement video is a useful visual reference.

Testing and calibration afterwards

Don’t skip this step. The quick start guide says that every time you replace the hot end, you should redo the XY Offset Calibration and Z Offset Calibration. After reassembling the module, you should also redo Leveling. Swipe left on the Home Screen and tap Calibration. If the calibration model won’t print, Snapmaker’s Calibration Model printing failed page walks you through what to check. Load filament and confirm it extrudes cleanly before you trust it with a real job.

Prevention tips

  • Snapmaker’s replacement kit includes an upgraded front cover with ventilation holes. Snapmaker says it improves cooling around the hot end and reduces clogs.
  • Make sure the first layer sticks. Snapmaker lists poor first-layer adhesion as a cause of filament build-up around the nozzle.
  • Check that the nozzle and heat break are snug, because Snapmaker notes that a loose fit causes leaks.
  • Keep the Artisan troubleshooting hub bookmarked.

The lesson is the same one I keep coming back to in troubleshooting in general. Follow the documented process, change one thing at a time and verify the result. A little patience with the hot end saves a lot of grief, and a lot of spare parts.

References (official Snapmaker sources)

Print head and included tool used to allow disassembly