Unit 8 · Lesson 12

Motion Magic (CTRE)

Motion Magic moves the motion profile off the roboRIO and onto the TalonFX itself. Less network traffic, less loop overhead, faster response — and one fewer place for your code to get out of sync with your hardware.

By the end of this lesson, you will:

  • Explain what Motion Magic is and why it runs on the motor controller rather than the roboRIO
  • Identify the four configuration parameters Motion Magic requires and what each one physically controls
  • Configure a TalonFX for Motion Magic using MotionMagicConfigs and apply it via MotorOutputConfigs and slot gains
  • Command a Motion Magic move using MotionMagicVoltage or MotionMagicVelocity control requests
  • Describe the tradeoffs between roboRIO-side profiling (ProfiledPIDController) and on-controller Motion Magic

What Motion Magic Is — and Where It Runs

In the previous lesson you built a trapezoidal motion profile on the roboRIO using WPILib's TrapezoidProfile and ProfiledPIDController. Every 20 milliseconds, your robot's main loop computed the next setpoint along the trajectory and sent it to the motor controller as a voltage or velocity command.

Motion Magic is CTRE's factory-built version of that same concept — except the profile runs inside the TalonFX motor controller itself, at 1 kHz. Instead of your roboRIO computing a setpoint and transmitting it over CAN 50 times per second, the TalonFX generates its own trajectory at 1000 times per second and closes the loop locally. Your roboRIO sends a single position goal once, and the TalonFX handles everything from there until the motion completes or you send a new goal.

This is not just a convenience. It has real engineering implications.

roboRIO-side (ProfiledPIDController)
  • Profile computes at 50 Hz (20 ms loop)
  • Setpoint transmitted over CAN each loop
  • CAN jitter can cause setpoint gaps
  • Works with any motor controller
  • Easy to log intermediate setpoints
  • Gains tuned in Java code / Constants.java
  • Supports gravity compensation via WPILib feedforward classes
On-controller (Motion Magic)
  • Profile computes at 1000 Hz on the TalonFX
  • No per-loop CAN traffic for setpoints
  • Immune to roboRIO loop overruns
  • TalonFX / Kraken X60 only
  • Gains configured via Phoenix Tuner X or code
  • Slot system for switching gain sets at runtime
  • Supports gravity feedforward (kG) in config
🔍 Why on-controller profiling matters at competition

Team 2910 uses Motion Magic extensively for elevator and arm control. One reason: at competition, the roboRIO runs many things simultaneously — vision processing, odometry updates, logging, path following. Under load, the main loop can take longer than 20 ms, which means a ProfiledPIDController gets starved for update cycles and its trajectory drifts from what you expected. A Motion Magic request already sent to the TalonFX keeps executing at 1 kHz regardless of what the roboRIO is busy with. The mechanism doesn't feel the roboRIO's stress.

The Four Motion Magic Parameters

Motion Magic requires four configuration values to define the motion profile. These live in MotionMagicConfigs in Phoenix 6. Click each parameter to understand what it controls physically — not just mathematically.

MotionMagicConfigs parameters click a parameter to explore
MotionMagicCruiseVelocity rot/s
MotionMagicAcceleration rot/s²
MotionMagicJerk rot/s³
MotionMagicExpo_kV V/(rot/s)
MotionMagicCruiseVelocity The maximum speed the profile will reach

This is the flat top of the trapezoid — the velocity the mechanism will cruise at between the acceleration and deceleration phases. Units are rotations per second at the motor shaft (before any gearing reduction you apply in your sensor ratio configuration).

Set this conservatively first. If your arm's motor-side velocity at full mechanism speed is 80 rot/s, start at 50. Watch in Tuner X's plot view whether the mechanism actually reaches cruise velocity or just follows the acceleration ramp continuously. If it never cruises, either the distance is short (triangular profile — expected) or your cruise velocity is already above the motor's capacity at that load.

