Overview

For our ME 102B final project, we designed and built a cable-actuated air hockey robot. The robot occupies one half of a standard air hockey table. Four BLDC motors mounted at the corners each drive a tensioned cable that meets at a center mallet, so the mallet’s 2D position is controlled by differentially spooling/unspooling each cable. An overhead camera tracks both the puck and the mallet. The mallet position goes through an Extended Kalman Filter (EKF), and the puck state goes through a fast filter and a wall-bounce predictor. Both feed a naive MPC strategy: every 10 ms the robot re-decides whether to defend (block an incoming shot at its predicted intercept) or attack (plan a quintic-spline trajectory that strikes the puck toward the opponent’s goal).

The project was deliberately chosen for the technical depth it forces across mechanical design, electronics, real-time control, computer vision, and motion planning — and because hitting a puck with a robot is fun.

This page walks through the whole pipeline in the order data flows through it: camera → vision → state estimation → prediction and planning → cable kinematics → motor control. Most sections have a live animation that runs the same algorithm, geometry, and constants as the robot code.


The Opportunity

Reflex-training methods for elite athletes are usually repetitive — catching falling objects, pressing buttons on light cues, and similar drills. A high-performance air hockey robot offers a more engaging alternative and removes the need for a human training partner. Hobbyist air hockey robots exist, but none achieve the shot velocity or precision needed to challenge elite athletes. Beyond training, the robot is also a fun standalone recreational platform.

CoreXY air hockey robot Figure 1: An existing CoreXY-gantry air hockey robot (credit: zeroshot).

Robot-arm air hockey Figure 2: Air hockey played using robot arms (credit: Puze Liu et al., arxiv.org/abs/2107.06140).

We chose a cable-driven parallel topology instead of either of the above: it avoids the bulk and inertia of a CoreXY gantry and the cost/calibration burden of two robot arms, while still covering the full half-table workspace at high speed.


High-Level Strategy

The robot occupies one half of the table. Four corner-mounted BLDC motors each drive a cable spool; all four cables converge on the mallet at the center. Rapid differential spooling repositions the mallet across the 2D workspace. The high-level loop runs in Python on the host (Jetson Nano), and each moteus controller closes its own motor loop, connected over CAN. An overhead USB camera (UVC) provides the only absolute measurement of where the puck and mallet are.

Initial vs. Achieved Specifications

SpecTargetAchieved
Mallet strike speed6 m/s800 mm/s (tuning-limited)
Puck speed3.5 m/s~750 mm/s measured
Positioning accuracy±3 mm±3 mm on move-to-position; consistent transient tracking, unquantified
Simulation correlationPredicted shot paths score in real lifeReal-time tracking works; correlation not quantitatively measured
Drive system4× BLDC with belt reduction4× BLDC, 1:1 transmission
SensingCamera + motor encodersSingle overhead camera + encoders, fused via EKF
Control strategyRL agent for mallet placementNaive MPC with quintic-spline trajectories on hardware (RL did not tune in time; Q-learning and PPO agents were later trained in simulation, see below)

The largest gap was strike velocity: tuning the cable-drive loop above ~800 mm/s exposed cable-slack and tension-tracking issues that we never fully resolved in the project window.


Integrated Physical Device

Full robot Figure 3: Full integrated robot.

LabelComponent
AAir hockey table (COTS)
BMallet (sheet-metal + 3D-printed)
C80/20 aluminum-extrusion frame
DCamera mounting subassembly (overhead, off-frame)
EMounting plate with rubber feet (×4)
FCorner assemblies (×4)
GBungee cables (tension preload)

Single corner assembly Figure 4: A single corner module.

LabelComponent
HEncoder (moteus r4.11 onboard)
IMotor (MJ5208 BLDC)
JSpool
KTensioner
LPulley

Fabrication

The corner modules are stacks of waterjet-cut aluminum plates: motor mounts, spool flanges, and side frames, all lightened with pockets. The whole set of plates nests onto one sheet.

Waterjet nesting of the corner-module plates Figure 5: Waterjet layout for every plate in the four corner modules.

Frame and corner modules during assembly Figure 6: The 80/20 base with all four corner modules mounted and spooled with cable, before the table went on.


Function-Critical Calculations

These are the sizing calculations from the design phase. Each one was re-checked against the final geometry in config.py. Where the original hand calculation used a simplification, a more complete model follows it.

Speed Requirement

Primary target: a mallet speed of 6 m/s, chosen to produce a puck speed of ~3.5 m/s. That is fast enough that a human opponent cannot reliably react. Over a 40 in (1.016 m) table, the puck’s travel time is

$$ t_\text{cross} = \frac{L_\text{table}}{v_\text{puck}} = \frac{1.016}{3.5} \approx 0.29\ \text{s} $$

which is only about 40 ms longer than an average human reaction time of 250 ms, leaving almost no time to move once the reaction is complete.

Mallet-to-puck speed. Treating the hit as an instantaneous impact along the contact normal $\hat{\mathbf n}$, conservation of momentum with restitution $e$ gives the puck’s post-impact speed for a puck initially at rest:

$$ v_p^+ = (1 + e)\,\frac{m_m}{m_m + m_p}\,\big(\mathbf v_m \cdot \hat{\mathbf n}\big) \;\approx\; (1 + e)\, v_m \cos\varphi \qquad (m_p \ll m_m) $$

where $\varphi$ is the angle between the mallet velocity and the contact normal. For a head-on hit with $e \approx 0.7$ (the value used in our simulator), the puck leaves at about $1.7\,v_m$. The 3.5 : 6 target ratio of 0.58 is therefore conservative: it still holds for glancing hits up to $\varphi = \arccos(0.58/1.7) \approx 70°$. On hardware we measured roughly 750 mm/s of puck speed from 800 mm/s strikes, a ratio of about 0.94. That is below the head-on ideal, which is consistent with off-center contacts and with cable compliance lowering the mallet’s effective mass at impact.

Motor and Transmission Selection

Each corner motor drives a cable spool through a 1:1 belt (20-tooth to 20-tooth pulley). How fast must a spool turn? Each cable’s rate is the projection of the mallet velocity onto that cable (see the Jacobian), so no cable ever moves faster than the mallet:

$$ \lvert \dot L_i \rvert = \lvert \hat{\mathbf u}_i^\top \dot{\mathbf m} \rvert \le \lVert \dot{\mathbf m} \rVert $$

The worst case is a move straight along one cable, which requires

$$ N_\text{spool} = \frac{V_\text{des}}{\pi \cdot d_\text{spool}} \cdot 60 = \frac{6}{\pi \cdot 0.075} \cdot 60 \approx 1528\ \text{rpm} $$

Motor maximum no-load RPM at supply voltage:

$$ N_\text{max} = K_V \cdot V_\text{PSU} = 330 \cdot 24 = 7920\ \text{rpm} $$

Since $N_\text{max} \geq N_\text{spool}$ with a wide margin, the motor can hit the target mallet speed at 1:1. No gear reduction is required, which keeps the reflected inertia low.

Voltage headroom under load. The no-load figure ignores the voltage consumed by current through the winding. Using a first-order DC model of the motor, the torque constant follows from $K_V$:

$$ K_t = \frac{60}{2\pi K_V} = \frac{60}{2\pi \cdot 330} = 0.0289\ \text{N}{\cdot}\text{m/A} $$

At the required torque of 1.31 N·m (next section), the motor draws $I = T/K_t \approx 45$ A. At the required spool speed $\omega = 160$ rad/s, the back-EMF is $K_t \omega \approx 4.6$ V, so the bus has 19.4 V of headroom. The design remains speed-feasible at full torque as long as the winding resistance satisfies $I R_\text{phase} < 19.4$ V, i.e. $R_\text{phase} < 0.43\ \Omega$. This is a simplified model that ignores the field-oriented-control details, but it shows the 24 V bus is not the binding constraint.

Torque Validation

With mallet mass 0.5 kg, target acceleration 15 m/s², and 10 N cable pretension:

$$ F_\text{acc} = m \cdot a = 0.5 \cdot 15 = 7.5\ \text{N} $$$$ F_\text{total} = (F_\text{acc} + F_\text{pre}) \cdot SF = (7.5 + 10) \cdot 2 = 35\ \text{N} $$

Required motor torque, with the 75 mm spool ($r_\text{spool} = 37.5$ mm):

$$ T_\text{req} = F_\text{total} \cdot r_\text{spool} = 35 \cdot 0.0375 = 1.3125\ \text{Nm} $$

