Simulation
Drive every mechanism on your robot from the browser with the built-in dashboard, then add full-field physics with maple-sim when you want it.
Table of contents
Built-in mechanism dashboard (SimDashboard)
SimDashboard is the primary way to simulate and drive your mechanisms. You register any CatalystMechanism and the dashboard renders a live, fitting widget for it and lets you drive it from a web page. There is no per-robot HTML to write and nothing to add to your build.
It works by reading each mechanism’s describe() method, which returns a MechanismView (name, kind, value, setpoint, range, velocity, current, and kind-specific extras). The dashboard picks a widget from the kind field, so a linear actuator gets a travel bar, a flywheel gets a speed readout, a claw gets a grip-state chip, and a team’s own subclass gets a widget too as long as it overrides describe(). The base CatalystMechanism.describe() returns a generic view, and every built-in mechanism overrides it.
Three properties make it safe to leave in shared robot code:
- Dependency-free. It serves a single page from the JDK’s built-in
com.sun.net.httpserver.HttpServer. No vendordep, no extra Gradle entry. - Sim-only.
start()andupdate()return immediately whenRobotBase.isSimulation()is false, so the exact same calls do nothing on a real robot. You do not need to guard them. - Thread-safe. The HTTP server runs on its own threads, but browser input is never executed there. Every button, slider, and toggle is queued and run on the main (scheduler) thread inside
update(), anddescribe()is only ever called there too. So it is always safe to schedule aCommandor mutate robot state from a control binding.
The default port is 5805. Pass a port to the constructor to change it.
Usage
Construct one SimDashboard, register each mechanism with add(...), attach optional controls, then start() it in robotInit and update() it once per loop in robotPeriodic.
private final SimDashboard dash = new SimDashboard(); // port 5805
@Override
public void robotInit() {
dash.add(elevator)
// slider that schedules a Command each time it moves
.slider("Height (m)", 0.0, 0.6, v -> CommandScheduler.getInstance().schedule(elevator.goTo(v)))
.command("Stow", () -> elevator.goTo("DOWN")) // button that schedules a Command
.command("Top", () -> elevator.goTo("UP"));
dash.add(intake)
.command("Intake", intake::intake)
.command("Stop", () -> intake.runAtSpeed(0))
.toggle("Game piece", intake::setSimHasPiece); // flip the simulated piece state
dash.add(shooter)
.slider("Target (rps)", 0.0, 90.0, v ->
CommandScheduler.getInstance().schedule(shooter.spinUp(v)));
dash.start();
}
@Override
public void robotPeriodic() {
dash.update(); // drains queued browser commands + snapshots state
}
A mechanism with no controls is still shown live, just read-only. The control methods are fluent and chain: button, command, slider, toggle (with an optional BooleanSupplier so a toggle tracks live robot state instead of just the last click), and add to chain straight to the next mechanism. Call title(String) to set the page title and stop() to shut the server down.
Live text panels
Not everything you want to watch in sim is a mechanism. A running state machine, the match clock, a scoring FSM — these have no travel bar or speed readout, they have state you want to read as text. statusPanel renders that:
public SimDashboard statusPanel(String title, java.util.function.Supplier<java.util.List<String>> lines)
It adds a titled text card to the cockpit that shows each string on its own line, and the supplier is called once per update() so the card is always live. It returns this, so it chains alongside add(...) like the other registration calls. It is deliberately decoupled from mechanisms and reusable: it takes a plain Supplier<List<String>>, so anything that can describe itself as a few lines of text works — you are not limited to things that implement describe().
The headline use is watching a state machine think. A Catalyst state machine’s explain() returns a plain-language dump of what you built and why it is stuck (current state, the route it is taking, which guard is blocking a transition). Split it into lines and hand it to a panel, and the reasoning updates live as the machine runs:
dash.statusPanel("Superstructure",
() -> java.util.Arrays.asList(sm.explain().split("\n")));
The example project does exactly this. Alongside the one-of-every-kind mechanism lab, it stands up a servo hood driven by a tiny three-state machine (CLOSE / MID / FAR), wires the state buttons to goTo(...), and adds a statusPanel("Hood State Machine", () -> Arrays.asList(hoodMachine.explain().split("\n"))) so you can click a target state and watch explain() narrate the routing and guarding live, next to the servo it is driving. For what explain() prints and how the engine decides transitions, see State machine internals.
Widget per mechanism kind
The dashboard maps each MechanismView kind to a fitting widget:
| Kind | Widget |
|---|---|
linear | travel bar over the configured range |
rotational / turret | angle gauge with setpoint tick |
flywheel | speed readout (rps) with at-speed chip |
roller | speed plus a game-piece chip |
claw | grip-state chip |
diffwrist | pitch value with roll in the extras |
winch | extension value over its range |
pneumatic | solenoid state chip |
Each widget also draws a live sparkline of its primary value with a setpoint guide, so you can watch a mechanism settle. The header has a Pause/Resume toggle (freeze the view mid-motion) and an Export CSV button that downloads the current snapshot of every mechanism.
Mechanisms now run real physics in sim
Four mechanisms that used to be inert in simulation now run real WPILib physics models so their widgets move on their own when commanded:
- Roller runs a
FlywheelSim. - Claw runs a
DCMotorSim. - Winch runs an
ElevatorSimwhenspoolRadius > 0, otherwise aDCMotorSim. - Differential Wrist runs two
DCMotorSim(one per axis).
Roller and Claw have no continuous sensor for a game piece, so call setSimHasPiece(boolean) to flip the simulated piece state from a toggle (it is sim-only and ignored on a real robot). Two new sim-only config fields tune the physics: WinchMechanism.Config.builder().loadMass(double kg) (default 6.0) and DifferentialWristMechanism.Config.builder().momentOfInertia(double kgMetersSquared) (default 0.004 per axis). Both affect simulation only.
Try it
The example ships a full one-of-every-kind lab. MechanismShowcase builds one of every mechanism kind (linear, rotational, roller, flywheel, turret, claw, differential wrist, winch, pneumatic) on CAN IDs 30 to 39 and drives each from its own SimDashboard on port 5806. Run the example in simulation and open localhost:5806 next to the game cockpit on localhost:5805. Nothing in the lab is specific to this year’s game; the same calls work against your real robot’s mechanisms.
Full-field physics (maple-sim)
SimDashboard drives mechanisms. When you also want a physics-simulated, game-piece-aware field to test the whole autonomy stack against (behavior framework, SOTF, pathfinding), add maple-sim. This is the optional advanced layer.
How Catalyst integrates
maple-sim is a physics-engine simulation with collisions and game pieces. Catalyst does not bundle it. It is an unstable, fast-moving, sim-only package, and Catalyst is a library other teams depend on, so forcing maple-sim onto every user (and risking a broken build when its API churns) would be the wrong trade.
Instead Catalyst gives you the seam: two dependency-free hooks that let your maple-sim instance drive Catalyst’s odometry and visualization. You add maple-sim to your own robot project (it is a normal vendordep there) and wire it through these.
| Hook | What it’s for |
|---|---|
SwerveSubsystem.setSimPose(Pose2d) | feed maple-sim’s physics pose into Catalyst’s estimator (sim only; no-op on a real robot). Also stands Catalyst’s own sim thread down on first call — see below |
SwerveSubsystem.disableInternalSim() | stand the internal sim down explicitly, if you want it off before the first pose arrives |
SimGamePieces | stream simulated piece positions to NT for AdvantageScope |
Setup
- Add maple-sim to your robot project (not Catalyst) by following their install guide.
- Create a
SwerveDriveSimulationfrom your drivetrain constants. - Wire it to Catalyst in
simulationPeriodic().
private final SwerveDriveSimulation swerveSim = /* maple-sim setup */;
private final SimGamePieces fuel = new SimGamePieces("Fuel");
@Override
public void simulationPeriodic() {
// 1. Step the physics world.
SimulatedArena.getInstance().simulationPeriodic();
// 2. Feed the simulated pose into Catalyst's odometry.
drive.setSimPose(swerveSim.getSimulatedDriveTrainPose());
// 3. Stream game pieces for AdvantageScope.
fuel.clear();
for (var piece : SimulatedArena.getInstance().getGamePiecesByType("Fuel")) {
fuel.set(piece, piece.getPose3d());
}
fuel.publish(); // -> /Catalyst/Sim/Fuel
}
Catalyst’s own sim thread gets out of the way
Without maple-sim, SwerveSubsystem runs a 200 Hz thread calling Phoenix’s updateSimState() so the drivetrain moves in the simulator out of the box. With maple-sim, that thread would be a second writer on the same module rotor states — and at 200 Hz it would win, quietly overwriting the physics before it ever reached your robot code.
So the first setSimPose() call stops it. The wiring above needs no extra step; this is only worth knowing about when something looks stuck. To check, or to stand it down before any pose arrives:
drive.disableInternalSim(); // explicit, idempotent, no-op on a real robot
drive.isInternalSimRunning(); // false once an external engine has taken over
Method names follow maple-sim’s API, which changes between releases, so check their current docs. The Catalyst side (
setSimPose,SimGamePieces) is stable.
Bridge at the mechanism level, not the device level
There are two ways to connect an external physics engine to a CTRE swerve, and the choice matters more than it looks.
- Device-level — write each
TalonFX/CANcodersimulation state yourself. This is the seam it is tempting to reach for, but getting a swerve right this way means reproducing every per-module magnet offset, every per-module inversion, the module orientation enums, and theFusedCANcoderrotor-to-CANcoder sync exactly. Get any of it subtly wrong and you get a marginally stable steer loop whose symptoms look like drive problems, brownouts, or drifting odometry — never like the steer feedback that is actually wrong. It is a genuinely hard thing to debug. - Mechanism-level (recommended) — hand maple-sim the module setpoints Catalyst already computed and let it own the physics. About forty lines, deterministic, and there is no device state to corrupt.
The seam for the second approach is {@code SwerveSubsystem.getModuleTargets()} (added in 1.2.1) — the commanded module states, so you never reach through the raw drivetrain:
// In simulationPeriodic(), instead of writing TalonFX sim states by hand:
SwerveModuleState[] targets = drive.getModuleTargets(); // what Catalyst just commanded
if (targets != null) {
selfControlledSim.runSwerveStates(targets); // maple-sim owns the physics
}
drive.setSimPose(selfControlledSim.getActualPoseInSimulationWorld());
where selfControlledSim is maple-sim’s SelfControlledSwerveDriveSimulation.
One caveat under mechanism-level bridging: because no device sim states are written,
/Catalyst/Swerve/ModuleStates(the measured states) reads zeros —getModuleTargets()//Catalyst/Swerve/ModuleTargetsis the observable that reflects what the robot is doing.
What you can test in sim
Because Catalyst’s odometry now tracks the physics world, the whole autonomy stack runs against it:
- Behavior framework drives your
Strategistagainst simulated fuel. Watch it chase pieces and bail to a shot as the simulated clock runs down, all on the AdvantageScope field. - SOTF reads the simulated pose and velocity through the
AimingSolver, so you can sanity-check the virtual-goal math before a turret exists. - Pathfinding / Choreo drives the simulated robot through the simulated field with
pathfindToPoseandfollowChoreoPath. - Vision pursuit feeds
driveToPiecea supplier of the nearest simulated piece pose and cycles. - Vision fusion runs with no camera: add a
SimCameraSourceto yourVisionConfig, feeding it the true simulated pose. It emits noisy, latency-delayed pose estimates so the wholeVisionSubsystempipeline (accept / reject, std-dev weighting, feeding the swerve estimator) runs in the simulator, letting you tunemaxAmbiguityand the std-dev model early.
VisionConfig cfg = VisionConfig.builder()
.driveSubsystem(drive)
.addCamera(SimCameraSource.builder("SimFront")
.truePose(() -> swerveSim.getSimulatedDriveTrainPose())
.translationStdDevMeters(0.03)
.latencySeconds(0.03)
.build())
.build();
Visualization
Open AdvantageScope, connect to the simulator, and add:
/Catalyst/Swerve/Posefor the robot (already published bySwerveSubsystem)/Catalyst/Sim/<name>for your game pieces (Pose3d[]fromSimGamePieces)/Catalyst/Ghost/Posefor a recorded driver path, if you are using GhostReplay
You get a full simulated match on the field view, driven by real physics.