icon

Building an 18-DOF Hexapod: A 20-Year Dream Finally Walking

Where It All Started

I built my first robots 20 years ago, and they weren't much to look at. Scavenged parts, sometimes literally cardboard, whatever I could get my hands on at the time. Not exactly cutting edge, but that's where it started.

 

image.png
A true junkbot
image.png
Papercraft Wall-E

 

Somewhere along the way I discovered the Phoenix Hexapod project, and that's what really hooked me. There was something almost organic about six legs moving in coordination, more like watching a creature than a machine, and that's the kind of robot I wanted to build. The problem was I had neither the machines nor the skills to pull off something like that myself. So the idea sat in the back of my mind for close to 20 years.

This year I finally had both, so I started building.

 

Building everything from scratch

This hexapod has 18 degrees of freedom, three joints per leg across six legs, and I built the whole stack myself: the electronics, the firmware, the walking logic, all of it. I made a deliberate choice early on not to use existing robotics frameworks like ROS. Not because there's anything wrong with them, but because I wanted to understand every layer myself, from the servo signal all the way up to the gait math. If something breaks, I want to know exactly why.

 

The chassis

I'm not a mechanical designer, so for the chassis I found an existing STL model online instead of drawing everything from zero. I had to adapt it fairly heavily to fit my own electronics, but the original license doesn't allow redistributing modified files, so I can't share my version here. If you want to build something similar, go grab the original file and adapt it to your own setup. The upside is that the code in this project isn't tied to this specific chassis. It's written to work with any 18-DOF hexapod, three joints per leg, so you can pair it with your own mechanical design without rewriting the walking logic. Everything is printed in full PLA-CF from Inslogic, which gives the legs enough rigidity to handle the load without adding unnecessary weight.

 

image.png

 

The electronics

The brain is a Raspberry Pi 3B+, handling higher-level logic, gait generation, vision processing, and connectivity (WiFi, Bluetooth for the controller). It's a good middle ground: enough power for what this build needs without overshooting the budget on something fancier. Real time servo control is handled separately by an ESP32 paired with two PCA9685 boards, which together drive all 18 PWM channels. The Pi talks to the ESP32 over a simple serial protocol. Power-wise, the Pi currently runs off a small powerbank, and I'm still deciding whether to tap into the main LiPo or keep it on the powerbank.

 

image.png
image.png

This split matters. Servo timing needs to be precise and consistent, and a general purpose Linux board juggling other tasks isn't great at guaranteeing that. Offloading it to a dedicated microcontroller keeps the walking motion clean.

 

The fire

I was originally driving the servos with a different board, one built specifically for high channel count servo control. One day, a firmware bug sent all 18 servos into a large, abrupt movement at once, and the board caught fire. I never got a fully confirmed root cause. The leading theories are a current transient from the sheer scale of the movement, or the speed and amplitude of it pushing well past what the board's regulation could absorb in that instant.

image.png

The lesson I took from this, regardless of the exact mechanism: if your firmware can trigger every actuator on a robot into a large movement at the same exact moment, you need to protect against that in software. Don't assume your power budget alone will cover it.

That's what pushed me to move away from that board entirely. The ESP32 plus dual PCA9685 setup I'm running now keeps servo control separate from the failure point, and I make sure uncontrolled movement can't happen by design. My power supply itself is unchanged since then, a single 12A buck converter regulated to 6V feeding the servos, but the control architecture around it is what actually needed to change.

 

The servo precision problem

The hexapod originally ran on 18 MG996R servos, and honestly, they gave me more trouble than I expected. Deadband, vibration, noise, and I even burned out a couple of units along the way. They're common and cheap, which makes them a reasonable starting point, but they weren't holding up to what I needed for stable, precise walking.

I ended up replacing all of them. The six femur joints, which carry the most load during walking, were upgraded to the DFRobot SER0066, a 25kg waterproof servo with a magnetic encoder instead of a standard potentiometer. That encoder is the real upgrade here: no contact wear, finer position resolution, and noticeably cleaner, more stable motion. This upgrade was made possible through a collaboration with DFRobot.

 

image.png

 

The remaining 12 joints (coxa and tibia) are now running DS3218MG servos, which solved the deadband and vibration issues I was having with the MG996R without needing the extra torque or encoder feedback of the SER0066.

 

