Build Guides / Deep-Dive Series
Deep-Dive Series // 8 Steps

Building the MonkiiPad20

The MonkiiPad20 steps up from our smaller pads — 20 handwired keys plus a 0.96" OLED screen, all driven by the RP2040 Zero. This guide takes you from a 3D-printed plate to a fully programmable macropad with a live display. No PCB required; just a diode matrix, an OLED, and Arduino firmware.

System Status

Nominal

Difficulty

Intermediate

Build Rev.

4–7 hours

← View Product: MonkiiPad20 View on GitHub →

Before You Build

Gather everything below before you start — it makes the whole build go smoothly.

Component Manifest

Component Qty Specification
MX-compatible switches 20 Any MX switch works — 3-pin or 5-pin
RP2040 Zero 1 Programmed via Arduino IDE (RP2040 core)
0.96" OLED (SSD1306, I²C) 1 128×64, 4-pin I²C module
1N4148 diodes 20 One per switch — through-hole
Hookup wire (24 AWG) ~4m Use multiple colors for rows vs columns
3D Printed Plate + Case (STL provided) 1 PLA, 0.2mm layer height, 40% infill
Keycaps (1u × 20) 20 Any MX-compatible set
M2 screws (6mm) 4–6 For case assembly
Soldering iron + solder Temperature-controlled iron recommended

Tools Required

01

Soldering iron (temp-controlled preferred)

02

Solder (63/37 or 60/40 rosin core)

03

Wire strippers

04

Flush cutters

05

Multimeter (continuity mode)

06

Tweezers

07

Computer with Arduino IDE installed

Build Steps

01

Step 1 // MONKIIPAD-20

Print the Plate & Case

Download the MonkiiPad20 STL files from our GitHub and 3D print the plate and case in PLA. Use 0.2mm layer height and 40% infill so the plate stays rigid under 20 switches. The case includes a cutout for the OLED window and the RP2040's USB-C port — print it flat, no supports needed.

Pro Tip

Test-fit one switch and the OLED in their cutouts before printing the full plate — it saves a reprint if your filament runs slightly large.

Step 1

02

Step 2 // MONKIIPAD-20

Mount Switches in the Plate

Snap all 20 switches into the plate. They should click in firmly with no wobble. Start with the four corners to lock the plate flat, then fill in the rest row by row. Confirm every switch sits flush before you start wiring.

Pro Tip

Check that no switch pins are bent before pressing down — bent pins cause hard-to-diagnose matrix issues later.

Step 2

03

Step 3 // MONKIIPAD-20

Solder Row Diodes

Solder one 1N4148 diode to the bottom pin of each switch. The cathode (black stripe) must point toward the row wire — keep the direction consistent across all 20 switches. Bend the diode body close to the pin, solder, and clip the excess lead flush.

Pro Tip

Wrong diode direction on even one switch will cause an entire row to ghost — double-check every stripe before soldering.

Step 3

04

Step 4 // MONKIIPAD-20

Wire the Rows

Run a wire horizontally across each row, soldering to each diode cathode. With 20 keys in a 4×6 grid that's 4 row wires — one continuous wire per row. Strip a small window at each joint, solder, and continue. Keep the wires tidy and close to the switches.

Pro Tip

Use one wire color for all rows and a different color for all columns — it makes debugging much easier.

Step 4

05

Step 5 // MONKIIPAD-20

Wire the Columns

Run a wire vertically through each column, soldering directly to the top pin of each switch — 6 column wires. Row wires touch diode cathodes; column wires touch switch pins. These two sets of wires must never touch each other.

Step 5

06

Step 6 // MONKIIPAD-20

Wire & Mount the OLED

The SSD1306 OLED uses I²C — four wires: VCC, GND, SDA, and SCL. Solder VCC to the RP2040's 3.3V pin, GND to ground, and SDA/SCL to its I²C pins (the firmware uses Wire1: SDA→GP14, SCL→GP15). Seat the screen into the case window so it shows through the cutout.

Pro Tip

Most 0.96" modules are address 0x3C. If the screen stays blank, run an I²C scanner sketch first to confirm the address.

Step 6

07

Step 7 // MONKIIPAD-20

Connect the Matrix to the RP2040 Zero

Connect each row and column to a GPIO pin on the RP2040 Zero, keeping clear of the OLED pins. The reference firmware uses rows on GP0, GP1, GP2, GP3 and columns on GP4, GP10, GP6, GP7, GP8, GP9. Write down every connection, or edit rowPins/colPins in the sketch to match your wiring.

Pro Tip

A simple sketch or spreadsheet of your pin mapping saves a lot of debugging time on a 20-key matrix.

Step 7

08

Step 8 // MONKIIPAD-20

