Built-in project · Raspberry Pi Pico

Fight stick on a Pico

A Sanwa-style lever and eight lit buttons on GP2040-CE’s Pico pins, with SOCD and stick modes on button combos, turbo and an OLED that draws the lever as you move it. The whole build, a Raspberry Pi Pico and 14 more parts, runs here in your browser on the firmware below; open it in the editor to change the wiring or the code and run it again.

Teaching? Assign it to your class in one click.

Advanced Runs in your browser. Free, and no account needed.

Fight stick on a Pico · Raspberry Pi Picolive0.000 s 0.00x
Press Run, then drag the ball or use the arrow keys, and hold U I O P
The circuit itself, running here on the simulator. Press what can be pressed; click anything else to open it in the editor.

The code

The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.

sketch.ino · Raspberry Pi Pico
// Fight stick on a Raspberry Pi Pico: a template for your own build.
//
// A traditional arcade stick: a Sanwa JLF style lever for the left hand,
// eight attack buttons for the right in the common two-row layout, Select,
// Start and Home along the top and a turbo button. It has the features
// people expect from a modern controller board:
//
//   - lag-free debounce, explained below where it lives
//   - D-pad, left stick and right stick modes on GP2040-CE's button combos
//   - SOCD modes too: a square gate cannot reach two opposite switches, but
//     a worn actuator or a wiring fault can, and the cleaner keeps the output
//     a valid direction whatever the switches say
//   - turbo per button: hold TURBO and tap a button to arm or disarm it
//   - lit buttons that react to every press, with five themes and brightness
//   - an OLED that draws the lever in its gate and an input history in
//     numpad notation
//   - an input viewer on the Serial Monitor, one line per change
//
// The lever is four microswitches to a common ground on a five-pin harness:
// GND, UP, DOWN, LEFT, RIGHT. Check your harness's colors against its
// listing; they are not the same from every seller.
//
// The pin map is GP2040-CE's default for a bare Pico (configs/Pico in the
// GP2040-CE repository), so a build you prove here can be flashed with
// GP2040-CE on the bench and wired exactly the same.
//
// Try it: press Run, then drag the ball or use the arrow keys. U I O P and
// J K L ; are the attack buttons, Q E H are Select, Start and Home, and T is
// turbo.
//
// What Mokxi does not simulate: the Pico's USB. A real build sends each
// report to the console or PC; here the same report is a line on the Serial
// Monitor. Everything before that point (wiring, debounce, cleaning, modes,
// lights, screen) runs exactly as written.

#include <Wire.h>
#include <Adafruit_GFX.h>
#include <string.h>

// =============================================================================
//  YOUR BUILD: everything you are likely to change is in this block.
// =============================================================================

// ---- Layout ------------------------------------------------------------------
// Which controller this is. It decides how the OLED draws the buttons and the
// starting SOCD mode; the pins below are the same for all three.
#define LAYOUT_LEVERLESS 0  // four direction buttons (Hit Box style)
#define LAYOUT_LEVER 1      // a joystick lever (Sanwa JLF style)
#define LAYOUT_MIXBOX 2     // W A S D keys for the left hand
#define LAYOUT LAYOUT_LEVER

// ---- Pins --------------------------------------------------------------------
// One switch per pin, the other leg on ground. The pin's internal pull-up holds
// it HIGH and a press pulls it LOW, so no resistors are needed anywhere. Set a
// pin to NO_PIN to leave that input out of your build.
//
// Names follow GP2040-CE: B1 to B4 are the face buttons, L1/R1 and L2/R2 the
// shoulders and triggers, S1 Select, S2 Start, A1 Home. B1 to B4 and A1 are
// spelled BTN_B1 to BTN_B4 and BTN_A1 here, because Arduino already uses those
// names: B1 is a binary constant and A1 an analog pin. LABELS is what the
// screen and the Serial Monitor call each one; rename them for your game.
#define NO_PIN 255

enum Input : uint8_t {
  UP, DOWN, LEFT, RIGHT,          // directions
  BTN_B1, BTN_B2, BTN_B3, BTN_B4, // face buttons
  L1, R1, L2, R2,                 // shoulders and triggers
  S1, S2, BTN_A1, TURBO,          // Select, Start, Home, turbo
  INPUT_COUNT
};

//                                 GP2040-CE default for a Pico
const uint8_t PINS[INPUT_COUNT] = {
  2, 3, 5, 4,                   // Up GP2, Down GP3, Left GP5, Right GP4
  6, 7, 10, 11,                 // B1 GP6, B2 GP7, B3 GP10, B4 GP11
  13, 12, 9, 8,                 // L1 GP13, R1 GP12, L2 GP9, R2 GP8
  16, 17, 20, 14,               // S1 GP16, S2 GP17, A1 GP20, turbo GP14
};

// Street Fighter style names: punches on the top row, kicks on the bottom.
// For a four-button game you might use "1" "2" "3" "4" instead.
const char *const LABELS[INPUT_COUNT] = {
  "U", "D", "L", "R",
  "LK", "MK", "LP", "MP",
  "3P", "HP", "3K", "HK",
  "Sel", "Sta", "Hom", "Tur",
};

// The eight attack buttons in the order they sit on the panel, top row then
// bottom row, left to right. The lights and the screen both follow it.
const Input PANEL[8] = {BTN_B3, BTN_B4, R1, L1, BTN_B1, BTN_B2, R2, L2};