⚙️ Physical consequence: too high → motor can't hold cruise velocity under mechanism load → profile tracks poorly. Too low → mechanism is slower than it needs to be → longer cycle times.
💡 All Motion Magic units are in the sensor's native domain

By default, Phoenix 6 units are in rotations at the motor shaft. If you configure a SensorToMechanismRatio in your FeedbackConfigs, the sensor position is scaled to mechanism rotations instead, and your Motion Magic parameters should be in mechanism units. If you're controlling an arm, set up your sensor ratio so that 1.0 = 1 full arm rotation, and your cruise velocity/acceleration parameters will be in arm rotations per second — which is much easier to reason about physically.

Slot Gains: The Feedback Side of Motion Magic

Motion Magic generates the trajectory, but it still needs a feedback controller to follow it. In Phoenix 6 that feedback is configured through slots — banks of PID and feedforward gains stored directly on the TalonFX. You can define up to three slots (0, 1, 2) and switch between them at runtime with a single line of code.

For Motion Magic position control, the relevant gains in each slot are:

  • kP — proportional gain on position error. Primary correction force.
  • kI — integral gain on accumulated position error. Start at 0 — with good feedforward you rarely need it.
  • kD — derivative gain on velocity error (how fast position error is changing). Acts as damping against overshoot.
  • kS — static feedforward: voltage added in the direction of motion to overcome breakaway friction.
  • kV — velocity feedforward: voltage per unit of profiled velocity. Predicts the voltage needed to hold cruise speed without waiting for PID error to develop.
  • kA — acceleration feedforward: voltage per unit of profiled acceleration. Helps during the ramp-up and ramp-down phases.
  • kG — gravity feedforward: constant voltage to counteract gravity. Available for arm and elevator mechanisms.
💡 Why slots matter in practice

Different positions on the same mechanism can have very different dynamics. An arm at 0° (horizontal, full gravitational load) needs very different gains than the same arm at 90° (vertical, gravity perpendicular to motion). Teams sometimes configure slot 0 for low-angle positions and slot 1 for high-angle positions, then switch slots mid-match. This is one of the things Motion Magic makes practical that roboRIO-side profiling makes awkward.

Full Configuration in Code

Phoenix 6 uses a configuration object pattern. You build a TalonFXConfiguration, populate its sub-configs (MotionMagicConfigs, Slot0Configs, FeedbackConfigs, etc.), and apply the entire configuration to the device in one call. This happens once — typically in the subsystem constructor — not every loop.

ArmSubsystem.java — constructor, Motor Magic configuration
import com.ctre.phoenix6.configs.*;
import com.ctre.phoenix6.controls.MotionMagicVoltage;
import com.ctre.phoenix6.hardware.TalonFX;
import com.ctre.phoenix6.signals.GravityTypeValue;

public class ArmSubsystem extends SubsystemBase {

    private final TalonFX m_motor = new TalonFX(ArmConstants.MOTOR_CAN_ID, "rio");

    // Reusable control request — allocate once, reuse every loop
    private final MotionMagicVoltage m_mmRequest = new MotionMagicVoltage(0);

    public ArmSubsystem() {
        TalonFXConfiguration cfg = new TalonFXConfiguration();

        // ── Sensor ratio ──────────────────────────────────────────────
        // Tell the TalonFX the gear ratio so position reads in arm rotations
        // (not motor shaft rotations). 1.0 arm rotation = ratio motor rotations.
        cfg.Feedback.withSensorToMechanismRatio(ArmConstants.GEAR_RATIO);

        // ── Motion Magic constraints ───────────────────────────────────
        cfg.MotionMagic
            .withMotionMagicCruiseVelocity(0.5)   // arm rot/s at mechanism
            .withMotionMagicAcceleration(1.0)     // arm rot/s² at mechanism
            .withMotionMagicJerk(10.0);           // optional smoothing; 0 = disabled

        // ── Slot 0 gains ───────────────────────────────────────────────
        // These are the feedback + feedforward gains used during the profile.
        // GravityType.Arm_Cosine makes kG scale with cos(angle) automatically.
        cfg.Slot0
            .withKP(24.0)
            .withKI(0.0)
            .withKD(0.5)
            .withKS(0.25)
            .withKV(0.12)
            .withKA(0.01)
            .withKG(0.35)
            .withGravityType(GravityTypeValue.Arm_Cosine);

        // ── Apply to hardware ──────────────────────────────────────────
        // applyConfiguration() flashes these values to the TalonFX over CAN.
        // In production code, check the StatusCode return value.
        m_motor.getConfigurator().apply(cfg);
    }