The software side

Before touching real hardware, I run gaits through a PyBullet simulator to validate them. That's saved me a lot of wasted testing time and a few tip-overs. One example: I originally used a cosine velocity profile for the leg stance phase, which looked fine in theory but caused massive foot slippage in practice because it created conflicting body velocities between legs mid-gait. Switching to a constant linear velocity profile cut that slippage by close to 90%.

 

Features

Right now the robot supports several selectable gaits, a mode for stepping over obstacles, and full control over body pose during walking, meaning it can hold a specific position and orientation of the body while the legs keep moving underneath it. It's got a round GC9A01 display installed to give it a bit of visual personality, and a camera for FPV mode.

The round display isn't showing pre-rendered animations, it's procedurally generated in real time, every blink, pupil movement, and emotional expression is computed on the fly rather than played back from a fixed set of GIFs. That's what lets it feel alive instead of looping.

 

image.png

 

Where it stands now

The robot walks. All 18 joints move in coordination, gaits are validated in simulation before hitting hardware. Body pitch, roll, and height are now adjustable and usable while walking, the eye display expresses emotions, the camera feeds an FPV mode, and a handful of emotes are already wired up.

 

What's next

Cable management right now is honestly a mess, 18 servos and power wiring add up fast, so a custom PCB is next on the list to clean that up and make the whole build more reliable. I'm also planning to add sensors in the legs to enable adaptive walking, adjusting gait and foot placement based on real terrain feedback instead of a fixed pattern.

 

About the code

The code is still a work in progress and isn't public yet, but it's close to be publishable. I want to clean it up and document it properly before sharing it, since it currently only makes sense to me.

 

Follow the journey

Here's the full build, day by day, from the first servo move to a walking 18-DOF hexapod:

 

https://www.instagram.com/reel/DcWk9Nqt2PP/

 

This project started as a way to really understand every layer of a walking robot, from IK math down to power delivery, without leaning on frameworks that would hide the hard parts. It's not finished: sensors, adaptive walking, and a public repo are all still ahead. But it walks, it has a bit of personality, and it's been worth every late night. Thanks for reading, and follow along on Instagram @_deadpohl_ for what's next.

CODE
"""
ik.py  Inverse kinematics for a 3-DOF leg

Direct port of the `solveleg` function validated in the 3D simulator.

Update D (hardware safety):
  - Output angles clamped to the joint limits defined in config.py
  - New 'clamped' field in the result to flag saturation
  - 'valid' = insufficient geometric reach (unchanged)
  - 'clamped' = an angle was clamped -> actual position != requested position
"""

from math import atan2, sqrt, acos, sin, cos, pi, radians, degrees
from config import (
    COXA_LENGTH, FEMUR_LENGTH, TIBIA_LENGTH,
    LEG_MOUNTS,
    joint_limits,
    REST_COXA_DEG, REST_FEMUR_DEG, REST_TIBIA_DEG,
    SERVO_TRIM_DEG,
)


def clamp(value, lo, hi):
    """Clamps value between lo and hi."""
    return max(lo, min(hi, value))


def _wrap_pi(rad):
    """Wraps an angle (radians) into [-pi, pi) (modulo -- +pi itself
    falls back to -pi, same physical angle). Without this, a foot whose
    absolute angle crosses the atan2 cut at +/-180 deg produces a 360 deg
    jump on coxa_relative -- critical on L3/R3 (base_ang +/-147 deg, audit
    2026-08-15: yaw=18 deg -> +32.6 deg, yaw=19 deg -> -99.6 deg, hits the
    limit in a single tick)."""
    return (rad + pi) % (2 * pi) - pi