// ---- Lit buttons ---------------------------------------------------------------
// One pin per lit button, in PANEL order, driving the lamp inside the button
// through PWM. NO_PIN for a button without a lamp. (GP2040-CE drives a WS2812
// chain from one pin through the RP2040's PIO; Mokxi does not model PIO, so
// this template lights each button from its own pin instead. GP15 is
// GP2040-CE's turbo LED by default; here it lights LK, the first button of
// the bottom row, and the Pico's own LED shows turbo.)
//
// The lamps are the 5 V LED buttons sold for sticks. From a 3.3 V pin they
// glow at about half their rated brightness; for full brightness, switch each
// lamp from 5 V through a small NPN transistor or a driver chip.
const uint8_t LAMP_PINS[8] = {18, 19, 21, 22, 15, 26, 27, 28};
const uint8_t TURBO_LED_PIN = LED_BUILTIN;  // GP25, on the Pico itself

enum Theme : uint8_t { THEME_STATIC, THEME_BREATHE, THEME_WAVE, THEME_REACTIVE, THEME_OFF, THEME_COUNT };
const Theme START_THEME = THEME_REACTIVE;
const uint8_t START_BRIGHTNESS = 3;     // 0 to 4
const uint16_t PRESS_FADE_MS = 280;     // how long a lit press takes to fade

// ---- SOCD cleaning ---------------------------------------------------------------
// What the controller sends when opposite directions are held together. See
// the SOCD section below for what each one does and which events want which.
enum Socd : uint8_t { SOCD_NEUTRAL, SOCD_UP_PRIORITY, SOCD_LAST_WIN, SOCD_FIRST_WIN, SOCD_OFF, SOCD_COUNT };
const Socd START_SOCD = SOCD_NEUTRAL;

// ---- Direction output ------------------------------------------------------------
enum DpadMode : uint8_t { MODE_DPAD, MODE_LEFT_STICK, MODE_RIGHT_STICK, MODE_COUNT };
const DpadMode START_MODE = MODE_DPAD;

// ---- Timing ------------------------------------------------------------------------
const uint8_t DEBOUNCE_MS = 5;          // lockout after an accepted change
const uint16_t SCAN_US = 250;           // read every input this often (4 kHz)
const uint8_t TURBO_SHOTS = 10;         // presses per second while held
const uint16_t TOAST_MS = 1200;         // how long a mode change stays on screen

// ---- Screen and Serial Monitor ------------------------------------------------------
const bool OLED_ENABLED = true;
const uint8_t OLED_SDA = 0;             // GP2040-CE default display pins
const uint8_t OLED_SCL = 1;
const uint8_t OLED_ADDRESS = 0x3C;
const bool INPUT_VIEWER = true;         // one line per change on Serial

// =============================================================================
//  The firmware. You should not need to change anything below to make the
//  build your own, but it is written to be read.
// =============================================================================

// -----------------------------------------------------------------------------
//  The screen driver
//
//  A controller cannot stop reading its buttons to talk to a screen. The
//  stock SSD1306 library sends the whole 1 KB frame on every display() call,
//  about 25 ms of I2C at 400 kHz on real hardware, and every press that lands
//  in that window waits for it. GP2040-CE avoids this by running the display
//  on the RP2040's second core. This template has one core (Mokxi models one,
//  and the technique is worth knowing anyway), so it does the same job by
//  slicing: drawing goes into a frame in RAM, and each scan sends at most one
//  8 x 8 pixel block that differs from what the panel already shows. A block
//  is 17 bytes, about 0.4 ms at 400 kHz on a real board (Mokxi's bit-banged
//  bus measures about 1.2 ms), an idle screen costs nothing, and the inputs
//  are never kept waiting for a whole frame.
//
//  Drawing uses Adafruit_GFX, so every shape and text call works as usual.
// -----------------------------------------------------------------------------
const uint16_t OLED_OFF = 0;
const uint16_t OLED_ON = 1;

class OledPanel : public Adafruit_GFX {
 public:
  OledPanel() : Adafruit_GFX(128, 64) {}

  // The standard SSD1306 power-up sequence for a 128 x 64 module.
  bool begin(uint8_t address) {
    address_ = address;
    static const uint8_t INIT[] = {
      0xAE, 0xD5, 0x80, 0xA8, 0x3F, 0xD3, 0x00, 0x40, 0x8D, 0x14, 0x20, 0x00,
      0xA1, 0xC8, 0xDA, 0x12, 0x81, 0xCF, 0xD9, 0xF1, 0xDB, 0x40, 0xA4, 0xA6, 0xAF,
    };
    Wire.beginTransmission(address_);
    Wire.write(0x00);
    for (uint8_t b : INIT) Wire.write(b);
    if (Wire.endTransmission() != 0) return false;
    // The panel's RAM is random at power-up: mark every block as unsent so
    // the first passes clear it.
    memset(frame_, 0, sizeof(frame_));
    memset(sent_, 0xFF, sizeof(sent_));
    pending_ = true;
    return true;
  }

  void drawPixel(int16_t x, int16_t y, uint16_t color) override {
    if (x < 0 || y < 0 || x >= 128 || y >= 64) return;
    uint8_t &b = frame_[(y >> 3) * 128 + x];
    uint8_t bitMask = (uint8_t)(1u << (y & 7));
    b = color ? (uint8_t)(b | bitMask) : (uint8_t)(b & ~bitMask);
  }

  void clear() { memset(frame_, 0, sizeof(frame_)); }