The MJ5208 has a peak torque of 1.7 Nm — sufficient with margin.

This estimate neglects the torque that goes into spinning up the rotor and spool themselves. Including it, each motor must supply

$$ \tau_i = r_\text{spool}\, t_i + \frac{J_\text{rot}}{r_\text{spool}}\, \ddot L_i $$

where $J_\text{rot}$ is the combined rotor, pulley, and spool inertia and $t_i$ is the cable tension. We did not measure $J_\text{rot}$, so the numbers on this page cover the cable term only.

Four-Cable Tension Distribution

The hand calculation above treats the mallet as if a single cable pulls it. In reality all four cables pull at once, at angles that change across the workspace. With $\mathbf e_i = (\mathbf c_i - \mathbf m)/\lVert \mathbf c_i - \mathbf m \rVert$ the unit vector from the mallet toward corner $i$, Newton’s law on the mallet is

$$ \sum_{i=1}^{4} t_i\, \mathbf e_i = -J(\mathbf m)^\top \mathbf t = m_m\, \mathbf a, \qquad t_\text{min} \le t_i \le t_\text{max} $$

The lower bound keeps every cable taut. The upper bound is the motor limit, $t_\text{max} = T_\text{peak}/r_\text{spool} = 1.7/0.0375 = 45.3$ N. That gives two equations in four unknowns, which leaves a two-dimensional family of solutions. We pick the one with the smallest peak tension by solving a small linear program at each mallet position $\mathbf m$ and each acceleration direction:

$$ t^\star(\mathbf m, \mathbf a) = \min_{\mathbf t}\; \max_i t_i \quad \text{s.t.} \quad -J(\mathbf m)^\top \mathbf t = m_m \mathbf a, \;\; \mathbf t \succeq t_\text{min}\mathbf 1 $$

It is only two-dimensional after eliminating two tensions, so we solve it exactly by enumerating vertices. The results for $\lVert \mathbf a \rVert = 15$ m/s² and $t_\text{min} = 10$ N, using the real corner positions:

LocationWorst-case $t^\star$ over all directionsWith $SF = 2$Motor torque
Hand calculation (single cable)17.5 N35 N1.31 N·m
Workspace center19.1 N38.3 N1.44 N·m
Top edge, $\mathbf m = (-311, 195)$ mm, accelerating in $+y$61.9 N124 N4.64 N·m

At the center, the hand calculation is close: the LP needs only 9% more tension, and the motor still has margin. Near the edges it is not close. At the top edge, cables 1 and 2 are nearly horizontal, so pulling the mallet upward takes large cable tension just to produce a small vertical component. Meanwhile, the 10 N preload in the lower cables has to be overcome on top of that.

Flipping the question around gives a map of what the drive can actually do. For each point, we search for the largest acceleration $a_\text{iso}$ that stays feasible in every direction under the peak-torque limit:

defenseattackM1M2M3M4015 ← design target304560a_iso (m/s²)Guaranteed acceleration a_isoin every direction, from the LPwith 10 N ≤ t_i ≤ 45.3 N(1.7 N·m peak / 37.5 mm spool)workspace center 65 m/s²defense line, y = 0 68 m/s²attack line, y = 0 24 m/s²worst cell 5.5 m/s²cells ≥ 15 m/s² 95 %with safety factor 2 (t_i ≤ 22.7 N):center 22 m/s², cells ≥ 15: 16 %worst max tension: 62 N
Figure 7: Guaranteed isotropic acceleration over the safe mallet workspace, from the tension LP with 10 N minimum and 45.3 N maximum cable tension. Blue cells meet the 15 m/s² design target in every direction; orange cells do not. The circle marks the point of highest required tension.

The drive is strong in the middle of the workspace and weak along its edges, especially near the attack line and the top and bottom edges. Applying the safety factor of 2 to the peak-torque limit ($t_\text{max} = 22.7$ N), only 16% of the workspace still guarantees 15 m/s² in every direction. At the attack line, the 10 N preload alone already needs more than 22.7 N in cables 1 and 4, because those cables are nearly parallel to the $y$-axis there and contribute little horizontal force against the left cables. This matches what we saw on the table: the tension and slack problems appeared during fast strikes, which end on the attack line. It also supports the planner’s 80 mm margin from the corners.

Power Budget

Only a small fraction of the motors’ shaft power reaches the mallet as net mechanical power:

$$ P_\text{mallet} = \mathbf f \cdot \dot{\mathbf m} \le m_m\, a\, v = 0.5 \cdot 15 \cdot 6 = 45\ \text{W} $$

A single motor, by contrast, can reach $\tau\omega = 1.31 \cdot 160 \approx 210$ W. The difference is antagonism: because the cables pull against each other, while some motors pay out cable under load and absorb energy, others reel in and supply it. Much of the power circulates between motors through the shared 24 V bus instead of reaching the mallet. The 750 W supply covers several motors at peak at once. The circulating power also means energy regenerated by one motor can be absorbed by the others on the same bus. A supply like the RSP-750-24 cannot sink current, so the case to watch is a moment when every motor decelerates at once.

Bearing Loads

With a 1:1 belt and a 20-tooth pulley of 12.7 mm pitch diameter, the effective (tight-side minus slack-side) belt force needed to transmit the spool torque is:

$$ F_e = T_t - T_s = \frac{2 T_\text{spool}}{d_\text{pulley}} = \frac{2 \cdot 1.3125}{0.0127} \approx 206.7\ \text{N} $$

The original report stopped here and argued the bearing loads were below this value. The shaft actually carries the sum of both belt spans, not their difference. With a 180° wrap, both spans pull in the same direction:

$$ F_\text{belt} = T_t + T_s = F_e + 2T_s $$

so every newton of belt pre-tension $T_s$ adds 2 N of shaft load. In the FBD, the spool shaft rests on two bearings A (left) and B (right). The belt acts at about $\xi_b \approx 0.88$ of the span from A, and the cable acts near mid-span, $\xi_c \approx 0.45$ (both scaled from the CAD in Figure 8). In the worst case, where the two loads line up, statics gives

$$ R_B = \xi_b F_\text{belt} + \xi_c F_\text{cable}, \qquad R_A = (1 - \xi_b) F_\text{belt} + (1 - \xi_c) F_\text{cable} $$

With $F_\text{cable} = 35$ N, bearing B carries $R_B \approx 198$ N at zero belt pre-tension and $\approx 286$ N at $T_s = 50$ N. Bearing A carries much less, about 44 N. Bearing B is the one to size. Its basic rating life is $L_{10} = (C/P)^3 \times 10^6$ revolutions, so 1000 hours at the full 1528 rpm ($9.2 \times 10^7$ rev) needs a dynamic load rating of

$$ C \ge P \left(\frac{L_{10}}{10^6}\right)^{1/3} = 4.5\, P \approx 0.9 \text{ to } 1.3\ \text{kN} $$

for the two pre-tension cases. Standard 12 mm flanged ball bearings are rated in this range or above, and the real duty cycle is far below continuous full speed. So the standard flanged bearings are sufficient, but belt pre-tension, not the transmitted torque alone, sets the margin.

Bearing FBD Figure 8: Bearing free-body diagram.


System Architecture

The software is one perception–planning–control loop with a nominal period of $\Delta t = 10$ ms. Vision runs in its own thread and publishes the latest detections. The main asyncio loop reads them, updates the estimators, re-plans, and sends one CAN command to each of the four motor controllers. Each reply carries the encoder position used on the next tick. A second, slower path streams the game state to an ESP32 touchscreen and takes START / PAUSE / STOP commands back.