def _ik_core(leg_index, foot_world, body_height):
    """
    Geometric core of the IK: computes the ABSOLUTE angles (before the
    neutral offset and before clamping) for one leg.

    Returns dict: coxa_deg, femur_deg, tibia_deg (absolute), valid,
                  coxa_angle, coxa_end, femur_angle, shoulder
    """
    fx, fy, fz = foot_world
    mount_x, mount_z, base_ang_deg, group, label = LEG_MOUNTS[leg_index]

    shoulder = (mount_x, body_height, mount_z)

    # Step 1: coxa angle (horizontal rotation)
    dx = fx - shoulder[0]
    dz = fz - shoulder[2]
    coxa_angle = atan2(dz, dx)

    coxa_end = (
        shoulder[0] + cos(coxa_angle) * COXA_LENGTH,
        body_height,
        shoulder[2] + sin(coxa_angle) * COXA_LENGTH,
    )

    # Step 2: femur + tibia (2D problem in the vertical plane)
    dx2 = fx - coxa_end[0]
    dz2 = fz - coxa_end[2]
    h_dist = sqrt(dx2*dx2 + dz2*dz2)
    v_dist = fy - coxa_end[1]
    d = sqrt(h_dist*h_dist + v_dist*v_dist)

    valid = True
    max_reach = FEMUR_LENGTH + TIBIA_LENGTH - 0.5
    d_clamped = d
    if d > max_reach:
        d_clamped = max_reach
        valid = False
    if d_clamped < 1:
        d_clamped = 1
        valid = False

    # Law of cosines
    cos_knee = (FEMUR_LENGTH**2 + TIBIA_LENGTH**2 - d_clamped**2) / (2 * FEMUR_LENGTH * TIBIA_LENGTH)
    knee_angle = acos(clamp(cos_knee, -1.0, 1.0))

    cos_hip = (FEMUR_LENGTH**2 + d_clamped**2 - TIBIA_LENGTH**2) / (2 * FEMUR_LENGTH * d_clamped)
    hip_sub = acos(clamp(cos_hip, -1.0, 1.0))

    reach_angle = atan2(v_dist, h_dist)
    femur_angle = reach_angle + hip_sub
    tibia_angle = pi - knee_angle

    base_ang_rad = radians(base_ang_deg)
    coxa_relative = _wrap_pi(coxa_angle - base_ang_rad)

    return {
        "coxa_deg":    degrees(coxa_relative),
        "femur_deg":   degrees(femur_angle),
        "tibia_deg":   degrees(tibia_angle),
        "valid":       valid,
        "coxa_angle":  coxa_angle,
        "coxa_end":    coxa_end,
        "femur_angle": femur_angle,
        "shoulder":    shoulder,
    }


# Cache of neutral offsets (femur, tibia) per (leg, body_height).
# The rest pose must correspond to 0 deg servo (mid PWM) on all 3 joints.
# The rest coxa is already 0 (coxa - base_ang); only femur/tibia need an
# absolute offset subtracted so that "rest = 0 deg", per the config.py
# contract ("JOINT LIMITS (degrees relative to rest position)").
_NEUTRAL_CACHE = {}


def _neutral_offsets(leg_index, body_height):
    """(femur_abs, tibia_abs) of the rest pose -- subtracted from IK output."""
    key = (leg_index, round(body_height, 3))
    if key not in _NEUTRAL_CACHE:
        rest_foot = get_rest_foot_position(leg_index, body_height)
        rest = _ik_core(leg_index, rest_foot, body_height)
        _NEUTRAL_CACHE[key] = (rest["femur_deg"], rest["tibia_deg"])
    return _NEUTRAL_CACHE[key]


