Unit 5 · Lesson 2

Joystick and Controller Input

A game controller is a collection of axes and buttons. WPILib maps every physical input to a method call your code polls every 20ms. This lesson covers reading that input correctly — including the deadband you must apply before commanding a motor, and the Y-axis inversion that trips up every first-year programmer without exception.

By the end of this lesson, you will:

  • Construct an XboxController and read both axes and buttons in teleopPeriodic()
  • Explain why a deadband is required and apply MathUtil.applyDeadband() correctly
  • Fix the Y-axis inversion that makes "forward" produce a negative value
  • Distinguish between button state (held) and button event (rising edge) and choose the right method for each use case
  • Read trigger axes, bumpers, and the POV hat correctly

Controllers Are Just Axes and Buttons

An Xbox controller has 6 axes (left X/Y, right X/Y, left trigger, right trigger) and 10 buttons (A, B, X, Y, left bumper, right bumper, left stick click, right stick click, Start, Back). WPILib reads all of them through the Driver Station connection — your code polls the current state each 20ms cycle.

The XboxController class wraps raw axis and button indices into named methods. For non-Xbox hardware use PS4Controller, PS5Controller, or the generic Joystick class with raw axis numbers.

// Construction — in robotInit(), never in periodic private final XboxController driverController = new XboxController(0); private final XboxController operatorController = new XboxController(1); // Port 0 = first controller in DS, port 1 = second // Convention: port 0 = driver (motion), port 1 = operator (mechanisms)

Controller Explorer

Click any input below to see the WPILib API call, return type, value range, and a hardware note for each input on the Xbox controller.

XboxController Input Explorer — click any input
← click any input to see the API call and hardware behavior

The Y-Axis Inversion Problem

Pushing the left stick forward returns a negative value. This is the HID standard — historically joystick Y-axes used screen coordinates where up is negative. WPILib inherits this and does not correct it.

If you use controller.getLeftY() directly to command a forward-positive motor, pushing forward drives the robot backward. Fix: negate the value before using it.

// ❌ Without inversion fix — forward stick = backward robot double speed = controller.getLeftY(); // forward push → -1.0 driveMotor.set(speed); // ✅ Negated — forward stick = positive output double speed = -controller.getLeftY(); // forward push → +1.0 driveMotor.set(speed);

Deadband: The Required Safety Step

A joystick at rest is not perfectly at zero. Mechanical wear and electrical noise produce a small residual value — typically 0.02–0.08 — even when nobody is touching the stick. Without a deadband, that residual value commands the motor and the robot slowly drifts while the driver's hands are off the controls. A deadband zeroes out all values below a threshold.

Deadband Comparator — drag the slider
0.00
Raw (no deadband)
0.00
motor output: 0.00
applyDeadband(0.05)
0.00
motor output: 0.00
Move the slider to values below 0.05 to see the deadband eliminate drift-causing noise.
// ❌ Manual deadband — discontinuous jump at threshold double speed = (Math.abs(raw) < 0.05) ? 0.0 : raw; // At 0.051: output snaps from 0 to 0.051 — driver feels a jerk // ✅ WPILib's implementation — rescales for smooth response // Below 0.05 → 0.0 | At 0.05 → ~0.0 | At 1.0 → 1.0 (linear) double speed = MathUtil.applyDeadband(-controller.getLeftY(), 0.05); // Full arcade drive pattern with inversion + deadband @Override public void teleopPeriodic() { double forward = MathUtil.applyDeadband(-controller.getLeftY(), 0.05); double rotation = MathUtil.applyDeadband( controller.getRightX(), 0.05); leftMotor.set(MathUtil.clamp(forward + rotation, -1.0, 1.0)); rightMotor.set(MathUtil.clamp(forward - rotation, -1.0, 1.0)); }

Button Input: State vs. Event

Use button state when the behavior should run continuously while the button is held — run the intake while A is pressed, enable turbo mode while right bumper is held.

// getAButton() returns true every cycle while A is held if (controller.getAButton()) { intakeMotor.set(INTAKE_SPEED); // runs every 20ms while A is pressed } else { intakeMotor.set(0.0); } if (controller.getRightBumper()) { shooter.setVelocityTarget(SHOOT_RPM); // held-state for continuous spinup }

Use rising-edge detection for toggles or one-time actions. Without it, the toggle flips 50 times per second while the button is held.

// ❌ Toggle broken — flips ~50x/sec while held if (controller.getBButton()) { deployExtended = !deployExtended; // oscillates wildly } // ✅ getButtonPressed() — fires ONCE on the first cycle after press if (controller.getBButtonPressed()) { deployExtended = !deployExtended; // toggles once per physical press } // WPILib tracks previous state internally — no manual debouncing needed // getAButton() → true every cycle while held // getAButtonPressed() → true only on the FIRST cycle after press-down // getAButtonReleased() → true only on the FIRST cycle after release
💡 How Pressed/Released tracking works