  // Call after drawing a new frame, so the next scans look for changes.
  void framed() { pending_ = true; }

  // Send the next block that differs, if any. Returns true when it sent one.
  bool flushStep() {
    if (!pending_) return false;
    for (uint8_t n = 0; n < BLOCKS; n++) {
      uint8_t block = cursor_;
      cursor_ = (uint8_t)((cursor_ + 1) % BLOCKS);
      uint16_t at = (uint16_t)block * 8u;
      if (memcmp(frame_ + at, sent_ + at, 8) == 0) continue;
      uint8_t page = block / 16u, column = (uint8_t)((block % 16u) * 8u);
      Wire.beginTransmission(address_);
      const uint8_t window[] = {0x00, 0x21, column, (uint8_t)(column + 7u), 0x22, page, page};
      for (uint8_t b : window) Wire.write(b);
      Wire.endTransmission();
      Wire.beginTransmission(address_);
      Wire.write(0x40);
      for (uint8_t i = 0; i < 8; i++) Wire.write(frame_[at + i]);
      Wire.endTransmission();
      memcpy(sent_ + at, frame_ + at, 8);
      return true;
    }
    pending_ = false;  // a whole pass found nothing to send
    return false;
  }

 private:
  static const uint8_t BLOCKS = 128;  // 8 pages x 16 blocks of 8 columns
  uint8_t frame_[1024];
  uint8_t sent_[1024];
  uint8_t address_ = 0x3C;
  uint8_t cursor_ = 0;
  bool pending_ = false;
};

OledPanel oled;

inline uint16_t mask(Input i) { return (uint16_t)(1u << i); }
const uint16_t ATTACK_MASK = (uint16_t)0x0FF0;   // BTN_B1 to R2

// -----------------------------------------------------------------------------
//  Debounce
//
//  A microswitch does not close cleanly: the contact bounces open and shut a
//  few times in the first millisecond or two. The textbook debounce waits for
//  the pin to sit still before believing it, which adds that wait to every
//  press. A controller cannot afford that, so this one is eager: the first edge
//  is accepted the moment it is seen, and the input then ignores its pin for
//  DEBOUNCE_MS. The bounce lands inside the lockout and is thrown away; the
//  press itself is reported with no delay at all. The cost is that a lone
//  spike would count as a press, which a microswitch with a pull-up does not
//  produce. Release works the same way, so a quick tap is never swallowed.
// -----------------------------------------------------------------------------
struct Debouncer {
  uint16_t held = 0;                    // accepted state, one bit per input
  uint32_t changedAt[INPUT_COUNT] = {}; // when each input last changed

  // Returns the inputs whose accepted state changed on this scan.
  uint16_t scan(uint32_t nowMs) {
    uint16_t changed = 0;
    for (uint8_t i = 0; i < INPUT_COUNT; i++) {
      if (PINS[i] == NO_PIN) continue;
      bool down = digitalRead(PINS[i]) == LOW;
      bool was = held & (1u << i);
      if (down == was) continue;
      if (nowMs - changedAt[i] < DEBOUNCE_MS) continue;  // still bouncing
      held ^= (uint16_t)(1u << i);
      changedAt[i] = nowMs;
      changed |= (uint16_t)(1u << i);
    }
    return changed;
  }
};

// -----------------------------------------------------------------------------
//  SOCD cleaning
//
//  A lever's gate cannot point left and right at once, but four buttons can be
//  held in any combination. SOCD (simultaneous opposite cardinal directions)
//  cleaning decides what the controller sends instead:
//
//    Neutral      left + right = neither, up + down = neither
//    Up priority  left + right = neither, up + down = up (the classic Hit Box
//                 rule, which GP2040-CE also calls Up priority)
//    Last win     the newer of the two wins; let go of it and the older one
//                 comes back (GP2040-CE: Last win, "second input priority")
//    First win    the one held first wins until it is released
//    Off          both are sent and the game decides. Many rule sets forbid
//                 a controller sending both, so treat this as a testing mode.
//
//  Which events want which: Evo's rules require that a controller never sends
//  opposite directions together and accept Neutral, First input priority and
//  Last input priority. The Capcom Pro Tour's 2023 rule for Street Fighter 6
//  says up + down must give neither a jump nor a crouch, and left + right
//  neither walk, which Neutral satisfies and Up priority does not. Rules
//  change by event and by game, so check the current ruleset before you play.
//  Neutral is the start mode here because it is accepted by both.
//
//  Two presses that land on the same scan have no order, so Last win and First
//  win fall back to neutral for them rather than guessing.
// -----------------------------------------------------------------------------
struct SocdCleaner {
  uint32_t pressedAt[4] = {};  // scan number each direction was pressed on

  void note(uint16_t changed, uint16_t held, uint32_t scanNo) {
    for (uint8_t d = UP; d <= RIGHT; d++) {
      if ((changed & held) & (1u << d)) pressedAt[d] = scanNo;
    }
  }

  // One axis: -1, 0 or +1, or 2 for "both" (SOCD off).
  int8_t axis(bool neg, bool pos, uint8_t negDir, uint8_t posDir, bool vertical, Socd mode) const {
    if (neg != pos) return pos ? 1 : -1;
    if (!neg) return 0;
    switch (mode) {
      case SOCD_UP_PRIORITY:
        return vertical ? 1 : 0;  // positive is up on the vertical axis
      case SOCD_LAST_WIN:
      case SOCD_FIRST_WIN: {
        uint32_t a = pressedAt[negDir], b = pressedAt[posDir];
        if (a == b) return 0;
        bool posNewer = b > a;
        return (posNewer == (mode == SOCD_LAST_WIN)) ? 1 : -1;
      }
      case SOCD_OFF:
        return 2;
      case SOCD_NEUTRAL:
      default:
        return 0;
    }
  }
};