def solve_leg(leg_index, foot_world, body_height):
    """
    Inverse kinematics for a 3-DOF leg.

    Args:
        leg_index:    leg index (0-5) in LEG_MOUNTS
        foot_world:   tuple (x, y, z) target foot position (world coords)
        body_height:  body height (Y of the shoulder center)

    Returns:
        dict with keys:
            coxa_deg, femur_deg, tibia_deg : servo angles (degrees), after clamping
            valid   : bool -- False if the position is out of geometric reach
            clamped : bool -- False if all angles are within their physical limits.
                      True if at least one angle was saturated (approximate
                      position, not exact).
            joints  : dict shoulder, coxa_end, knee, foot (debug)
    """
    core = _ik_core(leg_index, foot_world, body_height)
    coxa_angle  = core["coxa_angle"]
    coxa_end    = core["coxa_end"]
    femur_angle = core["femur_angle"]
    shoulder    = core["shoulder"]
    valid       = core["valid"]

    # ============================================================
    # Neutral offset: femur/tibia are expressed RELATIVE to the rest
    # pose (rest = 0 deg = mid PWM), same as the coxa already is via base_ang.
    # ============================================================
    femur_neutral, tibia_neutral = _neutral_offsets(leg_index, body_height)

    coxa_deg_raw  = core["coxa_deg"]
    femur_deg_raw = core["femur_deg"] - femur_neutral
    tibia_deg_raw = core["tibia_deg"] - tibia_neutral

    # ============================================================
    # "Foot on the ground" rest offset (flat mounting -> software rest).
    # Defaults to 0. Plus a per-servo mechanical trim (horn spline play
    # on the teeth, see config.py).
    # ============================================================
    label = LEG_MOUNTS[leg_index][4]
    coxa_wire_raw  = coxa_deg_raw  + REST_COXA_DEG  + SERVO_TRIM_DEG[f"{label}_coxa"]
    femur_wire_raw = femur_deg_raw + REST_FEMUR_DEG + SERVO_TRIM_DEG[f"{label}_femur"]
    tibia_wire_raw = tibia_deg_raw + REST_TIBIA_DEG + SERVO_TRIM_DEG[f"{label}_tibia"]

    # ============================================================
    # Clamping to physical joint limits (safety update D)
    # ABSOLUTE bounds per servo (final wire degrees) -- see joint_limits()
    # in config.py. Applied AFTER REST+TRIM: the bound depends only on the
    # angle actually sent, so it can't go stale if REST_*_DEG/SERVO_TRIM_DEG
    # change later (see config.py for history).
    # ============================================================
    coxa_lo,  coxa_hi  = joint_limits(label, "coxa")
    femur_lo, femur_hi = joint_limits(label, "femur")
    tibia_lo, tibia_hi = joint_limits(label, "tibia")
    coxa_deg  = clamp(coxa_wire_raw,  coxa_lo,  coxa_hi)
    femur_deg = clamp(femur_wire_raw, femur_lo, femur_hi)
    tibia_deg = clamp(tibia_wire_raw, tibia_lo, tibia_hi)

    # Was any angle saturated?
    clamped = (
        coxa_deg  != coxa_wire_raw  or
        femur_deg != femur_wire_raw or
        tibia_deg != tibia_wire_raw
    )

    if clamped:
        # Useful log during testing -- removed in production if too verbose
        pass  # print(f"[IK CLAMP] {label} coxa={coxa_deg_raw:.1f}->{coxa_deg:.1f} "
              #       f"femur={femur_deg_raw:.1f}->{femur_deg:.1f} "
              #       f"tibia={tibia_deg_raw:.1f}->{tibia_deg:.1f}")

    # Knee position (debug/visualization, computed before clamping)
    knee = (
        coxa_end[0] + cos(coxa_angle) * FEMUR_LENGTH * cos(femur_angle),
        coxa_end[1] + FEMUR_LENGTH * sin(femur_angle),
        coxa_end[2] + sin(coxa_angle) * FEMUR_LENGTH * cos(femur_angle),
    )

    return {
        "coxa_deg":  coxa_deg,
        "femur_deg": femur_deg,
        "tibia_deg": tibia_deg,
        "valid":     valid,
        "clamped":   clamped,
        "joints": {
            "shoulder": shoulder,
            "coxa_end": coxa_end,
            "knee":     knee,
            "foot":     foot_world,
        },
    }


def _body_rotation(roll_deg, pitch_deg, yaw_deg):
    """
    Body-to-world rotation matrix (3x3, lists), rest axes
    (X=forward, Y=up, Z=left), right-hand rule:
        roll+  (around X): the body leans LEFT
        pitch+ (around Z): the nose goes UP
        yaw+   (around Y): rotates CLOCKWISE seen from above
    Application order: yaw -> pitch -> roll (R = Ry.Rz.Rx), rotation
    about the body's CENTER. Conventions confirmed with the user
    on 2026-07-15 (consistent with rotation=+1 clockwise in gait.py).
    """
    r, p, y = radians(roll_deg), radians(pitch_deg), radians(yaw_deg)
    cr, sr = cos(r), sin(r)
    cp, sp = cos(p), sin(p)
    cy, sy = cos(y), sin(y)
    # Ry(yaw) @ Rz(pitch) @ Rx(roll), expanded by hand (no numpy here)
    return [
        [cy*cp, -cy*sp*cr + sy*sr,  cy*sp*sr + sy*cr],
        [sp,     cp*cr,            -cp*sr           ],
        [-sy*cp, sy*sp*cr + cy*sr, -sy*sp*sr + cy*cr],
    ]