PerceptionEstimationPrediction & planningControlHardware / HMIloop period Δt = 10 msOverheadcamera1280×720 MJPGVisionHSV mask · centroidhomography Hparallax correctionMallet EKFconstant velocityx = [x, y, ẋ, ẏ]Puck filterα-filter + jump gate(p̂, v̂)Puck predictorwall reflectionsintercept (y*, t*)Strategy FSMIDLE · DEFENDSTRIKE · RECOVERreceding horizon:re-decided every tick,feasibility-checkedagainst the workspaceTrajectoryquintic splines,rate-limited setpointsCommand lawincremental IK+ cable Jacobianmoteus r4.11 ×4PD position loop+ slack torqueCable drive4 spools → 4 cables→ malletESP32 TFTscore · state ·touch controlsframesz_mz_py*, t*mallet estimate x̂_mmode, contact,strike velocitym_cmdṁ_cmdq_cmdq̇_ffτx̂_mencoder positions q (CAN reply, same round-trip)optical: camera sees mallet + puckUART, line-delimited JSON: puck / mallet / strategy / planned trajectory / score ⇄ START · PAUSE · STOP
Figure 9: The full pipeline. Solid animated arrows run once per 10 ms tick; the dotted optical path closes the loop through the camera, and the encoder path closes it inside each tick. The camera is the only absolute sensor. The encoders are used only for incremental motion, so errors in the cable-length model do not build up.
StageCodeOutputRate
Visionvision.pypuck and mallet $(x, y)$ in mm, validity flags, scorecamera frame rate (requested 100 fps)
Mallet EKFekf_controller.py$\hat{\mathbf x}_m = [x, y, \dot x, \dot y]$ and covariance $P$every tick
Puck filter + predictorair_hockey_player.py$(\hat{\mathbf p}, \hat{\mathbf v})$, intercept $(y^{*}, t^{*})$every tick
Strategy + trajectoryair_hockey_player.py, spline_utils.pymode, $\mathbf m_\text{cmd}$, $\dot{\mathbf m}_\text{cmd}$every tick
Kinematics + command lawkinematics_utils.py$\mathbf q_\text{cmd}$, $\dot{\mathbf q}_\text{ff}$, $\boldsymbol\tau_\text{ff}$ for 4 motorsevery tick
Motor loopmoteus r4.11 firmwarephase currentson-board, kHz
HMIgame_controller_new.py, display_code.inoTFT game view, commandsevery tick (non-blocking)

Electronics

24 V DC powerCAN-FDUSB3-phase motormechanical (1:1 belt)cable(a) Physical layout, top view of the robot halfopponent half →goalM1M2M3M4PSURSP-750-24 (wall)Overhead cameraown mountHostJetson Nano,on the 80/20harness along the frameCorner module ×4MJ5208 + moteus r4.11,helical spoolTensioner pulleycable exit point c_iCable ×4Malletall four cables meet80/20 framebolted to the tableHarness24 V + CAN, all corners(b) Electrical and signal topologyAC mainswall outletRSP-750-2424 V, 750 W supplyPower distributionblock24 V24 V DC busmoteus r4.11ID 1 · drv + encMJ5208 BLDC330 KVSpool 1Ø75 mm, helicalmoteus r4.11ID 2 · drv + encMJ5208 BLDC330 KVSpool 2Ø75 mm, helicalmoteus r4.11ID 3 · drv + encMJ5208 BLDC330 KVSpool 3Ø75 mm, helicalmoteus r4.11ID 4 · drv + encMJ5208 BLDC330 KVSpool 4Ø75 mm, helical3φ1:1 beltMalletposition set by the four cable lengthsHostJetson Nano · Pythonasyncio loop, 100 HzUSB ↔ CAN-FDadapterCAN-FD daisy chain:q_cmd, q̇_ff, τ_ff out;q, q̇, τ back each tickUSB camera (UVC)1280×720 MJPGUSBESP32 + TFTST7796, 320×480touchscreenUSB serial,115200 baud JSON
Figure 10: Power and signal distribution. (a) Physical layout: the wall-powered PSU and the host sit beside the frame, and a harness carrying 24 V and CAN runs along the 80/20 to all four corner modules. (b) Topology: 24 V from the RSP-750-24 goes through a distribution block to four moteus r4.11 controllers, which share one daisy-chained CAN-FD bus to the host. The host also reads the USB camera and talks to the ESP32 touchscreen over USB serial.

First power-on of the drive Figure 11: First power-on test: the RSP-750-24 supply driving the corner modules on the bench before the table and camera were installed.


Perception: Overhead Vision

A single USB camera looks straight down at the table from $h_c = 305$ mm. It captures 1280 × 720 MJPG frames with the driver buffer set to one frame, so the loop always reads the newest image rather than a queued one. Each frame is rotated by −180.6° to undo the mount’s roll, then three steps turn pixels into table coordinates.

1. Color segmentation. The puck is green and the mallet carries a yellow marker. Each is found by thresholding in HSV space, which separates hue from brightness and is far less sensitive to the table’s glare than RGB:

$$ \mathcal M = \big\{ (u, v) \;:\; \mathbf h_\text{lo} \preceq \text{HSV}(u, v) \preceq \mathbf h_\text{hi} \big\}, \qquad \mathcal M \leftarrow (\mathcal M \ominus B) \oplus B $$

with $H \in [35, 85]$ for the puck and $H \in [12, 32]$ for the mallet (OpenCV’s 0–180 hue scale). The morphological opening with a 5 × 5 elliptical element $B$ removes speckle. We keep the largest contour, require an area above 50 px, and take its centroid from the image moments:

$$ (\bar u, \bar v) = \left( \frac{M_{10}}{M_{00}},\ \frac{M_{01}}{M_{00}} \right), \qquad M_{pq} = \sum_{(u,v) \in \text{contour}} u^p v^q $$

2. Homography to table coordinates. The table is a plane, so pixels and table millimetres are related by a projective map $H$ with 8 degrees of freedom:

$$ \lambda \begin{bmatrix} x \\ y \\ 1 \end{bmatrix} = H \begin{bmatrix} u \\ v \\ 1 \end{bmatrix}, \qquad \begin{bmatrix} u & v & 1 & 0 & 0 & 0 & -xu & -xv \\ 0 & 0 & 0 & u & v & 1 & -yu & -yv \end{bmatrix} \mathbf h = \begin{bmatrix} x \\ y \end{bmatrix} $$

At startup the operator clicks the four calibration corners $(\pm 273, \pm 240)$ mm in clockwise order. Each click gives two rows of the system above, so four clicks give the 8 × 8 linear system that cv.getPerspectiveTransform solves (with $h_{33} = 1$). The clicks are saved to calibration.json, so this only has to be done once per camera mount. In practice we warp the whole frame into an 862 × 480 image at exactly 1 px = 1 mm and detect in that rectified image, so a detection at $(u', v')$ is simply $(x, y) = (u' - 431,\ 240 - v')$ mm.

3. Parallax correction. The homography is only exact for points on the table plane. The mallet’s marker sits $h_m = 22.2$ mm above it, so the camera sees it pushed radially outward from the point directly below the lens. By similar triangles:

$$ r_\text{true} = r_\text{seen} \cdot \frac{h_c - h_m}{h_c} = 0.927\, r_\text{seen} $$

At the far edge of the robot’s half ($r \approx 400$ mm) the uncorrected error would be about 29 mm, which is nearly a full mallet radius. A fixed marker-to-center offset of $(-20, +23)$ mm is added after scaling. The puck is only a few millimetres tall, so it needs no correction.

Outlier rejection. The mallet cannot teleport. A new mallet detection more than 60 mm from the previous one is treated as a miss, unless the mallet has been lost for 5 frames in a row, in which case any detection is accepted so the tracker can re-acquire.

cycle the view to see the HSV masks
Figure 12 (animated): The vision pipeline on a simulated frame. Left: the camera's view with the four clicked calibration points. Right: the same detections after the homography, in millimetres. The dashed ring is where the homography alone puts the mallet. The solid disc is where it actually is after the parallax scale.

The real camera feed and its rectified top-down view.

Goal Detection and Scoring

The vision thread also keeps score with a small finite-state machine on the raw puck detection:

  • SEARCHING → TRACKING once the puck is seen for more than 3 consecutive frames. While tracking, the last four positions are buffered.
  • TRACKING → EVALUATE_SCORE after more than 10 consecutive frames without the puck (it has dropped into a goal or been picked up).
  • EVALUATE_SCORE extrapolates the last seen position three frames ahead, $\tilde{\mathbf p} = \mathbf p_k + 3(\mathbf p_k - \mathbf p_{k-3})$. If either $\mathbf p_k$ or $\tilde{\mathbf p}$ lies in a goal’s pixel box, that side scores. After 30 frames it returns to SEARCHING.

The score goes to the touchscreen as a goal event. A game ends at 120 s, and the vision thread also latches a winner if either side reaches 7.


State Estimation

Mallet: Extended Kalman Filter

The mallet state is its position and velocity in table millimetres,