getAButtonPressed() returns true only when the button is down this cycle and was up last cycle — that's a rising edge. WPILib tracks the previous state internally each cycle. You write no debouncing code yourself.

// Triggers — ANALOG axis, 0.0 (released) to 1.0 (full), NOT a boolean double rightTrig = controller.getRightTriggerAxis(); // Treat trigger as button with a threshold if (controller.getRightTriggerAxis() > 0.5) { climber.extend(); } // Use analog value for proportional control shooter.set(controller.getRightTriggerAxis() * MAX_SHOOTER_SPEED); // POV hat — angle in degrees: 0=up, 90=right, 180=down, 270=left // Diagonals: 45, 135, 225, 315 | Returns -1 when UNPRESSED int pov = controller.getPOV(); if (pov == 0) arm.setTarget(ArmPosition.HIGH); // D-pad up if (pov == 180) arm.setTarget(ArmPosition.LOW); // D-pad down if (pov == -1) { /* no direction pressed — do nothing */ }

Driver vs. Operator: The Two-Controller Convention

Most competition robots use two controllers: port 0 for the driver (motion, navigation, defense) and port 1 for the operator (intake, shooter, arm, climber). This separation keeps the driver focused without accidentally triggering mechanisms, and lets the operator run complex sequences without touching drive controls.

🔍 LRI Observation

During robot inspections I ask teams to show me their button mapping document. Teams that have a printed or posted controller layout can swap drivers without confusion and brief backup operators in under a minute. Teams that rely on "the operator just knows" lose time in the queue when a backup steps in. Document your mapping in Constants.java or a layout sheet. The drive team reads code through the physical controller, not the source file.

⚙️ 🔌 System Check
  • Always negate the Y-axis before using it for forward motion. -controller.getLeftY() makes forward stick produce positive output. This is the first thing to verify after first deploy: push stick forward, robot drives forward.
  • Always apply a deadband before commanding any axis-driven motor. A threshold of 0.05 is safe for most Xbox controllers. Use MathUtil.applyDeadband() for smooth response, not a manual if check.
  • Use getButtonPressed() for toggle actions, getButton() for held actions. A toggle driven by getButton() will flip 50 times per second.
  • Triggers return a double (0.0–1.0), not a boolean. Threshold them explicitly or use the analog value for proportional control.
  • POV hat returns -1 when unpressed. Always check for -1 before acting on the angle value.
  • Document your button mapping. The drive team's mental model must match the code. Mismatches are discovered during a match, not before it.

Knowledge Check

A driver pushes the left stick forward and the robot drives backward. What is the most likely cause?
  • 1The motor CAN ID is wrong
  • 2The Y-axis value was not negated — getLeftY() returns negative values when pushed forward (HID screen-coordinate convention), so the motor received a negative command and drove in reverse
  • 3The deadband is too large
  • 4The controller is on port 1 instead of port 0
A programmer uses if (controller.getBButton()) { armDeployed = !armDeployed; }. The operator presses and holds B for 0.5 seconds. What happens?
  • 1The arm toggles once — WPILib automatically detects a rising edge
  • 2The arm state toggles approximately 25 times — getBButton() returns true every 20ms cycle while held; at 50Hz over 0.5 seconds, the toggle fires ~25 times, leaving the arm in an unpredictable state
  • 3A compile error — boolean toggle requires a dedicated toggle method
  • 4The arm deploys and locks in position until B is released
Why does MathUtil.applyDeadband(value, 0.05) produce better driver feel than (Math.abs(value) < 0.05) ? 0.0 : value?
  • 1MathUtil is faster at runtime
  • 2The manual check produces a discontinuous jump — at the threshold, output snaps from 0.0 to 0.051; MathUtil rescales so the first non-zero output is near 0.0 and grows smoothly to 1.0, giving the driver proportional control with no lurching start
  • 3The manual check does not compile on the roboRIO's JVM
  • 4No difference — both produce identical output values above the threshold
💪 Practice Prompt

Full Controller Integration

  1. Add XboxController driverController (port 0) and XboxController operatorController (port 1) to your Robot.java from Lesson 1. Construct in robotInit().
  2. In teleopPeriodic(), read the left Y-axis with negation and a 0.05 deadband. Print the result to the console. Confirm pushing forward prints a positive value near 1.0.
  3. Add A-button held-state control: print "INTAKE RUNNING" while A is held using getAButton(). Verify it prints continuously while held.
  4. Add a B-button toggle using getBButtonPressed() and a boolean deployActive field. Confirm it fires exactly once per physical press regardless of hold duration.
  5. Read the POV hat — print the angle when a direction is pressed, skip when it returns -1. Test all four cardinal directions.
  6. Read the right trigger as an analog value. Print "SHOOTING AT X%" where X is the trigger depth times 100. Confirm partial presses produce intermediate values.
  7. Bonus: Create a ControllerMap section in Constants.java documenting every binding. Then look at a recent Team 2910 robot's button mapping documentation or code. How do they organize it?