def solve_body_pose(dx=0.0, dy=0.0, dz=0.0,
                    roll_deg=0.0, pitch_deg=0.0, yaw_deg=0.0,
                    body_height=None, reach=None, feet_world=None):
    """
    IK for all 6 legs for a generalized (quasi-static) body POSE:
    the body translates (dx, dy, dz, mm) and rotates (roll/pitch/yaw, degrees)
    about its center, while the FEET STAY FIXED on the ground -- each foot
    target is re-expressed in the displaced body frame before being passed
    to solve_leg() (which alone remains responsible for the SERVO_LIMITS_DEG
    clamp and the neutral offsets: nothing is duplicated here).

    Deltas relative to the neutral rest pose (all at 0 = strictly identical
    to solve_leg(get_rest_foot_position()) -- exact round-trip).
    Translations in rest frame: dx+=forward, dy+=up, dz+=left.
    Rotations: see _body_rotation (roll+=leans left, pitch+=nose up,
    yaw+=clockwise seen from above).

    feet_world: 6 foot positions to hold (default: rest positions at
    body_height/reach -- useful later to apply a body pose while the
    feet are somewhere other than rest).

    Returns: list of 6 solve_leg dicts (same keys: coxa/femur/tibia_deg,
    valid, clamped, joints). Check valid/clamped per leg as usual.
    """
    from config import BODY_HEIGHT_DEFAULT
    if body_height is None:
        body_height = BODY_HEIGHT_DEFAULT
    if feet_world is None:
        feet_world = [get_rest_foot_position(i, body_height, reach)
                      for i in range(6)]

    R = _body_rotation(roll_deg, pitch_deg, yaw_deg)
    cx, cy, cz = 0.0, body_height, 0.0   # body center, neutral pose

    results = []
    for i in range(6):
        fx, fy, fz = feet_world[i]
        # World foot -> displaced body frame: p_body = R^T (f - c - t),
        # then re-add the neutral center to stay within solve_leg's input
        # convention (body at (0, body_height, 0), ground at y=0).
        vx, vy, vz = fx - cx - dx, fy - cy - dy, fz - cz - dz
        px = R[0][0]*vx + R[1][0]*vy + R[2][0]*vz + cx
        py = R[0][1]*vx + R[1][1]*vy + R[2][1]*vz + cy
        pz = R[0][2]*vx + R[1][2]*vy + R[2][2]*vz + cz
        results.append(solve_leg(i, (px, py, pz), body_height))
    return results


def get_rest_foot_position(leg_index, body_height=None, reach=None):
    """
    Rest (grounded) foot position for leg leg_index.

    reach: horizontal spread of the foot from the mounting axis (mm).
           None -> config.REST_REACH (default 150mm). A value outside
           the reachable geometric range will produce a 'clamped'/'invalid'
           IK result (already handled and visualized downstream by
           solve_leg).
    """
    from config import BODY_HEIGHT_DEFAULT, REST_REACH
    if body_height is None:
        body_height = BODY_HEIGHT_DEFAULT
    if reach is None:
        reach = REST_REACH

    mount_x, mount_z, base_ang_deg, _, _ = LEG_MOUNTS[leg_index]
    a = radians(base_ang_deg)

    return (
        mount_x + cos(a) * reach,
        0.0,
        mount_z + sin(a) * reach,
    )


if __name__ == "__main__":
    print("IK test with clamping")
    print("=" * 50)
    from config import BODY_HEIGHT_DEFAULT, REST_REACH
    for i in range(6):
        rest = get_rest_foot_position(i, BODY_HEIGHT_DEFAULT)
        result = solve_leg(i, rest, BODY_HEIGHT_DEFAULT)
        label = LEG_MOUNTS[i][4]
        flags = []
        if not result['valid']:   flags.append("OUT OF REACH")
        if result['clamped']:     flags.append("CLAMPED")
        flag_str = " [!] " + ", ".join(flags) if flags else " OK"
        print(f"{label}: coxa={result['coxa_deg']:6.1f} deg  "
              f"femur={result['femur_deg']:6.1f} deg  "
              f"tibia={result['tibia_deg']:6.1f} deg{flag_str}")
License
All Rights
Reserved
licensBg
0