$$ \mathbf x = \begin{bmatrix} x & y & \dot x & \dot y \end{bmatrix}^\top, \qquad \mathbf x_{k+1} = F_k \mathbf x_k + \mathbf w_k, \qquad \mathbf z_k = H \mathbf x_k + \boldsymbol\nu_k $$$$ F_k = \begin{bmatrix} I_2 & \Delta t_k I_2 \\ 0 & I_2 \end{bmatrix}, \quad H = \begin{bmatrix} I_2 & 0 \end{bmatrix}, \quad Q_k = \operatorname{diag}(\sigma_p^2, \sigma_p^2, \sigma_v^2, \sigma_v^2)\,\Delta t_k, \quad R = \sigma_z^2 I_2 $$

with $\sigma_p = 5$ mm, $\sigma_v = 50$ mm/s, and $\sigma_z = 2$ mm for the camera. $\Delta t_k$ is the measured wall-clock time since the previous tick, not the nominal 10 ms, and $Q$ scales with it. A slow tick (garbage collection, a late CAN reply) therefore grows the uncertainty by the right amount instead of by a fixed step. Each tick runs

$$ \begin{aligned} \text{predict:}\quad & \hat{\mathbf x}^- = F_k \hat{\mathbf x}, && P^- = F_k P F_k^\top + Q_k \\ \text{update:}\quad & S = H P^- H^\top + R, && K = P^- H^\top S^{-1} \\ & \hat{\mathbf x} = \hat{\mathbf x}^- + K(\mathbf z_k - H\hat{\mathbf x}^-), && P = (I - K H) P^- \end{aligned} $$

The update runs only when the camera returned a valid mallet detection. During a miss (the mallet hidden under a hand, motion blur, a rejected jump), the filter keeps predicting and $P$ grows. After 10 consecutive misses, the controller stops trusting the prediction and freezes the motors at their current encoder positions until the mallet is seen again.

The filter is initialized from the first valid camera reading with $P_0 = \operatorname{diag}(4, 4, 100, 100)$. If the camera cannot see the mallet within 10 tries, it falls back to encoder forward kinematics (next section). Strictly, both models are linear, so this is a standard Kalman filter. We kept the EKF structure so that a nonlinear measurement, such as the cable-length forward kinematics, can be added as a second update without restructuring the loop.

Figure 13 (animated): The mallet filter tracking a mallet (orange) on a varying-radius loop. Grey dots are raw camera measurements. Blue is the estimate with its 2σ covariance ellipse and velocity arrow. During the shaded occlusions the filter predicts open-loop and the ellipse visibly grows. The noise is exaggerated for visibility; the real camera is about σ = 2 mm.

Puck: Fast α-Filter

The puck gets a different filter because it behaves differently. It changes velocity in an instant at every wall and mallet hit. A constant-velocity Kalman filter tuned for smooth mallet motion would smear each of those impulses over many frames. The puck tracker instead uses exponential smoothing on position and on the finite-difference velocity:

$$ \hat{\mathbf p}_k = (1 - \alpha_p)\,\hat{\mathbf p}_{k-1} + \alpha_p\,\mathbf z_k, \qquad \hat{\mathbf v}_k = (1 - \alpha_v)\,\hat{\mathbf v}_{k-1} + \alpha_v\,\frac{\mathbf z_k - \mathbf z_{k-1}}{\Delta t_k} $$

with $\alpha_p = 0.5$ and $\alpha_v = 0.3$. After a bounce, the old velocity’s weight decays as $0.7^n$, so it is below 10% within about 7 frames. Measurements more than 100 mm from the current estimate are rejected as misdetections.


Cable Kinematics

Inverse Kinematics

Let $\mathbf c_i$ be the exit point of cable $i$ at corner $i$, and $\mathbf m$ the mallet center. After homing, each encoder reads zero at its fully wound position, so the encoder angle is just the cable length divided by the spool circumference:

$$ L_i(\mathbf m) = \lVert \mathbf m - \mathbf c_i \rVert, \qquad q_i(\mathbf m) = s_i \frac{L_i(\mathbf m)}{\pi d_\text{spool}}, \quad d_\text{spool} = 75\ \text{mm} $$

where $s_i = \pm 1$ accounts for which way each spool is wound.

Forward Kinematics

The inverse direction is overdetermined: four lengths, two unknowns. Subtracting the circle equation of cable 0 from that of cable $i$ cancels the quadratic term $\lVert \mathbf m \rVert^2$ and leaves a linear equation:

$$ \lVert \mathbf m - \mathbf c_i \rVert^2 - \lVert \mathbf m - \mathbf c_0 \rVert^2 = L_i^2 - L_0^2 \;\;\Longrightarrow\;\; 2(\mathbf c_i - \mathbf c_0)^\top \mathbf m = (L_0^2 - L_i^2) + \lVert \mathbf c_i \rVert^2 - \lVert \mathbf c_0 \rVert^2 $$

Stacking $i = 1, 2, 3$ gives a 3 × 2 system $A\mathbf m = \mathbf b$, solved in the least-squares sense, $\hat{\mathbf m} = (A^\top A)^{-1} A^\top \mathbf b$. It is only used to initialize the EKF when the camera is blind.

def xy_to_enc(pos):
    """Inverse kinematics: mallet XY (mm) -> encoder positions (rev)."""
    lengths_mm = np.linalg.norm(CORNERS - pos, axis=1)
    return SIGNS * lengths_mm / SPOOL_CIRC_MM

def enc_to_xy(enc):
    """Least-squares forward kinematics: encoder readings -> mallet XY (mm)."""
    lengths_mm = np.abs(enc * SIGNS) * SPOOL_CIRC_MM
    x0, y0 = CORNERS[0]
    l0 = lengths_mm[0]
    A, b = [], []
    for i in [1, 2, 3]:
        xi, yi = CORNERS[i]
        li = lengths_mm[i]
        A.append([2 * (xi - x0), 2 * (yi - y0)])
        b.append((l0**2 - li**2) + (xi**2 - x0**2) + (yi**2 - y0**2))
    return np.linalg.lstsq(np.array(A), np.array(b), rcond=None)[0]

Velocity Jacobian and Cable Tension

Differentiating $L_i$ gives the cable-rate Jacobian. Its rows are the unit vectors from each corner to the mallet:

$$ \dot{\mathbf L} = J(\mathbf m)\,\dot{\mathbf m}, \qquad J(\mathbf m) = \begin{bmatrix} \hat{\mathbf u}_1^\top \\ \vdots \\ \hat{\mathbf u}_4^\top \end{bmatrix}, \quad \hat{\mathbf u}_i = \frac{\mathbf m - \mathbf c_i}{\lVert \mathbf m - \mathbf c_i \rVert}, \qquad \dot{\mathbf q} = \frac{1}{\pi d_\text{spool}}\, S\, J(\mathbf m)\, \dot{\mathbf m} $$

with $S = \operatorname{diag}(s_i)$. By the principle of virtual work, the same Jacobian maps cable tensions $\mathbf t$ to the planar force on the mallet, $\mathbf f = -J^\top \mathbf t$. Cables can only pull, so every achievable force needs $\mathbf t \succeq 0$. $J^\top$ is 2 × 4, which leaves a two-dimensional null space: tension that can be added to all four cables without moving the mallet. That redundancy is why a four-cable planar robot can stay taut anywhere inside the corners, and it is what the bungee preload and the tension bias below are pushing on. The condition number $\kappa(J)$ measures how evenly the cables share motion in each direction. It rises near the edges of the corner polygon, where two cables become nearly parallel, which is one reason the planner keeps an 80 mm margin from the corners.

Drag the mallet, or let it trace a figure-eight.purple shading = κ(J), darker is worse
Figure 14 (interactive): Inverse kinematics and the cable Jacobian using the robot's real corner positions. Cable color shows direction (red paying out, blue reeling in), and line width shows speed. The dashed rectangle is the safe mallet workspace the planner is clamped to.

Why Interpolate in Cartesian Space

Early on we simulated the naive approach: compute the start and end cable lengths and run every motor at a constant rate between them. Because $L_i(\mathbf m)$ is nonlinear, a straight line in joint space is a curve on the table. For a 360 mm diagonal move, the path bows 4.51 mm off the straight line. That is more than our ±3 mm accuracy target, and it gets worse for longer moves. So every trajectory in the final system is planned in Cartesian space and pushed through the inverse kinematics every 10 ms tick.

Early workspace simulator Figure 15: The first kinematic simulator: workspace with four pulleys (left), per-motor cable increments per tick (middle), and end-effector speed (right).

Cartesian path and per-motor cable increments Figure 16: A straight Cartesian move (left) needs cable-length increments per tick that are nonlinear and even change sign (right). Motor 1 goes from reeling in to paying out partway through the move.