    // Call this from a Command's initialize() or execute()
    public void setGoalRotations(double armRotations) {
        m_motor.setControl(m_mmRequest.withPosition(armRotations));
    }

    public boolean atGoal() {
        // ClosedLoopError is in mechanism units after ratio scaling
        double error = m_motor.getClosedLoopError().getValueAsDouble();
        return Math.abs(error) < ArmConstants.POSITION_TOLERANCE_ROT;
    }

    public double getPositionRotations() {
        return m_motor.getPosition().getValueAsDouble();
    }

    @Override
    public void periodic() {
        // No profile calculation here — the TalonFX handles it internally.
        // periodic() is used for telemetry and safety checks only.
        SmartDashboard.putNumber("Arm/Position", getPositionRotations());
        SmartDashboard.putNumber("Arm/ClosedLoopError",
            m_motor.getClosedLoopError().getValueAsDouble());
    }
}
⚠️ Allocate control requests once, outside periodic()

The MotionMagicVoltage control request object is allocated in the field declaration, not inside setGoalRotations(). This is intentional. If you write m_motor.setControl(new MotionMagicVoltage(position)) inside periodic(), you're allocating a new object 50 times per second. In Java, this means the garbage collector eventually pauses your program mid-match to reclaim memory — a loop overrun. Allocate once, reuse via .withPosition() which modifies the existing object in place and returns it.

Using Motion Magic in a Command

The command pattern is nearly identical to the ProfiledPIDController version from Lesson 11. The subsystem encapsulates all the hardware interaction; the command only needs to know what goal to set and when to consider the move done.

MoveArmCommand.java
public class MoveArmCommand extends Command {

    private final ArmSubsystem m_arm;
    private final double m_targetRotations;

    public MoveArmCommand(ArmSubsystem arm, double targetRotations) {
        m_arm = arm;
        m_targetRotations = targetRotations;
        addRequirements(arm);
    }

    @Override
    public void initialize() {
        m_arm.setGoalRotations(m_targetRotations);
        // The TalonFX starts the Motion Magic profile immediately.
        // No further calls needed in execute() — the motor drives itself.
    }

    @Override
    public boolean isFinished() {
        return m_arm.atGoal();
    }

    @Override
    public void end(boolean interrupted) {
        if (interrupted) {
            // Hold current position — send a new Motion Magic goal at current pos
            m_arm.setGoalRotations(m_arm.getPositionRotations());
        }
    }
}
💡 You don't need execute() for most Motion Magic commands

With roboRIO-side profiling (ProfiledPIDController), the subsystem's periodic() does work every loop, so the command can be purely structural. Motion Magic is even simpler: the profile runs inside the TalonFX, so after initialize() sends the goal, nothing in your Java code needs to run until isFinished() returns true. The execute() override is only needed if you want to update the goal dynamically — for example, a "hold at joystick-trimmed angle" mode where the target changes with driver input.

First-Time Setup Workflow

Getting Motion Magic running correctly the first time requires a specific sequence. Skipping steps — especially the sensor ratio setup — is the most common source of confusion.

1
Verify the encoder reads correctly in Tuner X

Open Phoenix Tuner X, navigate to your TalonFX, and watch the Position signal while you manually move the mechanism by hand. Confirm the reading changes in the expected direction and by the expected amount per mechanism movement. If you rotate the arm by 90° and the position reads 5.2 rotations, your gear ratio is 5.2/0.25 = 20.8:1 — write that down.