// -----------------------------------------------------------------------------
//  The report: what a USB game controller would send to the host.
// -----------------------------------------------------------------------------
struct Report {
  int8_t x = 0, y = 0;     // cleaned direction, y positive is up (2 = both)
  uint16_t buttons = 0;    // BTN_B1 .. BTN_A1 bits, after turbo and hotkeys
  DpadMode mode = MODE_DPAD;

  bool operator!=(const Report &o) const { return x != o.x || y != o.y || buttons != o.buttons || mode != o.mode; }

  // Numpad notation: 5 neutral, 6 right, 2 down, 3 down-right, 8 up, and so on.
  uint8_t numpad() const {
    int8_t cx = (x == 2) ? 0 : x, cy = (y == 2) ? 0 : y;
    return (uint8_t)(5 + cx + 3 * cy);
  }
};

// -----------------------------------------------------------------------------
//  Controller state
// -----------------------------------------------------------------------------
Debouncer debouncer;
SocdCleaner socd;
Socd socdMode = START_SOCD;
DpadMode dpadMode = START_MODE;
Theme theme = START_THEME;
uint8_t brightness = START_BRIGHTNESS;
uint16_t turboArmed = 0;          // attack buttons with turbo on
uint32_t scanNo = 0;
Report sent;                      // the last report "sent" to the host
uint32_t sentAt = 0;

uint32_t releasedAt[8] = {};      // per panel button, for the press fade
bool lampHeld[8] = {};

char toast[22] = "";
uint32_t toastUntil = 0;
bool screenDirty = true;

// The input history the screen shows: the newest tokens on the right, in
// numpad notation with button presses after them, the way a training mode's
// input display reads ("2 3 6 HP").
char history[40] = "";

const char *socdName(Socd m) {
  switch (m) {
    case SOCD_NEUTRAL: return "Neutral";
    case SOCD_UP_PRIORITY: return "Up priority";
    case SOCD_LAST_WIN: return "Last win";
    case SOCD_FIRST_WIN: return "First win";
    default: return "Off";
  }
}
const char *socdShort(Socd m) {
  const char *s[] = {"NEU", "UP", "LAST", "1ST", "OFF"};
  return s[m];
}
const char *modeName(DpadMode m) {
  const char *s[] = {"D-pad", "Left stick", "Right stick"};
  return s[m];
}
const char *modeShort(DpadMode m) {
  const char *s[] = {"DP", "LS", "RS"};
  return s[m];
}
const char *themeName(Theme t) {
  const char *s[] = {"Static", "Breathe", "Wave", "Reactive", "Lights off"};
  return s[t];
}

void showToast(const char *a, const char *b = nullptr) {
  uint8_t n = 0;
  for (const char *p = a; *p && n < sizeof(toast) - 1; p++) toast[n++] = *p;
  if (b) {
    if (n < sizeof(toast) - 1) toast[n++] = ' ';
    for (const char *p = b; *p && n < sizeof(toast) - 1; p++) toast[n++] = *p;
  }
  toast[n] = '\0';
  toastUntil = millis() + TOAST_MS;
  screenDirty = true;
  if (INPUT_VIEWER) {
    Serial.print("== ");
    Serial.println(toast);
  }
}

void pushHistory(const char *token) {
  size_t len = strlen(history), add = strlen(token) + 1;
  if (len + add >= sizeof(history)) {
    size_t drop = len + add - (sizeof(history) - 1);
    memmove(history, history + drop, len - drop + 1);
    len -= drop;
  }
  if (len > 0) history[len++] = ' ';
  strcpy(history + len, token);
}

// -----------------------------------------------------------------------------
//  Hotkeys
//
//  Two or more of Select, Start and Home held together open the menu. While it
//  is open nothing reaches the game: not the function buttons, and not the
//  directions, which pick what to change. Each combo fires once when it is
//  completed, the screen and the Serial Monitor confirm it, and the menu
//  closes when the function buttons are let go. That way a mode change can
//  never leak a Start press or a jump into a match.
//
//    S1 + S2 + Down    D-pad              (GP2040-CE default)
//    S1 + S2 + Left    left stick         (GP2040-CE default)
//    S1 + S2 + Right   right stick        (GP2040-CE default)
//    S1 + S2 + Up      Home               (GP2040-CE default)
//    S2 + BTN_A1 + Up      SOCD Up priority   (GP2040-CE default)
//    S2 + BTN_A1 + Down    SOCD Neutral       (GP2040-CE default)
//    S2 + BTN_A1 + Left    SOCD Last win      (GP2040-CE default)
//    S2 + BTN_A1 + Right   SOCD First win     (this template)
//    S1 + BTN_A1 + Left/Right   previous/next light theme (this template)
//    S1 + BTN_A1 + Up/Down      brighter/dimmer           (this template)
//
//  Turbo: hold TURBO and tap an attack button to arm or disarm turbo on it.
// -----------------------------------------------------------------------------
const uint16_t FN_MASK = (uint16_t)((1u << S1) | (1u << S2) | (1u << BTN_A1));