Flash Arduino Firmware & Test

Open the MonkiiPad20 firmware sketch in Arduino IDE with the RP2040 board core installed. Set the rowPins and colPins arrays to match your wiring, confirm the OLED address, then upload. Press every key in a keyboard tester — all 20 should register — and check the screen draws correctly.

Pro Tip

Install the Adafruit SSD1306 + GFX libraries before compiling — the display code depends on them.

Step 8

Firmware

The full Arduino sketch for this build. Open it in the Arduino IDE, match the pins to your wiring, and upload — or grab the whole repo from GitHub.

MonkiiPad20.ino
#include <Keyboard.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

// ═══════════════════════════════════════════════════════
//  OLED
// ═══════════════════════════════════════════════════════
Adafruit_SSD1306 display(128, 64, &Wire1, -1);

// ═══════════════════════════════════════════════════════
//  MATRIX PINS
// ═══════════════════════════════════════════════════════
const int ROW_NUM = 4;
const int COL_NUM = 6;
const int rowPins[ROW_NUM] = {0, 1, 2, 3};
const int colPins[COL_NUM] = {4, 10, 6, 7, 8, 9};

// ═══════════════════════════════════════════════════════
//  SPECIAL KEY CODES
// ═══════════════════════════════════════════════════════
#define K_EMPTY  0x00
#define K_FN     0xF1
#define K_CTRL   0xF2
#define K_DEL    0xF3

// ═══════════════════════════════════════════════════════
//  KEYMAPS [layer][row][col]
//  Layer 0: SHORTCUTER (default boot layer)
//    FN  = Row2,Col5 (H position)
//    CTRL= Row3,Col5 (N position)
//    DEL = Row0,Col3 (4 position)
//  Layer 1: TYPIST
//    Same FN/CTRL positions, everything else types normally
// ═══════════════════════════════════════════════════════
const uint8_t keymap[2][ROW_NUM][COL_NUM] = {
  // ── Layer 0: SHORTCUTER ──────────────────────────────
  {
    { '1',   '2',  '3',  K_DEL,  K_EMPTY, K_EMPTY },
    { 'q',   'w',  'e',  'r',    K_EMPTY, K_EMPTY },
    { 'a',   's',  'd',  'f',    'g',     K_FN    },
    { 'z',   'x',  'c',  'v',    'b',     K_CTRL  },
  },
  // ── Layer 1: TYPIST ──────────────────────────────────
  {
    { '1',   '2',  '3',  '4',    K_EMPTY, K_EMPTY },
    { 'q',   'w',  'e',  'r',    K_EMPTY, K_EMPTY },
    { 'a',   's',  'd',  'f',    'g',     K_FN    },
    { 'z',   'x',  'c',  'v',    'b',     K_CTRL  },
  },
};

// ═══════════════════════════════════════════════════════
//  STATE VARIABLES
// ═══════════════════════════════════════════════════════
int           currentLayer      = 0;
char          lastKeyStr[8]     = "---";
unsigned long lastActivityTime  = 0;
bool          screensaverActive = false;
bool          oledNeedsUpdate   = true;

// FN hold
bool          fnHeld      = false;
unsigned long fnHoldStart = 0;
bool          fnToggled   = false;

// Debounce
bool          keyState[ROW_NUM][COL_NUM];
bool          lastRaw[ROW_NUM][COL_NUM];
unsigned long debounceTimers[ROW_NUM][COL_NUM];
uint8_t       pressedKey[ROW_NUM][COL_NUM];
const unsigned long DEBOUNCE_MS = 20;

// ═══════════════════════════════════════════════════════
//  ROBO EYE STATE
// ═══════════════════════════════════════════════════════
float         eyePupilX        = 0.0f;   // current pupil X offset (-1 to 1)
float         eyePupilTargetX  = 0.0f;
float         eyeOpenH         = 1.0f;   // 1.0 = fully open, 0.0 = closed
float         eyeOpenTarget    = 1.0f;
int           eyeSeqIdx        = 0;
unsigned long lastEyeStateTime = 0;
unsigned long lastEyeDrawTime  = 0;

const unsigned long IDLE_TIMEOUT  = 5000;
const unsigned long EYE_STATE_DUR = 700;
const unsigned long EYE_DRAW_INTV = 33;  // ~30fps

// Animation sequence: pupil position, blink?
const float eyePupilSeq[] = { 0.0f, -1.0f,  0.0f, 1.0f, 0.0f, 0.0f };
const bool  eyeBlinkSeq[] = { false, false, false, false, false, true };
const int   EYE_SEQ_LEN   = 6;