Constant-motor-rate path deviation Figure 17: Running each motor at a constant rate instead of interpolating in Cartesian space: the path (blue) deviates up to 4.51 mm from the straight line (dashed).


Prediction and Planning: Naive MPC

We call the planner a naive model-predictive controller. Like MPC, it runs a model of the puck forward in time every tick, commits to a plan based on that prediction, executes one step, and then re-plans with fresh measurements. It is naive because it does not solve an optimization. Each mode’s plan comes from a closed-form spline with hand-set timing, and constraints are handled by rejecting infeasible plans rather than optimizing around them. Because it re-plans every 10 ms, errors in the puck model (no friction, lossless bounces) are corrected continuously instead of building up.

Puck Prediction with Wall Reflections

Between hits the puck moves in a straight line, and each side-wall bounce mirrors it. The predictor steps the puck forward in 5 ms increments and reflects it off the bounce limits $y_\text{min}$ and $y_\text{max}$ (the rail positions inset by the puck radius). The same result has a closed form by “unfolding” the table: reflect the table instead of the puck, and the path becomes a straight line. For a puck at $(x, y)$ moving with $v_x < 0$ toward the defense line $x_d$:

$$ t^{*} = \frac{x_d - x}{v_x}, \qquad \tilde y = y + v_y t^{*}, \qquad s = (\tilde y - y_\text{min}) \bmod 2W, \qquad y^{*} = y_\text{min} + \min(s,\ 2W - s) $$

where $W = y_\text{max} - y_\text{min}$. The intercept $y^{*}$ is then clamped to the mallet workspace. The model ignores friction and the roughly 10% speed loss per bounce, on purpose: re-planning every tick makes the systematic error shrink as the puck approaches.

def predict_intercept(puck_pos, puck_vel, target_x):
    """Find where and when the puck crosses target_x, with wall bounces."""
    x, y = float(puck_pos[0]), float(puck_pos[1])
    vx, vy = float(puck_vel[0]), float(puck_vel[1])
    if abs(vx) < 5.0:
        return None, None
    if (target_x < x and vx > 0) or (target_x > x and vx < 0):
        return None, None
    sim_dt, t = 0.005, 0.0
    for _ in range(1000):                       # up to 5 s ahead
        x += vx * sim_dt
        y += vy * sim_dt
        t += sim_dt
        if y < PUCK_Y_MIN:
            y, vy = 2 * PUCK_Y_MIN - y, -vy
        elif y > PUCK_Y_MAX:
            y, vy = 2 * PUCK_Y_MAX - y, -vy
        if (vx < 0 and x <= target_x) or (vx > 0 and x >= target_x):
            return np.clip(y, MALLET_Y_MIN, MALLET_Y_MAX), t
    return None, None

DEFEND: Shadowing the Intercept

In DEFEND the mallet stays on the defense line $x_d$, 120 mm in front of the goal, and tracks the predicted intercept $y^{*}$. A raw $y^{*}$ jumps around, especially right after a bounce, so it passes through a rate- and acceleration-limited reference before reaching the motors:

$$ v_\text{des} = \operatorname{sat}_{500}\!\big(25\,(y^{*} - y_\text{ref})\big), \qquad v_\text{ref} \leftarrow v_\text{ref} + \operatorname{sat}_{4000\,\Delta t}\!\big(v_\text{des} - v_\text{ref}\big), \qquad y_\text{ref} \leftarrow y_\text{ref} + v_\text{ref}\,\Delta t $$

(units of mm and s). The reference is then low-passed with an exponential moving average and ramped toward at a maximum speed set by the difficulty chosen on the touchscreen: 300, 1200, or 4000 mm/s for easy, medium, and hard. The ramp’s own finite-difference velocity is sent as the velocity feedforward.

STRIKE: Quintic-Spline Attack

When the puck is on our half and slow enough to hit (below 400 mm/s), the planner builds a three-phase strike toward the center of the opponent’s goal $\mathbf g$:

  1. Approach. The contact point $\mathbf c = (x_a, y^{*}(x_a))$ is where the puck is predicted to cross the attack line $x_a = -120$ mm. The strike direction is $\hat{\mathbf d} = (\mathbf g - \mathbf c)/\lVert \mathbf g - \mathbf c \rVert$. A quintic takes the mallet from rest at its current estimate to $\mathbf c$, arriving with velocity $v_s \hat{\mathbf d}$ and zero acceleration, where $v_s = 800$ mm/s.
  2. Follow-through. 20 mm in a straight line at $v_s\hat{\mathbf d}$, so the mallet is still accelerating the puck at contact instead of decelerating into it.
  3. Return. A second quintic from the end of the follow-through back to rest at $(x_d, 0)$, lasting $\max(\lVert\Delta\mathbf p\rVert / 300,\ 0.3)$ s.

The approach duration matches the puck’s arrival time when the prediction exists, but is never slower than the distance requires, and is capped at 0.5 s:

$$ T = \min\!\Big(0.5,\ \min\big(t^{*},\ 0.8\,T_\text{dist}\big)\Big), \qquad T_\text{dist} = \max\!\Big(\frac{\lVert \mathbf c - \hat{\mathbf m} \rVert}{0.7\,v_s},\ 0.15\Big) $$

A quintic is the lowest-order polynomial that can match position, velocity, and acceleration at both ends. That matters here because a jump in commanded acceleration becomes a torque step on all four motors at once, which is exactly what makes a cable go slack. With normalized time $\tau = t/T \in [0, 1]$,

$$ \mathbf p(\tau) = \sum_{k=0}^{5} \mathbf c_k \tau^k, \qquad \mathbf c_0 = \mathbf p_0, \quad \mathbf c_1 = \mathbf v_0 T, \quad \mathbf c_2 = \tfrac{1}{2}\mathbf a_0 T^2 $$$$ \begin{bmatrix} \mathbf c_3 \\ \mathbf c_4 \\ \mathbf c_5 \end{bmatrix} = \begin{bmatrix} 10 & -4 & \tfrac12 \\ -15 & 7 & -1 \\ 6 & -3 & \tfrac12 \end{bmatrix} \begin{bmatrix} \mathbf p_1 - (\mathbf c_0 + \mathbf c_1 + \mathbf c_2) \\ \mathbf v_1 T - (\mathbf c_1 + 2\mathbf c_2) \\ \mathbf a_1 T^2 - 2\mathbf c_2 \end{bmatrix} $$

The 3 × 3 matrix is the inverse of $\begin{bmatrix} 1 & 1 & 1 \\ 3 & 4 & 5 \\ 6 & 12 & 20 \end{bmatrix}$, the end conditions on $\mathbf p$, $\mathbf p'$, and $\mathbf p''$ at $\tau = 1$. Velocity and acceleration in real time are $\mathbf p'(\tau)/T$ and $\mathbf p''(\tau)/T^2$. The whole plan is sampled once at the 10 ms tick rate. If any sample leaves the safe workspace, the plan is rejected and the robot keeps defending.

Figure 18 (interactive): The three-phase strike from the defense position, built by the same spline code as the robot. Velocity and acceleration are continuous across all three phases. Raising v_s or shortening T drives the peak approach acceleration up quickly. At the default settings it is already several m/s², and cable slack at high accelerations is what capped our strike speed in practice.

RECOVER: Freeing a Stuck Puck

Two situations would otherwise stall a game: a slow puck sitting behind the defense line, where the mallet cannot strike it toward the goal, and a puck pinned against a wall by the mallet. The planner detects the second with a counter that increases each tick the puck is within $3(r_p + r_m)$ of the mallet, within 60 mm of a wall, and slower than 80 mm/s. In both cases RECOVER plans a quintic to a standoff point 25 mm behind the puck, pushes through it at 500 mm/s toward the nearer side wall for 80 mm, and returns to defense.

The Strategy State Machine

Each tick, decide_strategy evaluates its guards in a fixed priority order and returns the first mode that applies. STRIKE and RECOVER commit to their precomputed trajectory, but a fast incoming shot ($v_x < -400$ mm/s) preempts them. After any committed trajectory ends, a 0.3 s cooldown stops the robot from immediately re-striking a puck it just hit.