If position reads backwards, set cfg.MotorOutput.withInverted(InvertedValue.Clockwise_Positive) or flip the sensor phase. Do this before touching any gains — there's no point tuning a controller running on backwards sensor data.

2
Configure SensorToMechanismRatio

Set cfg.Feedback.withSensorToMechanismRatio(gearRatio) using the ratio you just measured or calculated. After this, the TalonFX position will read in mechanism rotations (0.0 to 1.0 for a full arm rotation). Confirm by moving the mechanism one full revolution — position should change by exactly 1.0.

3
Start with zero feedback gains, kV only

Set kP, kI, kD, kS, kA, kG all to 0. Set kV to your mechanism's characterization value (or a rough estimate from SysId / kV = 12V ÷ free-speed-in-rot-per-s). Set cruise velocity to 30% of free speed, acceleration to cruise velocity × 1.5.

Deploy and command a motion. The mechanism should follow the profile shape — visibly accelerating, cruising, decelerating — but may not land precisely at the target. That's expected. You're confirming the profile is being executed before introducing feedback.

4
Add kP to close the final gap

Increase kP until the mechanism reliably reaches the target position. Watch ClosedLoopError in Tuner X — it should converge to near zero by the end of the motion. If it oscillates, add kD. If it consistently stops short, increase kP or add a small kS.

5
Add gravity compensation if applicable

For arms, set withGravityType(GravityTypeValue.Arm_Cosine) and tune kG. The Arm_Cosine type automatically scales kG by the cosine of the mechanism position, so the compensation is correct at every angle — not just at 0°. For elevators, use GravityTypeValue.Elevator_Static.

6
Set software limits before pushing cruise velocity higher

Configure cfg.SoftwareLimitSwitch with forward and reverse soft limits that match your mechanism's physical travel range. This prevents Motion Magic from driving the mechanism into a hard stop if you send it an out-of-range goal. Always enable soft limits before increasing cruise velocity or testing in auto.

Motion Magic vs. ProfiledPIDController: Which to Use

Both approaches produce a trapezoidal motion profile. The choice depends on your hardware situation and what you're optimizing for.

Consider Choose ProfiledPIDController Choose Motion Magic
Motor controller SparkMax, SparkFlex, or any motor TalonFX (Falcon 500, Kraken X60) only
Loop overhead Profile math runs on roboRIO each loop Profile runs on motor controller at 1 kHz
Simulation Easy — WPILib sim classes match exactly More setup — need to mock CTRE sim device
Logging Easy access to setpoint via getSetpoint() Read from TalonFX signals (slightly more boilerplate)
Multi-axis gravity WPILib ArmFeedforward handles it kG + GravityTypeValue.Arm_Cosine handles it
Competition reliability Can degrade if roboRIO loop is overloaded Continues correctly even if roboRIO is busy
Gain storage Constants.java, compiled into code Slots on device (can edit in Tuner X without redeploy)
💡 The practical answer for most teams

If all your mechanism motors are TalonFX, Motion Magic is usually the better choice for arms and elevators at competition. It reduces roboRIO load, its gains are editable in Tuner X without a redeploy (huge during pit debugging), and the 1 kHz update rate produces noticeably smoother motion. If you're running SparkMax motors, or if simulation is a priority for your workflow, ProfiledPIDController is the appropriate tool. Teams like 2910 use Motion Magic on TalonFX mechanisms and ProfiledPIDController for any mechanisms running REV hardware in the same robot.

🔌 System Check

⚙️ Before You Enable Motion Magic on Hardware