uint8_t popcount(uint16_t v) {
  uint8_t n = 0;
  for (; v; v &= (uint16_t)(v - 1)) n++;
  return n;
}

bool menuOpen(uint16_t held) { return popcount(held & FN_MASK) >= 2; }

// The combo a direction completes while the menu is open. Returns true when
// it did something, so the caller knows the press was used.
bool runHotkey(uint16_t held, Input dir) {
  bool s1 = held & mask(S1), s2 = held & mask(S2), a1 = held & mask(BTN_A1);
  if (s1 && s2 && !a1) {
    if (dir == DOWN) dpadMode = MODE_DPAD;
    else if (dir == LEFT) dpadMode = MODE_LEFT_STICK;
    else if (dir == RIGHT) dpadMode = MODE_RIGHT_STICK;
    else return true;  // Up is Home, sent while it is held (see buildReport)
    showToast("Mode:", modeName(dpadMode));
    return true;
  }
  if (s2 && a1 && !s1) {
    if (dir == UP) socdMode = SOCD_UP_PRIORITY;
    else if (dir == DOWN) socdMode = SOCD_NEUTRAL;
    else if (dir == LEFT) socdMode = SOCD_LAST_WIN;
    else socdMode = SOCD_FIRST_WIN;
    showToast("SOCD:", socdName(socdMode));
    return true;
  }
  if (s1 && a1 && !s2) {
    if (dir == LEFT) theme = (Theme)((theme + THEME_COUNT - 1) % THEME_COUNT);
    else if (dir == RIGHT) theme = (Theme)((theme + 1) % THEME_COUNT);
    else if (dir == UP && brightness < 4) brightness++;
    else if (dir == DOWN && brightness > 0) brightness--;
    if (dir == LEFT || dir == RIGHT) {
      showToast("Lights:", themeName(theme));
    } else {
      char level[12] = "Bright 0/4";
      level[7] = (char)('0' + brightness);
      showToast(level);
    }
    return true;
  }
  return false;
}

void handleTurboToggle(uint16_t pressed) {
  for (uint8_t i = BTN_B1; i <= R2; i++) {
    if (!(pressed & (1u << i))) continue;
    turboArmed ^= (uint16_t)(1u << i);
    showToast((turboArmed & (1u << i)) ? "Turbo on:" : "Turbo off:", LABELS[i]);
  }
}

// -----------------------------------------------------------------------------
//  Building the report
// -----------------------------------------------------------------------------
Report buildReport(uint16_t held, uint32_t nowMs) {
  Report r;
  r.mode = dpadMode;
  bool menu = menuOpen(held);
  bool turboHeld = held & mask(TURBO);

  if (!menu) {
    r.x = socd.axis(held & mask(LEFT), held & mask(RIGHT), LEFT, RIGHT, false, socdMode);
    r.y = socd.axis(held & mask(DOWN), held & mask(UP), DOWN, UP, true, socdMode);
    r.buttons = held & (uint16_t)(ATTACK_MASK | FN_MASK);
  } else if ((held & mask(S1)) && (held & mask(S2)) && !(held & mask(BTN_A1)) && (held & mask(UP))) {
    r.buttons = mask(BTN_A1);  // S1 + S2 + Up is Home
  }

  // Turbo held means "choose buttons", so attack presses do not reach the game.
  if (turboHeld) r.buttons &= (uint16_t)~ATTACK_MASK;

  // An armed button that is held fires at TURBO_SHOTS a second: down for the
  // first half of each period, up for the second, starting down on the press.
  uint16_t firing = r.buttons & turboArmed;
  if (firing) {
    uint32_t period = 1000u / TURBO_SHOTS;
    for (uint8_t i = BTN_B1; i <= R2; i++) {
      if (!(firing & (1u << i))) continue;
      uint32_t since = nowMs - debouncer.changedAt[i];
      if ((since % period) >= period / 2) r.buttons &= (uint16_t)~(1u << i);
    }
  }
  return r;
}

// -----------------------------------------------------------------------------
//  Input viewer: one line on the Serial Monitor per report that changed.
//
//      1234 ms  dir 3  LP HK         D-pad 3      (prev 9f)
//
//  dir is numpad notation, then the buttons held, then what the host gets for
//  the direction in the current mode, then how many 60 Hz frames the previous
//  report lasted, which is how players count timing.
// -----------------------------------------------------------------------------
void printReport(const Report &r, uint32_t nowMs) {
  if (!INPUT_VIEWER) return;
  char line[96];
  uint8_t n = 0;
  auto put = [&](const char *s) {
    while (*s && n < sizeof(line) - 1) line[n++] = *s++;
  };
  auto putNum = [&](long v, uint8_t width) {
    char tmp[12];
    uint8_t k = 0;
    bool neg = v < 0;
    unsigned long u = neg ? (unsigned long)(-v) : (unsigned long)v;
    do { tmp[k++] = (char)('0' + u % 10); u /= 10; } while (u && k < 10);
    if (neg) tmp[k++] = '-';
    while (k < width) tmp[k++] = ' ';
    while (k) { if (n < sizeof(line) - 1) line[n++] = tmp[--k]; else k--; }
  };
  auto pad = [&](uint8_t col) { while (n < col && n < sizeof(line) - 1) line[n++] = ' '; };

  putNum((long)nowMs, 7);
  put(" ms  dir ");
  line[n++] = (char)('0' + r.numpad());
  if (r.x == 2 || r.y == 2) put("!");
  put("  ");
  uint8_t start = n;
  bool any = false;
  for (uint8_t i = BTN_B1; i <= BTN_A1; i++) {
    if (r.buttons & (1u << i)) {
      if (any) put(" ");
      put(LABELS[i]);
      any = true;
    }
  }
  if (!any) put("-");
  pad((uint8_t)(start + 16));
  put("  ");
  if (r.mode == MODE_DPAD) {
    put("D-pad ");
    if (r.x == 2 || r.y == 2) put("both");
    else line[n++] = (char)('0' + r.numpad());
  } else {
    put(r.mode == MODE_LEFT_STICK ? "LS " : "RS ");
    int8_t cx = r.x == 2 ? 0 : r.x, cy = r.y == 2 ? 0 : r.y;
    putNum(cx * 100, 0);
    put("%,");
    putNum(cy * 100, 0);
    put("%");
  }
  if (sentAt != 0) {
    pad((uint8_t)(start + 34));
    put("  (prev ");
    putNum((long)(((nowMs - sentAt) * 60u + 500u) / 1000u), 0);
    put("f)");
  }
  line[n] = '\0';
  Serial.println(line);
}