// ═══════════════════════════════════════════════════════
//  ROBO EYE DRAWING
//  White rounded-rect eyes, black rounded-rect pupils
//  Pupils glide left/right; eyes blink open/shut
// ═══════════════════════════════════════════════════════
void drawRoboEyes(float pupilNorm, float openFactor) {
  display.clearDisplay();

  const int EYE_W      = 46;
  const int EYE_H_FULL = 42;
  const int EYE_R      = 11;   // corner radius (gives robo roundness)
  const int LEFT_CX    = 32;
  const int RIGHT_CX   = 96;
  const int EYE_CY     = 34;
  const int PUPIL_W    = 20;
  const int PUPIL_H_MX = 28;
  const int PUPIL_R    = 7;
  const int MAX_MOVE   = 9;

  int eyeH   = max(2, (int)(EYE_H_FULL * openFactor));
  int eyeTopY = EYE_CY - eyeH / 2;
  int cr     = min(EYE_R, eyeH / 2);      // safe corner radius
  int pOff   = (int)(pupilNorm * MAX_MOVE);

  // White eye bodies
  display.fillRoundRect(LEFT_CX  - EYE_W / 2, eyeTopY, EYE_W, eyeH, cr, SSD1306_WHITE);
  display.fillRoundRect(RIGHT_CX - EYE_W / 2, eyeTopY, EYE_W, eyeH, cr, SSD1306_WHITE);

  // Black pupils (only when sufficiently open)
  if (openFactor > 0.25f) {
    int pH   = min(PUPIL_H_MX, eyeH - 10);
    int pTopY = EYE_CY - pH / 2;
    if (pH > 4) {
      display.fillRoundRect(LEFT_CX  - PUPIL_W / 2 + pOff, pTopY, PUPIL_W, pH, PUPIL_R, SSD1306_BLACK);
      display.fillRoundRect(RIGHT_CX - PUPIL_W / 2 + pOff, pTopY, PUPIL_W, pH, PUPIL_R, SSD1306_BLACK);
    }
  }

  display.display();
}

// ═══════════════════════════════════════════════════════
//  OLED LAYER SCREEN
// ═══════════════════════════════════════════════════════
void drawLayerScreen() {
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);

  display.setTextSize(1);
  display.setCursor(0, 0);
  display.print("MONKIIBOARD");
  display.drawLine(0, 10, 127, 10, SSD1306_WHITE);

  display.setTextSize(2);
  display.setCursor(0, 14);
  display.print(currentLayer == 0 ? "SHORTCUTER" : "TYPIST");

  display.drawLine(0, 46, 127, 46, SSD1306_WHITE);
  display.setTextSize(1);
  display.setCursor(0, 50);
  display.print("LAST: ");
  display.print(lastKeyStr);

  display.display();
}

// ═══════════════════════════════════════════════════════
//  OLED UPDATE (non-blocking)
// ═══════════════════════════════════════════════════════
void updateOLED(unsigned long now) {
  if (screensaverActive) {
    // Advance eye animation state every EYE_STATE_DUR ms
    if (now - lastEyeStateTime >= EYE_STATE_DUR) {
      lastEyeStateTime = now;
      eyeSeqIdx        = (eyeSeqIdx + 1) % EYE_SEQ_LEN;
      eyePupilTargetX  = eyePupilSeq[eyeSeqIdx];
      eyeOpenTarget    = eyeBlinkSeq[eyeSeqIdx] ? 0.0f : 1.0f;
    }

    // Redraw at ~30fps with lerp for smooth motion
    if (now - lastEyeDrawTime >= EYE_DRAW_INTV) {
      lastEyeDrawTime = now;

      const float LERP = 0.18f;
      eyePupilX += (eyePupilTargetX - eyePupilX) * LERP;
      eyeOpenH  += (eyeOpenTarget   - eyeOpenH)  * LERP;

      // Auto-reopen after blink completes
      if (eyeOpenTarget < 0.5f && eyeOpenH < 0.06f) {
        eyeOpenTarget = 1.0f;
      }

      drawRoboEyes(eyePupilX, eyeOpenH);
    }

  } else if (oledNeedsUpdate) {
    drawLayerScreen();
    oledNeedsUpdate = false;
  }
}

// ═══════════════════════════════════════════════════════
//  LAYER TOGGLE
// ═══════════════════════════════════════════════════════
void toggleLayer() {
  Keyboard.releaseAll();            // release any held keys
  currentLayer      = (currentLayer == 0) ? 1 : 0;
  screensaverActive = false;
  lastActivityTime  = millis();
  drawLayerScreen();                // instant OLED update
}