Main menupick difficultyInitializingvision · homing · EKF initIDLEhold (x_d, 0)DEFENDtrack y* on x_dSTRIKEhit, then returnRECOVERpush off the wallPausedhold encoder positionSafety holdfreeze motorsGame overwinner → touchscreenPlayingone guard evaluation per 10 ms tickSTART[difficulty]ready / move_to(x_d, 0)STOP / motors offg₁g₀g₂g₄g₃g₄g₂g₃g₀ from any statePAUSERESUMEg₅g₆120 snew game
Figure 19: Hierarchical state machine for the game controller (outer) and the strategy (inside Playing). Transitions are labeled with guards g₀–g₆, defined in the table below. When the simulation below is on screen, the strategy state it is currently in is highlighted here.
GuardCondition (checked in this priority order)Action on entry
$g_4$trajectory finished, or $v_x < -400$ mm/s while executingstart 0.3 s cooldown, go to DEFEND
$g_1$incoming: $v_x < -30$ mm/s ($-10$ once already defending, for hysteresis). Relaxed to $-400$ when the puck is on our half and slower than 400 mm/s so that STRIKE gets a chancepredict $y^{*}$ at $x_d$, smooth, track
$g_3$cooldown $= 0$ and either the puck is behind the defense line ($x < x_d + 40$ mm, $\lVert\mathbf v\rVert < 200$ mm/s) or the stuck counter exceeds 15 ticks, and plan_recover is feasiblecommit to the push trajectory
$g_2$puck on our half, $\lVert\mathbf v\rVert < 400$ mm/s, $x > x_d + 20$ mm, cooldown $= 0$, and plan_attack is feasiblecommit to the strike trajectory
$g_1$puck on our half, nothing else appliespassive DEFEND at the puck’s $y$
$g_0$puck not visible, or puck on the opponent’s halfreset attack state, hold $(x_d, 0)$
$g_5$ / $g_6$mallet not detected for ≥ 10 frames / mallet re-detectedfreeze motors / resume

This replaces our original hand-drawn state diagram, which is kept below for reference.

Original hand-drawn state diagram Figure 20: The original state diagram from the project report.

Putting It Together

The simulation below runs the complete planner (puck filter, predictor, decide_strategy, plan_attack, and plan_recover), translated line for line from the robot code, against a scripted opponent. The cable drive is modeled as a PD tracker with velocity feedforward and acceleration and speed limits. Watch the state diagram above change as it plays.

click the table to flick the puck toward that point
Figure 21 (interactive): The naive-MPC player defending the left goal. Purple dashes are the predicted puck path from the filtered state, and the purple × is the defense intercept y*. Committed trajectories are drawn red → orange → cyan for approach → follow-through → return, and fade as they are executed. The small crosshair is the commanded setpoint; the orange disc is where the lagging cable drive actually is.

The real debug view on the robot: puck detection with the predicted trajectory overlay.


Low-Level Control

Homing and Spool Calibration

At power-on the encoders know nothing about cable length, so each motor is homed by stall detection. The other three motors hold a light 0.05 N·m tension while the target motor winds in at 1.5 rev/s with a 0.75 N·m torque limit. Once it has moved and then stays below 0.04 rev/s for two consecutive checks, the cable is fully wound and the encoder is re-zeroed. The order is 4 → 2 → 1 → 3, which pairs the diagonals. While motor 1 winds, the mallet is pulled into corner 1, so the travel that motor 3 then measures winding in is the full diagonal. That gives an in-situ spool calibration:

$$ C_\text{spool} = \frac{\lVert \mathbf c_1 - \mathbf c_3 \rVert}{\Delta q_3} $$

The code compares this against the nominal $\pi \cdot 75$ mm and warns if they differ by more than 5 mm, which catches a mis-wound spool or a changed frame before the game starts. The motors then move together to the middle of the workspace.

The homing sequence at startup.

Camera-Corrected Command Law

The obvious approach is to send each motor its absolute inverse-kinematics angle $q_i(\mathbf m_\text{cmd})$. It fails slowly: the spool’s effective radius changes as cable winds onto it, the cable stretches, and homing is only accurate to a few millimetres. All of these put a persistent offset between where the encoders think the mallet is and where it actually is. Instead, each tick computes an incremental inverse kinematics around the camera’s estimate $\hat{\mathbf m}$ and adds it to the encoder position the motor just reported:

$$ \mathbf q_\text{cmd} = \mathbf q_k + G\,\big[\,\mathbf q(\mathbf m_\text{cmd}) - \mathbf q(\hat{\mathbf m})\,\big] - b\,\mathbf s, \qquad \lvert q_{\text{cmd},i} - q_{k,i} \rvert \le 0.2\ \text{rev} $$

Any fixed offset $\boldsymbol\delta$ in the kinematic model appears in both $\mathbf q(\mathbf m_\text{cmd})$ and $\mathbf q(\hat{\mathbf m})$ and cancels to first order, so the loop drives the camera-measured position to the command. In effect this is position-based visual servoing, with the encoders providing only short-horizon incremental motion. $G = 1$ is a move gain, and $b = 0.015$ rev (about 3.5 mm of cable) is a small extra wind on every spool that keeps all four cables preloaded. The per-tick clamp is a safety limit: no single command can ask a motor for more than 0.2 rev (47 mm of cable) at once, no matter what the planner outputs. In DEFEND and IDLE, the encoder commands are also low-passed with $\alpha = 0.3$ to filter EKF noise. STRIKE bypasses the filter because its spline is already smooth.

Velocity and Torque Feedforward

The planned Cartesian velocity is mapped through the Jacobian to a spool-velocity feedforward, $\dot{\mathbf q}_\text{ff} = S J(\mathbf m_\text{cmd})\,\dot{\mathbf m}_\text{cmd} / (\pi d_\text{spool})$. Each moteus then runs its onboard position loop,

$$ \tau_i = k_p\,(q_{\text{cmd},i} - q_i) + k_d\,(\dot q_{\text{ff},i} - \dot q_i) + \tau_{\text{ff},i}, \qquad \lvert \tau_i \rvert \le \tau_\text{max} $$

with the firmware’s gains scaled by 0.7 ($k_p$) and 1.0 ($k_d$). Without the velocity term, the derivative gain would brake the motor in proportion to its own speed, and the mallet would lag every moving setpoint. The torque term is a slack compensator. If a motor’s reported torque drops below 0.1 N·m, its cable is probably slack, so it gets a 0.05 N·m winding bias on the next tick until tension returns:

$$ \tau_{\text{ff},i} = \begin{cases} 0.05\ \text{N}{\cdot}\text{m} \cdot \sigma_i & \lvert \tau_i \rvert < 0.1\ \text{N}{\cdot}\text{m} \\ 0 & \text{otherwise} \end{cases} $$

where $\sigma_i$ is the per-motor torque sign. All four set_position calls go out concurrently with query=True, so one CAN round-trip both sends the command and returns the positions, velocities, and torques used in the next tick.

One corner module under closed-loop control: spool, pulley, and cable in motion.


Human–Machine Interface

An ESP32 drives a TFT touchscreen that shows the live puck and mallet, the current strategy, the planned strike trajectory, and the score. It also provides the difficulty menu and START / PAUSE / STOP. The host talks to it over UART with one JSON object per line. The per-tick callback in the main loop only enqueues a message for a writer thread and polls a command queue, so the display adds a few hundred microseconds per tick at most.

Non-Blocking UART Protocol (HMI side)

void broadcastMessage(const char *json)
{
    Serial.println(json);
}

void checkSerialIncoming()
{
    static char buf[1024];
    static int buflen = 0;

    while (Serial.available())
    {
        int c = Serial.read();
        if (c < 0) break;

        if (c == '\n' || c == '\r')
        {
            if (buflen > 0)
            {
                buf[buflen] = '\0';
                if (buf[0] == '{')
                    processIncomingMessage(buf);
                buflen = 0;
            }
        }
        else if (buflen < (int)sizeof(buf) - 1)
        {
            buf[buflen++] = (char)c;
        }
        else { buflen = 0; }
    }
}

Line-delimited JSON in, line-delimited JSON out. The UART read loop never blocks — partial lines accumulate in a static buffer, complete lines hand off to processIncomingMessage, and over-length lines are discarded.

TFT Coordinate Mapping

int16_t mmToPxX(float x_mm)
{
    float pad = 20.0f;
    return TABLE_PX_X + (int16_t)((x_mm - (TBL_X_MIN - pad)) /
           ((TBL_X_MAX + pad) - (TBL_X_MIN - pad)) * TABLE_PX_W);
}

int16_t mmToPxY(float y_mm)
{
    float pad = 20.0f;
    return TABLE_PX_Y + (int16_t)(((TBL_Y_MAX + pad) - y_mm) /
           ((TBL_Y_MAX + pad) - (TBL_Y_MIN - pad)) * TABLE_PX_H);
}

