Rally is a high-performance robotic table tennis simulation combining deterministic C++ real-time control loops (1kHz physics, 500Hz per-arm) with Python-based LLM orchestration. The system demonstrates ownership arbitration without learning, EKF-based ball prediction (validated against real robot data), and adaptive arm strategy via recursive least squares.
docs/TECHNICAL_WRITEUP.md (3.5k words)docs/PHILOSOPHY.md — read first for contextdocs/TESTING.mddocs/EMBEDDED_NOTES.mdRally splits execution across C++ (real-time) + Python (orchestration), communicating entirely via ZeroMQ to ensure process isolation and determinism.
| Component | Frequency | Purpose |
|---|---|---|
| Physics Arbiter (mujoco_bridge) | 1kHz | MuJoCo forward dynamics, ball EKF, ownership arbitration, human paddle & swing synthesis, rally lifecycle (serve / GAME_OVER / RESUME) |
| Left/Right Arm Loops (inside the bridge) | 500Hz | Ownership-gated interception: EKF plane-crossing → AnalyticalIK → impedance control |
| Arm Controller apps | 500Hz | Standalone LLC shells consuming PlayStyleParams from the bus (live control loops run in-bridge) |
| High-Level Controller (coordination_bus) | 100Hz | ArmStatus intake, RLS strategy updates, LLM StrategyCommand intake, rally-outcome routing |
Key Algorithms:
// All control-path data structures are fixed-size
class EkfBallPredictor {
Eigen::Vector6d state_;
Eigen::Matrix6d P_;
};
| Port | Direction | Payload |
|---|---|---|
| 5556 | bridge → viewer (PUB) | full qpos vector, 1kHz, CONFLATE |
| 5557 | viewer → bridge (PUSH) | PaddleCommand (40B); SERVE / GAME_OVER / RESUME / SHUTDOWN text |
| 5558 | brain → bus (PUSH) | StrategyCommand (32B) |
| 5559 | bridge → bus (PUSH) | RallyOutcome (40B) |
| 5560 | bridge → viewer (PUB) | RallyOutcome tap for the coach |
| 5561 | bus → viewer (PUB) | PlayStyleParams (40B) per arm, on every update |
Watch the demo — dual-arm rallies with ownership arbitration, human paddle play (movement, timed swing, scoring), the 3-2-1 warm-up countdown, live score HUD, and the coach's post-game review in the terminal.
Note on lag: the visible frame stutter comes from running the renderer through WSLg's software rasterizer (llvmpipe) on this machine — a display-stack constraint, not the control system. The physics and control loops run deterministically at 1kHz/500Hz in C++ regardless of frame rate, and a native (non-WSL) build renders this scene smoothly.
# Ubuntu/Debian
sudo apt-get install cmake g++ libeigen3-dev libzmq3-dev
# Python
pip install mujoco llama-cpp-python
mkdir build && cd build
cmake ..
make
# Terminal 1: Physics loop (1kHz)
./mujoco_bridge
# Terminal 2: Ownership arbiter + RLS (100Hz)
./coordination_bus
# Terminal 3 & 4: Per-arm control (500Hz each, with core affinity)
./arm_controller --side left --core 2
./arm_controller --side right --core 3
# View logs
ls logs/rally_telemetry*.log # Binary telemetry from each process
# Terminal 5: Human player viewer (60 FPS + LLM coach)
python viewer.py
The viewer drives a kinematic racket (human_paddle mocap body) over ZMQ. Position it to intercept the incoming ball, then time your swing:
| Key | Action |
|---|---|
← / → | Slide paddle along the table (−X toward robots / +X back) |
↑ / ↓ | Move paddle away from you / toward you (±Y) |
m / n | Raise / lower paddle |
f | Swing (hits only count during the swing's forward phase) |
SPACE | Serve ball |
c | Toggle ball-tracking camera |
t | LLM stress test |
The viewer launches with MuJoCo's UI panels hidden (press Tab to show
them) and overlays a live HUD: session score, rally state, the LLM coach's
current tactical call, and each arm's live RLS parameters.
On launch the viewer freezes the simulation and runs a 3-2-1 warm-up
countdown (shader compile + first slow software-rendered frames happen
before the match starts), then resumes into an opening serve.
RALLY_COUNTDOWN=0 disables it. The robot arms stand on pedestals behind
the table edge, mounted at table height.
Closing the viewer window ends the session: the simulation freezes, the coach writes a review of your match (score, rallies, swing count, and how each arm's RLS parameters adapted), and it prints to the viewer terminal after the shutdown message. The on-window HUD stays minimal — countdown and live score only — since MuJoCo's text overlays are small and stack.
LP_NUM_THREADS) and defaults to
30fps; physics always runs at 1kHz in C++ regardless.GALLIUM_DRIVER=d3d12) is faster but has
frozen entire systems on Intel iGPUs during sustained rendering. It is
therefore opt-in: RALLY_D3D12=1 mujoco_env/bin/python viewer.py — save
your work first. Override either path's fps with RALLY_RENDER_FPS=45../stop_rally.sh
to reap orphaned engine processes.# Fixed-point math for embedded targets (no FPU)
cmake .. -DRALLY_FIXED_POINT=ON
# Include LLM orchestrator + Streamlit (adds Python dependency)
cmake .. -DRALLY_ONLINE_FEATURES=ON
# Unit tests
ctest --output-on-failure
# Integration test (all 4 processes + telemetry validation)
bash tests/integration/test_rls_pipeline.sh
31 commits
C++
94.2%
Python
4.5%
Rally is a high-performance robotic table tennis simulation combining deterministic C++ real-time control loops (1kHz physics, 500Hz per-arm) with Python-based LLM orchestration. The system demonstrates ownership arbitration without learning, EKF-based ball prediction (validated against real robot data), and adaptive arm strategy via recursive least squares.
docs/TECHNICAL_WRITEUP.md (3.5k words)docs/PHILOSOPHY.md — read first for contextdocs/TESTING.mddocs/EMBEDDED_NOTES.mdRally splits execution across C++ (real-time) + Python (orchestration), communicating entirely via ZeroMQ to ensure process isolation and determinism.
| Component | Frequency | Purpose |
|---|---|---|
| Physics Arbiter (mujoco_bridge) | 1kHz | MuJoCo forward dynamics, ball EKF, ownership arbitration, human paddle & swing synthesis, rally lifecycle (serve / GAME_OVER / RESUME) |
| Left/Right Arm Loops (inside the bridge) | 500Hz | Ownership-gated interception: EKF plane-crossing → AnalyticalIK → impedance control |
| Arm Controller apps | 500Hz | Standalone LLC shells consuming PlayStyleParams from the bus (live control loops run in-bridge) |
| High-Level Controller (coordination_bus) | 100Hz | ArmStatus intake, RLS strategy updates, LLM StrategyCommand intake, rally-outcome routing |
Key Algorithms:
// All control-path data structures are fixed-size
class EkfBallPredictor {
Eigen::Vector6d state_;
Eigen::Matrix6d P_;
};
| Port | Direction | Payload |
|---|---|---|
| 5556 | bridge → viewer (PUB) | full qpos vector, 1kHz, CONFLATE |
| 5557 | viewer → bridge (PUSH) | PaddleCommand (40B); SERVE / GAME_OVER / RESUME / SHUTDOWN text |
| 5558 | brain → bus (PUSH) | StrategyCommand (32B) |
| 5559 | bridge → bus (PUSH) | RallyOutcome (40B) |
| 5560 | bridge → viewer (PUB) | RallyOutcome tap for the coach |
| 5561 | bus → viewer (PUB) | PlayStyleParams (40B) per arm, on every update |
Watch the demo — dual-arm rallies with ownership arbitration, human paddle play (movement, timed swing, scoring), the 3-2-1 warm-up countdown, live score HUD, and the coach's post-game review in the terminal.
Note on lag: the visible frame stutter comes from running the renderer through WSLg's software rasterizer (llvmpipe) on this machine — a display-stack constraint, not the control system. The physics and control loops run deterministically at 1kHz/500Hz in C++ regardless of frame rate, and a native (non-WSL) build renders this scene smoothly.
# Ubuntu/Debian
sudo apt-get install cmake g++ libeigen3-dev libzmq3-dev
# Python
pip install mujoco llama-cpp-python
mkdir build && cd build
cmake ..
make
# Terminal 1: Physics loop (1kHz)
./mujoco_bridge
# Terminal 2: Ownership arbiter + RLS (100Hz)
./coordination_bus
# Terminal 3 & 4: Per-arm control (500Hz each, with core affinity)
./arm_controller --side left --core 2
./arm_controller --side right --core 3
# View logs
ls logs/rally_telemetry*.log # Binary telemetry from each process
# Terminal 5: Human player viewer (60 FPS + LLM coach)
python viewer.py
The viewer drives a kinematic racket (human_paddle mocap body) over ZMQ. Position it to intercept the incoming ball, then time your swing:
| Key | Action |
|---|---|
← / → | Slide paddle along the table (−X toward robots / +X back) |
↑ / ↓ | Move paddle away from you / toward you (±Y) |
m / n | Raise / lower paddle |
f | Swing (hits only count during the swing's forward phase) |
SPACE | Serve ball |
c | Toggle ball-tracking camera |
t | LLM stress test |
The viewer launches with MuJoCo's UI panels hidden (press Tab to show
them) and overlays a live HUD: session score, rally state, the LLM coach's
current tactical call, and each arm's live RLS parameters.
On launch the viewer freezes the simulation and runs a 3-2-1 warm-up
countdown (shader compile + first slow software-rendered frames happen
before the match starts), then resumes into an opening serve.
RALLY_COUNTDOWN=0 disables it. The robot arms stand on pedestals behind
the table edge, mounted at table height.
Closing the viewer window ends the session: the simulation freezes, the coach writes a review of your match (score, rallies, swing count, and how each arm's RLS parameters adapted), and it prints to the viewer terminal after the shutdown message. The on-window HUD stays minimal — countdown and live score only — since MuJoCo's text overlays are small and stack.
LP_NUM_THREADS) and defaults to
30fps; physics always runs at 1kHz in C++ regardless.GALLIUM_DRIVER=d3d12) is faster but has
frozen entire systems on Intel iGPUs during sustained rendering. It is
therefore opt-in: RALLY_D3D12=1 mujoco_env/bin/python viewer.py — save
your work first. Override either path's fps with RALLY_RENDER_FPS=45../stop_rally.sh
to reap orphaned engine processes.# Fixed-point math for embedded targets (no FPU)
cmake .. -DRALLY_FIXED_POINT=ON
# Include LLM orchestrator + Streamlit (adds Python dependency)
cmake .. -DRALLY_ONLINE_FEATURES=ON
# Unit tests
ctest --output-on-failure
# Integration test (all 4 processes + telemetry validation)
bash tests/integration/test_rls_pipeline.sh
31 commits
C++
94.2%
Python
4.5%