// ═══════════════════════════════════════════════════════
//  KEY PRESS
// ═══════════════════════════════════════════════════════
void onKeyPress(int r, int c) {
  lastActivityTime = millis();
  pressedKey[r][c] = K_EMPTY;

  // Wake screensaver — consume keypress, don't type
  if (screensaverActive) {
    screensaverActive = false;
    drawLayerScreen();
    return;
  }

  uint8_t key      = keymap[currentLayer][r][c];
  pressedKey[r][c] = key;

  if (key == K_EMPTY) return;

  if (key == K_FN) {
    fnHeld      = true;
    fnHoldStart = millis();
    fnToggled   = false;
    return;
  }

  if (key == K_DEL) {
    strncpy(lastKeyStr, "DEL", sizeof(lastKeyStr));
    Keyboard.press(KEY_BACKSPACE);
    oledNeedsUpdate = true;
    return;
  }

  if (key == K_CTRL) {
    strncpy(lastKeyStr, "CTRL", sizeof(lastKeyStr));
    Keyboard.press(KEY_LEFT_CTRL);
    oledNeedsUpdate = true;
    return;
  }

  // Regular ASCII
  lastKeyStr[0] = (char)toupper(key);
  lastKeyStr[1] = '\0';
  Keyboard.press((char)key);
  oledNeedsUpdate = true;
}

// ═══════════════════════════════════════════════════════
//  KEY RELEASE
// ═══════════════════════════════════════════════════════
void onKeyRelease(int r, int c) {
  lastActivityTime = millis();
  uint8_t key      = pressedKey[r][c];
  pressedKey[r][c] = K_EMPTY;

  if (key == K_EMPTY)  return;
  if (key == K_FN)     { fnHeld = false; return; }
  if (key == K_DEL)    { Keyboard.release(KEY_BACKSPACE);   return; }
  if (key == K_CTRL)   { Keyboard.release(KEY_LEFT_CTRL);   return; }

  Keyboard.release((char)key);
}

// ═══════════════════════════════════════════════════════
//  SETUP
// ═══════════════════════════════════════════════════════
void setup() {
  for (int r = 0; r < ROW_NUM; r++) {
    pinMode(rowPins[r], OUTPUT);
    digitalWrite(rowPins[r], HIGH);
  }
  for (int c = 0; c < COL_NUM; c++) {
    pinMode(colPins[c], INPUT_PULLUP);
  }

  memset(keyState,       false, sizeof(keyState));
  memset(lastRaw,        false, sizeof(lastRaw));
  memset(debounceTimers, 0,     sizeof(debounceTimers));
  memset(pressedKey,     0,     sizeof(pressedKey));

  Keyboard.begin();

  Wire1.setSDA(14);
  Wire1.setSCL(15);
  Wire1.begin();
  display.begin(SSD1306_SWITCHCAPVCC, 0x3C);

  lastActivityTime = millis();
  drawLayerScreen();
}

// ═══════════════════════════════════════════════════════
//  MAIN LOOP
// ═══════════════════════════════════════════���═══════════
void loop() {
  unsigned long now = millis();

  // ── Matrix scan ──────────────────────────────────────
  for (int r = 0; r < ROW_NUM; r++) {
    digitalWrite(rowPins[r], LOW);
    delayMicroseconds(10);

    for (int c = 0; c < COL_NUM; c++) {
      bool raw = (digitalRead(colPins[c]) == LOW);

      if (raw != lastRaw[r][c]) {
        debounceTimers[r][c] = now;
        lastRaw[r][c]        = raw;
      }

      if ((now - debounceTimers[r][c]) >= DEBOUNCE_MS) {
        if (raw != keyState[r][c]) {
          keyState[r][c] = raw;
          if (raw) onKeyPress(r, c);
          else     onKeyRelease(r, c);
        }
      }
    }

    digitalWrite(rowPins[r], HIGH);
  }

  // ── FN hold 1s → toggle layer ─────────────────────────
  if (fnHeld && !fnToggled && (now - fnHoldStart) >= 1000) {
    toggleLayer();
    fnToggled = true;
  }

  // ── Idle 5s → screensaver ────────────────────────────
  if (!screensaverActive && (now - lastActivityTime) >= IDLE_TIMEOUT) {
    screensaverActive = true;
    eyeSeqIdx         = 0;
    eyePupilX         = 0.0f;
    eyePupilTargetX   = 0.0f;
    eyeOpenH          = 1.0f;
    eyeOpenTarget     = 1.0f;
    lastEyeStateTime  = now;
    lastEyeDrawTime   = now;
    drawRoboEyes(0.0f, 1.0f);
  }

  // ── OLED update ──────────────────────────────────────
  updateOLED(now);
}

Join the Discussion

Stuck on a step, got a tip, or finished your build? Leave us a comment through the form — we read every one and it helps us improve the guide.

Leave a Comment →