Flicker-Free Dynamic Rendering

void eraseAllDynamic()
{
    if (prevPuckPX >= 0)
        tft.fillCircle(prevPuckPX, prevPuckPY, 8, TFT_BLACK);
    if (prevMalletPX >= 0)
        tft.fillCircle(prevMalletPX, prevMalletPY, 10, TFT_BLACK);
    redrawTableLines();
}

void drawAllDynamic()
{
    if (puckValid)
    {
        int16_t px = mmToPxX(puckX);
        int16_t py = mmToPxY(puckY);
        tft.fillCircle(px, py, 6, COL_PUCK);
        tft.drawCircle(px, py, 7, 0x0400);
        prevPuckPX = px;
        prevPuckPY = py;
    }
    // ... [mallet and trajectory drawing]
}

Erase-old-then-draw-new pattern in two phases. The whole TFT is never cleared; only the dirty regions get blanked, so we avoid the perceived flicker of tft.fillScreen()-per-frame.

JSON State Parsing

void processIncomingMessage(const char *buf) {
    char type[16] = "";
    jsonString(buf, "type", type, sizeof(type));

    if (strcmp(type, "state") == 0) {
        puckX = jsonFloat(buf, "px", puckX);
        puckY = jsonFloat(buf, "py", puckY);
        puckValid = (jsonFloat(buf, "pv", 0) > 0.5f);
        malletX = jsonFloat(buf, "mx", malletX);
        malletY = jsonFloat(buf, "my", malletY);
        malletValid = (jsonFloat(buf, "mv", 0) > 0.5f);
        jsonString(buf, "strategy", strategy, sizeof(strategy));
        trajLen = jsonFloatArray(buf, "traj", trajX, trajY, MAX_TRAJ_PTS);

        if (currentScreen == SCREEN_GAME && !goalBannerActive() && !winnerActive)
            updateGameView();
    }
    // ... [score and goal parsing]
}

Hand-written non-allocating JSON helpers — no Arduino-side dynamic memory, no ArduinoJson dependency.

TFT HMI screen showing live game state — puck, mallet, and planned trajectory.

The full FSM and game-control code lives on GitHub: https://github.com/thomaszyu/me102b.


Reinforcement Learning in Simulation

Our original spec called for an RL agent to place the mallet, but we shipped the naive MPC instead because the RL player did not tune in time. After the course I went back to it. I built a simulator from the robot’s own calibration and trained two agents: tabular Q-learning as the naive baseline, and PPO (Proximal Policy Optimization). Everything in this section runs in simulation. Both policies are wired into the robot code behind a flag, but neither has been run on hardware yet.

Interactive replay: 30-second rallies against a scripted shooter. The robot defends the left goal (orange mallet, arrow = chosen action). Switch between PPO, Q-learning, and a hand-coded goalie. Open full screen for the sweep tables and curves.

The Simulator

The sim is a vectorized 2D model written in NumPy that runs 512 tables in parallel. Table geometry is loaded from the same table_calibration.json and config.py the real robot uses:

QuantityValue
Puck-center range$x \in [-407, 419]$ mm, $y \in [-217, 204]$ mm
Goal mouth$\lvert y \rvert < 80$ mm at each end wall
Safe mallet workspace$x \in [-381, -101]$ mm, $y \in [-212, 195]$ mm (corners inset 80 mm)
Defense line$x = -262$ mm
Physics tick / decision rate$\Delta t = 10$ ms / one action every 3 ticks (~33 Hz)

Puck. The puck flies with light air drag, $\mathbf{v}_{k+1} = (1 - c_d \Delta t)\,\mathbf{v}_k$ with $c_d = 0.15\ \text{s}^{-1}$. It reflects off the walls with restitution $e_w = 0.9$ unless it crosses an end wall inside the goal mouth. The mallet is treated as infinitely massive. When the puck overlaps it ($\lVert \mathbf{p} - \mathbf{m} \rVert < r_p + r_m = 55$ mm), the puck is pushed back to contact and receives the normal impulse

$$ \mathbf{v}^{+} = \mathbf{v} - (1 + e_m)\,\min\!\big(0,\ (\mathbf{v} - \dot{\mathbf{m}})\cdot\hat{\mathbf{n}}\big)\,\hat{\mathbf{n}}, \qquad \hat{\mathbf{n}} = \frac{\mathbf{p} - \mathbf{m}}{\lVert \mathbf{p} - \mathbf{m} \rVert},\quad e_m = 0.7 $$

Mallet. The policy picks one of 10 discrete actions: hold, one of 8 compass directions at $v_{\max} = 700$ mm/s, or “home” (a P-controller back to the defense spot). The mallet tracks the desired velocity $\mathbf{v}^\star$ under an acceleration limit, which stands in for the cable drive:

$$ \dot{\mathbf{m}}_{k+1} = \dot{\mathbf{m}}_k + \operatorname{sat}_{a_{\max}\Delta t}\!\big(\mathbf{v}^\star - \dot{\mathbf{m}}_k\big), \qquad a_{\max} = 5000\ \text{mm/s}^2 $$

The new position is then clamped to the safe workspace.

Opponent. A scripted shooter fires from the far half at 300–1100 mm/s, aiming anywhere within $\pm 1.4\times$ the goal half-width, so some shots miss on their own. 30% of shots are bank shots, aimed at the mirror image of the goal across a side wall. Another 15% are slow drifters (80–250 mm/s) that the robot should go and attack.

Reward. An episode is one shot:

EventReward
Robot scores$+1$
Robot concedes$-1$
Puck cleared to the far wall without a goal$+0.3$
First contact with the puck$+0.1$
5 s timeout with the puck still on our half (“stalled”)$-0.3$
Every decision step$-0.002$

For tuning, I scored every policy on a fixed set of evaluation shots with $J = P(\text{score}) + 0.3\,P(\text{clear}) - P(\text{concede})$.

Method 1: Tabular Q-Learning

Q-learning estimates the optimal action-value function, which satisfies the Bellman optimality equation