// -----------------------------------------------------------------------------
//  Lit buttons
//
//  Every theme sets a resting level for each button; a press lights the button
//  fully and the release fades it back to rest over PRESS_FADE_MS. Levels go
//  through a square-law curve so the fade looks even to the eye, which sees
//  brightness on roughly that kind of curve rather than in straight steps.
// -----------------------------------------------------------------------------
uint8_t lastLamp[8];

uint8_t restLevel(uint8_t slot, uint32_t nowMs) {
  const uint8_t top = (uint8_t)(40 + brightness * 50);  // 40 .. 240
  switch (theme) {
    case THEME_STATIC:
      return top;
    case THEME_BREATHE: {
      uint32_t t = nowMs % 3000u;                       // one breath, 3 s
      uint32_t tri = t < 1500u ? t : 3000u - t;         // 0 .. 1500 .. 0
      return (uint8_t)(top * tri / 1500u);
    }
    case THEME_WAVE: {
      // A crest that runs along the panel: top row, then bottom row.
      uint32_t pos = (nowMs / 90u) % 12u;               // 8 buttons + a gap
      uint8_t col = slot % 4u, row = slot / 4u;
      uint8_t me = (uint8_t)(col + row * 4u);
      uint32_t d = pos > me ? pos - me : me - pos;
      return d == 0 ? top : d == 1 ? top / 3 : 0;
    }
    case THEME_REACTIVE:
    case THEME_OFF:
    default:
      return 0;
  }
}

void updateLamps(uint16_t held, uint16_t armedFiring, uint32_t nowMs) {
  for (uint8_t s = 0; s < 8; s++) {
    if (LAMP_PINS[s] == NO_PIN) continue;
    Input in = PANEL[s];
    bool down = held & mask(in);
    if (lampHeld[s] && !down) releasedAt[s] = nowMs;
    lampHeld[s] = down;

    uint8_t level = restLevel(s, nowMs);
    if (theme != THEME_OFF) {
      uint8_t top = (uint8_t)(40 + brightness * 50);
      if (down) {
        // An armed button that is firing blinks with its shots.
        bool shotDown = sent.buttons & mask(in);
        level = (armedFiring & mask(in)) && !shotDown ? (uint8_t)(top / 4) : 255;
      } else if (releasedAt[s] != 0 && nowMs - releasedAt[s] < PRESS_FADE_MS) {
        uint32_t left = PRESS_FADE_MS - (nowMs - releasedAt[s]);
        uint8_t fade = (uint8_t)(255u * left / PRESS_FADE_MS);
        if (fade > level) level = fade;
      }
    }
    uint8_t out = (uint8_t)(((uint16_t)level * level + 254u) / 255u);  // square law
    if (out != lastLamp[s]) {
      lastLamp[s] = out;
      analogWrite(LAMP_PINS[s], out);
    }
  }
}

// -----------------------------------------------------------------------------
//  The screen layout
//
//  A new frame is drawn into RAM at most 30 times a second, and only when
//  something on it changed; the driver above sends it a block per scan.
// -----------------------------------------------------------------------------
uint32_t drawnAt = 0;

void drawButton(int16_t x, int16_t y, int16_t r, bool down) {
  if (down) oled.fillCircle(x, y, r, OLED_ON);
  else oled.drawCircle(x, y, r, OLED_ON);
}

void drawKey(int16_t x, int16_t y, bool down) {
  if (down) oled.fillRoundRect(x - 5, y - 5, 11, 11, 2, OLED_ON);
  else oled.drawRoundRect(x - 5, y - 5, 11, 11, 2, OLED_ON);
}