Motion Magic runs on the TalonFX — which means its behavior depends on the TalonFX's firmware version, CAN ID, and configuration state. Work through this list before enabling:

  • Phoenix 6 firmware on the TalonFX. Motion Magic in the Phoenix 6 API will not work with Phoenix 5 firmware. Open Tuner X, find the device, and confirm the firmware version starts with 6.x. If it shows 22.x or 23.x, update firmware before proceeding.
  • CANivore vs. "rio" bus string. If your TalonFX is on a CANivore (CAN FD adapter), the constructor is new TalonFX(id, "Canivore") — the second argument must match your CANivore's configured name. Mismatch means the code creates a device object that silently fails to communicate.
  • Encoder direction verified before any gains are applied. Motion Magic will drive hard into a hard stop if the encoder reads backwards and kP is nonzero. Verify direction first in Tuner X with zero gains, then add feedback.
  • Software limits configured before increasing cruise velocity. Set both forward and reverse soft limits in your config before your first full-speed test. This is not optional for arms and elevators — without limits, a bad goal value can damage the mechanism or damage the robot around it.
  • applyConfiguration() return status is checked. In production code, store the StatusCode returned by m_motor.getConfigurator().apply(cfg) and log a warning if it's not StatusCode.OK. Silent config failures — where the config appeared to apply but didn't — are rare but genuinely happen on damaged CAN buses.

Knowledge Check

1. A team configures Motion Magic on their elevator TalonFX, but the elevator consistently overshoots the target by about two inches before settling back. The profile shape looks correct in Tuner X. Which gain is most likely the first to adjust?

  • A Increase MotionMagicCruiseVelocity
  • B Increase kV
  • C Increase kD
  • D Decrease MotionMagicJerk

2. A programmer writes m_motor.setControl(new MotionMagicVoltage(targetPosition)) inside periodic(). The mechanism works correctly during testing but the robot has frequent loop overruns at competition. What is the most likely connection?

  • A Motion Magic is inherently too computationally expensive for the roboRIO
  • B Allocating a new MotionMagicVoltage object 50 times per second triggers garbage collection, causing periodic loop overruns
  • C CAN traffic from Motion Magic exceeds the bus's bandwidth
  • D setControl() should only be called from initialize(), not periodic()

3. A team wants to use different PID gains for their arm at low angles (where gravity load is high) versus high angles (where it's near vertical). They're using Phoenix 6 Motion Magic. What is the correct approach?

  • A Create two separate TalonFX objects for the same motor, each with different configurations
  • B Call applyConfiguration() with different configs every time the arm crosses the transition angle
  • C Configure gains in Slot0 and Slot1, then switch slots using m_mmRequest.withSlot(1) when the arm passes the transition angle
  • D Motion Magic only supports one gain set — use ProfiledPIDController for multi-zone control
💪 Practice Prompt

Configure and Command a Motion Magic Mechanism

  1. Create an ArmSubsystem with a single TalonFX at a CAN ID stored in ArmConstants. In the constructor, build a full TalonFXConfiguration with placeholder values for SensorToMechanismRatio, MotionMagicCruiseVelocity, MotionMagicAcceleration, MotionMagicJerk, and all Slot0 gains (set all to 0 except kV = 0.12). Apply the configuration and log the returned StatusCode to the console.
  2. Add a setGoalRotations(double) method and an atGoal() method that checks getClosedLoopError() against a tolerance constant. In periodic(), publish position and closed-loop error to SmartDashboard.
  3. Create a MoveArmCommand that calls setGoalRotations() in initialize() and returns atGoal() from isFinished(). Handle interruption in end() by holding the current position. Bind it to a controller button in RobotContainer.
  4. If you have a TalonFX available: deploy, open Tuner X, and enable the Signal Plot for Position, ClosedLoopReference, and ClosedLoopError. Command a move and observe whether the reference (the Motion Magic target) follows the expected trapezoidal velocity shape before position catches up.
  5. Bonus: Configure a second slot (Slot1) with kP doubled and kD halved compared to Slot0. Add a method setSlot(int slot) that updates the MotionMagicVoltage request's slot field. Trigger a slot switch from a second controller button and observe the difference in response in Tuner X's plot. Describe in a code comment what physical condition would justify using the faster slot vs. the slower one on your mechanism.