Tutorial 30: GamePad and Controller Input

Input  ·  Intermediate

GamePad support in CNA is provided through SDL3's joystick and gamepad subsystem. Xbox-style controllers, DualShock, and most SDL3-compatible gamepads are supported on Linux and Windows. Availability may vary by backend — the SDL3-based backend has the fullest support.

GamePad.GetState(PlayerIndex)

Call GamePad::GetState(PlayerIndex) once per frame in your Update method. It returns a GamePadState snapshot by value — cheap to copy. CNA supports up to four simultaneous controllers via PlayerIndex::One through PlayerIndex::Four.

Always check IsConnected before reading any state. Reading state from a disconnected controller returns safe defaults (all buttons released, sticks at zero).

void Update(GameTime& gameTime) override {
    auto state = GamePad::GetState(PlayerIndex::One);
    if (!state.IsConnected) return;

    // safe to read buttons, sticks, triggers here
}

GamePadState Struct

GamePadState groups its data into four sub-structs:

MemberTypeDescription
ButtonsGamePadButtonsFace buttons, shoulder buttons, Start, Back
ThumbSticksGamePadThumbSticksLeft and right stick as Vector2
TriggersGamePadTriggersLeft and right trigger as float 0–1
DPadGamePadDPadD-pad directions as ButtonState
IsConnectedboolWhether the controller is plugged in

Buttons (A/B/X/Y/Start/Back/Shoulder)

Each button in GamePadButtons is a ButtonState: either ButtonState::Pressed or ButtonState::Released.

Available buttons: A, B, X, Y, Start, Back, LeftShoulder, RightShoulder, LeftStick, RightStick (stick click).

To detect a single press (not held), compare the current frame against the previous frame:

bool IsJustPressed(ButtonState current, ButtonState previous) {
    return current == ButtonState::Pressed && previous == ButtonState::Released;
}

// In your Update:
bool attackPressed = IsJustPressed(
    state.Buttons.A,
    prevState_.Buttons.A
);

Thumbsticks (Left/Right as Vector2)

state.ThumbSticks.Left and state.ThumbSticks.Right each return a Vector2 with components in the range [-1, 1]. In XNA convention, Y positive is up — the opposite of screen coordinates. When using a stick for 2D movement, negate Y to get screen-down as positive.

Vector2 move = state.ThumbSticks.Left;
// Convert to screen space: negate Y
playerPos_.X += move.X * speed * dt;
playerPos_.Y -= move.Y * speed * dt;  // negate for screen coords

Triggers (float 0-1)

state.Triggers.Left and state.Triggers.Right return a float between 0.0 (fully released) and 1.0 (fully depressed). Ideal for analog actions such as acceleration, zoom, or charge attacks.

float shieldStrength = state.Triggers.Left;   // 0 = no shield, 1 = full
float accelerator    = state.Triggers.Right;  // 0 = coasting, 1 = full throttle

Vibration

Call GamePad::SetVibration(PlayerIndex, leftMotor, rightMotor) to start rumble. Both motors accept a float in [0, 1]. Left motor is low-frequency (heavy thud), right is high-frequency (sharp buzz). Always call SetVibration with zeros to stop the effect — the motors keep running until told otherwise.

// Hit feedback: strong thud for 200 ms
void OnPlayerHit() {
    GamePad::SetVibration(PlayerIndex::One, 0.8f, 0.3f);
    vibrateTimer_ = 0.2f;
}

void Update(GameTime& gameTime) override {
    float dt = (float)gameTime.getElapsedGameTime().TotalSeconds();
    if (vibrateTimer_ > 0.0f) {
        vibrateTimer_ -= dt;
        if (vibrateTimer_ <= 0.0f)
            GamePad::SetVibration(PlayerIndex::One, 0.0f, 0.0f);
    }
}

Deadzone Handling

Physical thumbsticks never rest exactly at (0, 0). Always apply a deadzone to avoid unwanted drift. A radial deadzone checks the stick's total length — cleaner than checking X and Y independently (axial deadzone):

static constexpr float DEADZONE = 0.18f;

Vector2 ApplyRadialDeadzone(Vector2 stick) {
    float len = stick.Length();
    if (len < DEADZONE) return Vector2::Zero;
    // Rescale so the live range starts at 0 outside the deadzone
    float scale = (len - DEADZONE) / (1.0f - DEADZONE);
    return Vector2::Normalize(stick) * scale;
}

// Axial deadzone (simpler, less accurate):
Vector2 ApplyAxialDeadzone(Vector2 stick) {
    if (std::abs(stick.X) < DEADZONE) stick.X = 0.0f;
    if (std::abs(stick.Y) < DEADZONE) stick.Y = 0.0f;
    return stick;
}

Multiple Controllers

CNA supports up to four simultaneous controllers. Each player reads from their own PlayerIndex:

void Update(GameTime& gameTime) override {
    static const PlayerIndex indices[] = {
        PlayerIndex::One, PlayerIndex::Two,
        PlayerIndex::Three, PlayerIndex::Four
    };
    for (int i = 0; i < 4; ++i) {
        auto state = GamePad::GetState(indices[i]);
        if (!state.IsConnected) continue;
        UpdatePlayer(i, state);
    }
}

Complete Example

A full game that moves a player rectangle with the left thumbstick, attacks with A (single-press), uses the left trigger for a shield, and vibrates on hit:

#include <memory>
#include <cmath>
#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/GameTime.hpp"
#include "Microsoft/Xna/Framework/Color.hpp"
#include "Microsoft/Xna/Framework/Vector2.hpp"
#include "Microsoft/Xna/Framework/Input/GamePad.hpp"
#include "Microsoft/Xna/Framework/Input/PlayerIndex.hpp"
#include "Microsoft/Xna/Framework/Input/ButtonState.hpp"
#include "Microsoft/Xna/Framework/Graphics/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteBatch.hpp"
#include "Microsoft/Xna/Framework/Graphics/Texture2D.hpp"

using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Graphics;
using namespace Microsoft::Xna::Framework::Input;

static constexpr float DEADZONE   = 0.18f;
static constexpr float PLAYER_SPD = 250.0f;

static Vector2 ApplyRadialDeadzone(Vector2 stick) {
    float len = stick.Length();
    if (len < DEADZONE) return Vector2::Zero;
    float scale = (len - DEADZONE) / (1.0f - DEADZONE);
    return Vector2::Normalize(stick) * scale;
}

class GamePadDemo final : public Game {
public:
    GamePadDemo() : graphics_(this) {
        graphics_.setPreferredBackBufferWidth(800);
        graphics_.setPreferredBackBufferHeight(600);
    }

protected:
    void LoadContent() override {
        spriteBatch_ = std::make_unique<SpriteBatch>(getGraphicsDeviceProperty());
        // 1x1 white pixel used as a coloured rectangle
        pixel_ = std::make_unique<Texture2D>(getGraphicsDeviceProperty(), 1, 1);
        Color white = Color::White;
        pixel_->SetData(&white, 1);
    }

    void Update(GameTime& gameTime) override {
        float dt = (float)gameTime.getElapsedGameTime().TotalSeconds();

        auto state = GamePad::GetState(PlayerIndex::One);

        // Vibration timer
        if (vibrateTimer_ > 0.0f) {
            vibrateTimer_ -= dt;
            if (vibrateTimer_ <= 0.0f)
                GamePad::SetVibration(PlayerIndex::One, 0.0f, 0.0f);
        }

        if (!state.IsConnected) {
            noController_ = true;
            return;
        }
        noController_ = false;

        // Movement with left thumbstick + radial deadzone
        Vector2 move = ApplyRadialDeadzone(state.ThumbSticks.Left);
        playerPos_.X += move.X * PLAYER_SPD * dt;
        playerPos_.Y -= move.Y * PLAYER_SPD * dt;  // Y-flip for screen space

        // Clamp to screen
        playerPos_.X = std::clamp(playerPos_.X, 0.0f, 760.0f);
        playerPos_.Y = std::clamp(playerPos_.Y, 0.0f, 560.0f);

        // Attack: A button — single press only
        bool aPressed = state.Buttons.A == ButtonState::Pressed
                     && prevState_.Buttons.A == ButtonState::Released;
        if (aPressed) {
            attacking_    = true;
            attackTimer_  = 0.15f;
            // Vibrate: sharp buzz on attack
            GamePad::SetVibration(PlayerIndex::One, 0.2f, 0.9f);
            vibrateTimer_ = 0.12f;
        }
        if (attackTimer_ > 0.0f) {
            attackTimer_ -= dt;
            if (attackTimer_ <= 0.0f) attacking_ = false;
        }

        // Shield: left trigger (analog)
        shieldStrength_ = state.Triggers.Left;

        prevState_ = state;
    }

    void Draw(const GameTime&) override {
        auto& gd = getGraphicsDeviceProperty();
        gd.Clear(Color(30, 30, 40, 255));

        spriteBatch_->Begin();

        if (noController_) {
            // Draw a grey placeholder when no controller connected
            spriteBatch_->Draw(*pixel_,
                Rectangle(300, 260, 200, 80), Color(80, 80, 80, 255));
        } else {
            // Shield aura (blue tint, proportional to trigger)
            if (shieldStrength_ > 0.01f) {
                int alpha = (int)(shieldStrength_ * 180);
                spriteBatch_->Draw(*pixel_,
                    Rectangle((int)playerPos_.X - 6, (int)playerPos_.Y - 6, 52, 52),
                    Color(60, 120, 255, alpha));
            }

            // Player body
            Color bodyCol = attacking_ ? Color(255, 200, 50, 255)
                                       : Color(50, 200, 100, 255);
            spriteBatch_->Draw(*pixel_,
                Rectangle((int)playerPos_.X, (int)playerPos_.Y, 40, 40),
                bodyCol);
        }

        spriteBatch_->End();
        gd.Present();
    }

private:
    GraphicsDeviceManager graphics_;
    std::unique_ptr<SpriteBatch> spriteBatch_;
    std::unique_ptr<Texture2D> pixel_;

    Vector2 playerPos_    = { 380.0f, 280.0f };
    GamePadState prevState_{};
    float shieldStrength_ = 0.0f;
    float vibrateTimer_   = 0.0f;
    float attackTimer_    = 0.0f;
    bool  attacking_      = false;
    bool  noController_   = false;
};

int main() {
    GamePadDemo game;
    game.Run();
    return 0;
}

Key Points

  • Always check IsConnected before using any state members.
  • Use a radial deadzone on thumbsticks to avoid diagonal bias.
  • Detect single presses by comparing current and previous GamePadState.
  • Always reset vibration to 0, 0 — motors stay on until explicitly stopped.
  • Y on thumbsticks is positive-up; negate when mapping to screen Y (positive-down).