$$ Q^\star(s, a) = \mathbb{E}\left[\, r + \gamma \max_{a'} Q^\star(s', a') \;\middle|\; s, a \right] $$

The “naive” part is the state: a lookup table over coarse, mallet-relative bins. $\phi(s)$ discretizes the puck’s offset from the mallet $(\Delta x, \Delta y)$ into 7 × 7 bins, puck $v_x$ into 5 bins (fast incoming → moving away), puck $v_y$ into 3, and the mallet’s own position into a 3 × 3 grid. That gives $7 \cdot 7 \cdot 5 \cdot 3 \cdot 3 \cdot 3 = 6{,}615$ states × 10 actions, and training visited 6,074 of the states. Actions are ε-greedy, with ε decaying linearly from 1.0 to 0.05. The update is one-step TD:

$$ Q(s, a) \leftarrow Q(s, a) + \alpha \Big[\, r + \gamma\,(1 - d)\max_{a'} Q(s', a') - Q(s, a) \Big] $$

where $d$ flags a terminal transition. With 512 tables stepping at once, many transitions in a batch land in the same $(s, a)$ cell. Summing their updates would overshoot, so the batch applies the mean TD error per cell:

$$ Q(s,a) \leftarrow Q(s,a) + \alpha \cdot \frac{1}{\lvert B_{s,a} \rvert} \sum_{i \in B_{s,a}} \delta_i $$

Sweep. I trained 8 configurations for 25,000 decision steps each (× 512 tables), then retrained the best one for 80,000 steps:

$\alpha$$\gamma$ε-decay fraction$J$SavesScoredStalled
0.050.990.8+0.39294.8%30.1%17.0%
0.050.950.4+0.33991.7%28.5%17.7%
0.050.950.8+0.32489.8%28.2%13.6%
0.20.990.4+0.31689.6%28.2%15.5%
0.20.950.4+0.30388.3%28.1%13.7%
0.050.990.4+0.29687.8%27.2%12.1%
0.20.950.8+0.20681.3%26.8%12.8%
0.20.990.8+0.15879.5%24.8%16.2%

The smaller learning rate took the top three spots, and the two worst runs both paired $\alpha = 0.2$ with the long exploration schedule. With 512 tables writing into one Q-table, a large step size makes the estimates noisy, and a long stretch of mostly random play feeds that noise.

Method 2: PPO

PPO replaces the table with two small networks. Both are 64 × 64 tanh MLPs with orthogonal initialization and read a continuous 10-D observation: puck position, puck velocity, mallet position, mallet velocity, and the puck-minus-mallet offset, each scaled to roughly unit range. The actor $\pi_\theta(a \mid s)$ outputs a categorical distribution over the same 10 actions, so it drops into the robot the same way as the Q-table. The critic $V_\psi(s)$ estimates the value of a state.

Each iteration rolls out 64 steps on all 512 tables (32,768 transitions), then computes advantages with Generalized Advantage Estimation:

$$ \delta_t = r_t + \gamma (1 - d_t)\, V_\psi(s_{t+1}) - V_\psi(s_t), \qquad \hat{A}_t = \sum_{l \ge 0} (\gamma \lambda)^l\, \delta_{t+l} $$

The policy is updated with the clipped surrogate objective, where $\rho_t$ is the probability ratio between the new and old policy:

$$ \rho_t(\theta) = \frac{\pi_\theta(a_t \mid s_t)}{\pi_{\theta_\text{old}}(a_t \mid s_t)}, \qquad L^{\text{CLIP}}(\theta) = \mathbb{E}_t\Big[ \min\big( \rho_t \hat{A}_t,\ \operatorname{clip}(\rho_t, 1 - \epsilon, 1 + \epsilon)\,\hat{A}_t \big) \Big] $$

The full minimized loss adds a value regression term and an entropy bonus that keeps exploration alive:

$$ \mathcal{L}(\theta, \psi) = -L^{\text{CLIP}}(\theta) + c_v\, \mathbb{E}_t\Big[\tfrac{1}{2}\big(V_\psi(s_t) - \hat{R}_t\big)^2\Big] - c_e\, \mathbb{E}_t\big[\mathcal{H}[\pi_\theta(\cdot \mid s_t)]\big], \qquad \hat{R}_t = \hat{A}_t + V_\psi(s_t) $$

Fixed settings: $\gamma = 0.99$, $\lambda = 0.95$, $\epsilon = 0.2$, $c_v = 0.5$, 4 epochs over minibatches of 4,096, advantages normalized per minibatch, gradient norm clipped at 0.5, and Adam with a linearly annealed learning rate.

Sweep. I ran 4 configurations for 150 iterations each, then retrained the best one for 500 iterations (~2 minutes on a laptop CPU):

Learning rateEntropy coef $c_e$$J$SavesScoredStalled
1e-30.003+0.80399.1%74.2%1.4%
1e-30.02+0.72998.2%67.0%5.8%
3e-40.003+0.54898.3%39.0%1.1%
3e-40.02+0.47997.5%34.9%11.1%

Results

Each policy was evaluated greedily (always taking its best action) on the same 10,000 held-out shots. The hand-coded goalie is a simplified version of our DEFEND logic: sit on the defense line at the predicted intercept and poke slow pucks forward.

Outcome comparison Figure 22: Where each shot ends up. Right column is save rate.

Player$J$SavesScoredClearedStalledConceded
PPO+0.87099.6%82.7%15.9%1.0%0.4%
Tabular Q-learning+0.37793.9%29.1%49.2%15.6%6.1%
Hand-coded goalie+0.453100.0%29.9%51.4%18.7%0.0%
Random−0.20562.0%11.3%20.7%29.9%38.0%

Training curves Figure 23: Outcome rates during training, including exploration. Left: Q-learning (ε-greedy). Right: PPO (sampling from the policy).

What the numbers say.

  • Q-learning learns to defend but not to aim. It went from random play (38% conceded) to 94% saves, and it scores at about the same rate as the hand-coded goalie. With 7 × 7 position bins it cannot tell a shot that will go in from one that will hit the post, so it mostly just clears the puck. It also leaves the puck stalled on its own half 16% of the time, because the table has no velocity state for the mallet and no fine position information near the puck.
  • PPO learns to aim. With continuous inputs and mallet velocity, it lines up angled shots and bank shots into the goal. By about 10,000 rollout steps it scores on roughly three-quarters of shots, and it almost never stalls. In the 30-second replays, where the shooter returns every puck that is not already on target, PPO scores 8 to 13 goals per rally, against 0 to 5 for the other two players.

Caveats.

  • The shooter never defends during training. PPO’s 83% scoring rate is against an open net. A real opponent, or self-play, will cut it substantially.
  • This is simulation only. The contact model, cable dynamics, camera latency, and puck tracking noise are all idealized. A policy this precise may be more sensitive to those gaps than the coarse Q-table is.

Deploying the Policy

Both policies share one module (rl_core.py) with the simulator: geometry, state encoding, action set, and mallet model. That way training and deployment cannot drift apart. The PPO actor is exported as plain NumPy weights, so the Jetson runs the forward pass without PyTorch. On the robot, rl_player.py picks an action every 3 ticks and integrates the same acceleration-limited model to produce a smooth position and velocity command. The command then goes through the existing inverse kinematics and cable Jacobian. To keep the open-loop command from running away from a lagging cable drive, it is leashed to the EKF mallet estimate $\hat{\mathbf{m}}$:

$$ \mathbf{m}_\text{cmd} \leftarrow \hat{\mathbf{m}} + (\mathbf{m}_\text{cmd} - \hat{\mathbf{m}}) \cdot \min\!\left(1,\ \frac{80\ \text{mm}}{\lVert \mathbf{m}_\text{cmd} - \hat{\mathbf{m}} \rVert}\right) $$

Setting RL_POLICY = 'ppo' (or 'q') in air_hockey_player.py swaps the learned player in for decide_strategy. The natural next steps are to test on the table, train against a defending opponent or through self-play, and add domain randomization over restitution, latency, and tracking noise.


Reflection

What Worked Well

Our team kept strong CAD progress across all subsystems and executed manufacturing efficiently — the table was finished well ahead of schedule. Nearly everything worked first try, which is a testament to how thoroughly we planned before cutting parts. The one minor hiccup was a delay on the safety shields due to broken 3D printers, but it didn’t impact the overall timeline. We structured the workflow so simulation and computer-vision development ran in parallel with physical manufacturing, which meant the software side was never bottlenecked waiting on hardware.

What We Would Change

We would not change much. The main thing we’d add is a strict code freeze before demos — on showcase day we had a last-minute merge conflict resolved incorrectly that deleted parts of our code; in hindsight an easy fix, but a stressful one in the moment.

The other change is a better cable tension detection and maintenance system. This was the primary source of our control error and the reason we could not run the motors at higher speeds. A mechanical system to detect cable slack and ensure correct spool/unspool behavior at all times would have unlocked much higher mallet velocities. Even without it, the robot was quite good in the end.


CAD Renderings

Full robot CAD render Figure 24: Full robot — rendered CAD model of the integrated system.

Frame CAD render Figure 25: 80/20 aluminum-extrusion frame — rendered CAD model.

Corner assembly CAD render — isometric Figure 26: Corner assembly — isometric CAD view (motor, spool, tensioner, pulley).

Corner assembly CAD render — top Figure 27: Corner assembly — top CAD view.

Camera subassembly CAD render — view 1 Figure 28: Overhead camera mounting subassembly — CAD view 1.

Camera subassembly CAD render — view 2 Figure 29: Overhead camera mounting subassembly — CAD view 2.

Original hand-drawn layout and wiring sketch Figure 30: The original hand-drawn layout and wiring sketch from the project report, redrawn as Figure 10.

Gameplay

Playing against the robot — full gameplay demo.

Project Documents


Bill of Materials (Summary)

Full BOM is in the linked P4B BoM PDF. Highlights:

Sub-assemblyNotable ItemsCost
Table & frameCOTS air-hockey table, 80/20 extrusion, sheet-metal mallet~$135
Corner assemblies (×4)MJ5208 BLDC, moteus r4.11, 12 mm REX shafts, flanged bearings~$200
TensionersGoBilda pulley brackets and extrusion~$80
ElectronicsRSP-750-24 PSU, power distribution block, CAN cables, Jetson Nano~$520
Vision2× USB 2.0 UVC camera modules~$36
Hardware (fasteners)M2 / M3 / M4 / M6 / M8 SHCS, nuts, washers~$135
Total~$1,108

Acknowledgments

Thanks to the ME 102B instructors and shop staff for fifteen weeks of guidance, and to the open-source maintainers behind moteus, OpenCV, and the Jetson Nano ecosystem. Thomas Yu, Athul Krishnan, Eric Yamaguchi, and Larry Hui contributed across all sub-systems; this was a four-way collaborative build.