void drawDirections(const Report &r, uint16_t held) {
  // What the player is holding, drawn where it sits on the panel. The cleaned
  // result is the dot in the corner box, so you can see SOCD do its work.
#if LAYOUT == LAYOUT_LEVER
  oled.drawRect(8, 19, 30, 30, OLED_ON);  // the square gate
  int8_t cx = r.x == 2 ? 0 : r.x, cy = r.y == 2 ? 0 : r.y;
  oled.fillCircle(23 + cx * 9, 34 - cy * 9, 5, OLED_ON);
  (void)held;
#elif LAYOUT == LAYOUT_MIXBOX
  drawKey(22, 24, held & mask(UP));
  drawKey(9, 38, held & mask(LEFT));
  drawKey(22, 38, held & mask(DOWN));
  drawKey(35, 38, held & mask(RIGHT));
#else
  drawButton(7, 26, 5, held & mask(LEFT));
  drawButton(19, 23, 5, held & mask(DOWN));
  drawButton(31, 26, 5, held & mask(RIGHT));
  drawButton(36, 45, 7, held & mask(UP));
#endif
#if LAYOUT != LAYOUT_LEVER
  // the cleaned direction, as a dot in a 3 x 3 box
  oled.drawRect(2, 41, 13, 13, OLED_ON);
  int8_t dx = r.x == 2 ? 0 : r.x, dy = r.y == 2 ? 0 : r.y;
  oled.fillRect(7 + dx * 4, 46 - dy * 4, 3, 3, OLED_ON);
#endif
}

void drawScreen(const Report &r, uint16_t held, uint32_t nowMs) {
  oled.clear();
  oled.setTextSize(1);
  oled.setTextColor(OLED_ON);

  // Status bar: direction mode, SOCD mode, turbo.
  oled.setCursor(0, 0);
  oled.print(modeShort(dpadMode));
  oled.setCursor(22, 0);
  oled.print("SOCD ");
  oled.print(socdShort(socdMode));
  if (turboArmed) {
    oled.setCursor(98, 0);
    oled.print("TURBO");
  }
  oled.drawFastHLine(0, 10, 128, OLED_ON);

  drawDirections(r, held);

  // The eight attack buttons in two staggered rows, as on the panel.
  static const int8_t COL_X[4] = {56, 72, 88, 104};
  static const int8_t ROW_Y[2] = {24, 40};
  static const int8_t STAGGER[4] = {0, -3, -3, 0};
  for (uint8_t s = 0; s < 8; s++) {
    uint8_t col = s % 4u, row = s / 4u;
    Input in = PANEL[s];
    int16_t x = COL_X[col], y = (int16_t)(ROW_Y[row] + STAGGER[col]);
    drawButton(x, y, 6, held & mask(in));
    if (turboArmed & mask(in)) oled.drawPixel(x, y - 9, OLED_ON);
  }
  // Select, Start, Home as small squares on the right edge.
  for (uint8_t k = 0; k < 3; k++) {
    Input in = (Input)(S1 + k);
    int16_t y = (int16_t)(16 + k * 11);
    if (held & mask(in)) oled.fillRect(119, y, 7, 7, OLED_ON);
    else oled.drawRect(119, y, 7, 7, OLED_ON);
  }

  // Input history along the bottom, newest on the right.
  size_t len = strlen(history);
  const char *tail = len > 21 ? history + (len - 21) : history;
  oled.setCursor((int16_t)(128 - 6 * (int16_t)strlen(tail)), 56);
  oled.print(tail);

  // A mode change, boxed over everything else until it times out.
  if (toastUntil && (int32_t)(toastUntil - nowMs) > 0) {
    int16_t w = (int16_t)(strlen(toast) * 6 + 8);
    int16_t x = (int16_t)((128 - w) / 2);
    oled.fillRect(x, 22, w, 17, OLED_OFF);
    oled.drawRect(x, 22, w, 17, OLED_ON);
    oled.setCursor((int16_t)(x + 4), 27);
    oled.print(toast);
  }
  oled.framed();
}

// -----------------------------------------------------------------------------
//  Setup and the scan loop
// -----------------------------------------------------------------------------
bool oledOk = false;

void setup() {
  for (uint8_t i = 0; i < INPUT_COUNT; i++) {
    if (PINS[i] != NO_PIN) pinMode(PINS[i], INPUT_PULLUP);
  }
  for (uint8_t s = 0; s < 8; s++) {
    if (LAMP_PINS[s] != NO_PIN) {
      pinMode(LAMP_PINS[s], OUTPUT);
      analogWrite(LAMP_PINS[s], 0);
    }
  }
  pinMode(TURBO_LED_PIN, OUTPUT);
  Serial.begin(115200);

  if (OLED_ENABLED) {
    Wire.begin(OLED_SDA, OLED_SCL);
    Wire.setClock(400000);
    oledOk = oled.begin(OLED_ADDRESS);
  }

  Serial.println("Fight stick ready. Arrow keys or drag the ball for the lever, U I O P / J K L ; attacks,");
  Serial.println("Q E H = Select Start Home, T = turbo. dir is numpad notation: 5 is neutral.");
  Serial.print("SOCD ");
  Serial.print(socdName(socdMode));
  Serial.print(", ");
  Serial.print(modeName(dpadMode));
  Serial.print(", lights ");
  Serial.println(themeName(theme));
  if (OLED_ENABLED && !oledOk) Serial.println("No OLED answered at 0x3C: check SDA on GP0 and SCL on GP1.");
  printReport(sent, millis());
  sentAt = millis();
}

uint32_t nextScan = 0;

void loop() {
  // Pace the scan to SCAN_US. A full-speed USB host asks for a report at most
  // once a millisecond, so scanning four times as often keeps the added delay
  // under a quarter of a millisecond, and the wait parks the core in between.
  uint32_t nowUs = micros();
  if ((int32_t)(nextScan - nowUs) > 0) {
    delayMicroseconds(nextScan - nowUs);
  }
  nextScan = micros() + SCAN_US;
  uint32_t nowMs = millis();
  scanNo++;

  uint16_t changed = debouncer.scan(nowMs);
  uint16_t held = debouncer.held;
  uint16_t pressed = changed & held;
  socd.note(changed, held, scanNo);

  // Hotkeys and turbo selection act on presses, once each.
  if (pressed) {
    if (menuOpen(held)) {
      for (uint8_t d = UP; d <= RIGHT; d++) {
        if (pressed & (1u << d)) runHotkey(held, (Input)d);
      }
    } else if (held & mask(TURBO)) {
      handleTurboToggle(pressed & ATTACK_MASK);
    }
  }

  Report r = buildReport(held, nowMs);
  if (r != sent) {
    // History: a new direction, and each newly pressed button.
    if (r.numpad() != sent.numpad()) {
      char d[2] = {(char)('0' + r.numpad()), '\0'};
      pushHistory(d);
    }
    uint16_t newButtons = r.buttons & (uint16_t)~sent.buttons & ATTACK_MASK;
    for (uint8_t i = BTN_B1; i <= R2; i++) {
      if (newButtons & (1u << i)) pushHistory(LABELS[i]);
    }
    printReport(r, nowMs);
    sent = r;
    sentAt = nowMs;
    screenDirty = true;
  }
  if (changed) screenDirty = true;

  digitalWrite(TURBO_LED_PIN, turboArmed ? HIGH : LOW);
  updateLamps(held, held & turboArmed, nowMs);

  // The screen last: a new frame when something changed, and one block of
  // it to the panel per scan.
  if (toastUntil && (int32_t)(toastUntil - nowMs) <= 0) {
    toastUntil = 0;
    screenDirty = true;
  }
  if (oledOk && screenDirty && nowMs - drawnAt >= 33) {
    drawScreen(sent, held, nowMs);
    drawnAt = nowMs;
    screenDirty = false;
  }
  if (oledOk) oled.flushStep();
}

Parts list

15 parts, plus the jumper wires. Every one is in the editor's parts bin.

How it is wired

31 connections, pin by pin, read from the circuit itself. Each line is one set of pins joined together, by a jumper wire or a breadboard strip.

  • Raspberry Pi Pico pin GP0; OLED display, 128x64 pin SDA
  • Raspberry Pi Pico pin GP1; OLED display, 128x64 pin SCL
  • Raspberry Pi Pico pin GP2; Arcade joystick lever pin UP
  • Raspberry Pi Pico pin GP3; Arcade joystick lever pin DOWN
  • Raspberry Pi Pico pin GP4; Arcade joystick lever pin RIGHT
  • Raspberry Pi Pico pin GP5; Arcade joystick lever pin LEFT
  • Ground: Raspberry Pi Pico pin GND; Arcade joystick lever pin GND
  • Raspberry Pi Pico pin GP6; LED arcade button (5) pin 1
  • Raspberry Pi Pico pin GP7; LED arcade button (6) pin 1
  • Raspberry Pi Pico pin GP8; LED arcade button (7) pin 1
  • Raspberry Pi Pico pin GP9; LED arcade button (8) pin 1
  • Ground: Raspberry Pi Pico pin GND; LED arcade button (1) pin 2; LED arcade button (1) pin L-; LED arcade button (2) pin 2; LED arcade button (2) pin L-; LED arcade button (3) pin 2; LED arcade button (3) pin L-; LED arcade button (4) pin 2; LED arcade button (4) pin L-; LED arcade button (5) pin 2; LED arcade button (5) pin L-; LED arcade button (6) pin 2; LED arcade button (6) pin L-; LED arcade button (7) pin 2; LED arcade button (7) pin L-; LED arcade button (8) pin 2; LED arcade button (8) pin L-; Arcade button (1) pin 2
  • Raspberry Pi Pico pin GP10; LED arcade button (1) pin 1
  • Raspberry Pi Pico pin GP11; LED arcade button (2) pin 1
  • Raspberry Pi Pico pin GP12; LED arcade button (3) pin 1
  • Raspberry Pi Pico pin GP13; LED arcade button (4) pin 1
  • Raspberry Pi Pico pin GP14; Arcade button (1) pin 1
  • Raspberry Pi Pico pin GP15; LED arcade button (5) pin L+
  • Ground: Raspberry Pi Pico pin GND; OLED display, 128x64 pin GND
  • Raspberry Pi Pico pin 3V3; OLED display, 128x64 pin VCC
  • Raspberry Pi Pico pin GP28; LED arcade button (8) pin L+
  • Raspberry Pi Pico pin GP27; LED arcade button (7) pin L+
  • Raspberry Pi Pico pin GP26; LED arcade button (6) pin L+
  • Raspberry Pi Pico pin GP22; LED arcade button (4) pin L+
  • Raspberry Pi Pico pin GP21; LED arcade button (3) pin L+
  • Raspberry Pi Pico pin GP20; Arcade button (4) pin 1
  • Raspberry Pi Pico pin GP19; LED arcade button (2) pin L+
  • Raspberry Pi Pico pin GP18; LED arcade button (1) pin L+
  • Ground: Raspberry Pi Pico pin GND; Arcade button (2) pin 2; Arcade button (3) pin 2; Arcade button (4) pin 2
  • Raspberry Pi Pico pin GP17; Arcade button (3) pin 1
  • Raspberry Pi Pico pin GP16; Arcade button (2) pin 1

Change it and keep it

Open it in the editor, change the circuit or the code, and keep your version in a free account.