Deep-Dive Series // 8 Steps

Building the MonkiiPad20

Wire up a 20-key macropad with an OLED screen on the RP2040 Zero

Difficulty

Intermediate

Time

4–7 hours

Steps

8 stages

Every file for this build is up on GitHub, the plates, the case and the firmware

Our GitHub

Before You Build

Gather everything below before you start and the whole build goes a lot smoother. The printable parts are on GitHub, so you only need to buy the hardware.

No Case // The Sandwich

There is no case in this build. Our boards are a sandwich, a 3D printed top plate and a 3D printed bottom plate with the whole handwired matrix pressed in between, held together by screws at the corners. That is deliberate. Handwiring goes wrong in ways a beginner or a STEM maker cannot always predict, and a sealed case turns every loose joint into an afternoon of surgery. Undo the screws, lift the top plate, and the entire matrix is in front of you, ready to fix and bolt back together.

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 Plates (STLs provided) 2 Top + bottom, PLA, 0.2mm layer height, 40% infill
Keycaps (1u × 20) 20 Any MX-compatible set
M2 screws (6mm) 4–6 Clamp the two plates together
Soldering iron + solder 1 set 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

Work through these in order. Anything you need to download along the way, the plates, the case and the firmware, is already waiting for you on GitHub.

01

Step 1 of 8

Print the Plates

The MonkiiPad20 STL files are on GitHub, so take the top and bottom plates from there and print them both in PLA. Use 0.2mm layer height and 40% infill so the top plate stays rigid under 20 switches. Between them the two plates carry the cutouts for the OLED window and the RP2040's USB-C port, so print them flat with 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.

Print the Plates
Step 1, Print the Plates

02

Step 2 of 8

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, because bent pins cause hard to diagnose matrix issues later.

Mount Switches in the Plate
Step 2, Mount Switches in the Plate

03

Step 3 of 8

Solder Row Diodes

Solder one 1N4148 diode to the bottom pin of each switch. The cathode (black stripe) must point toward the row wire, and 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, so double check every stripe before soldering.

Solder Row Diodes
Step 3, Solder Row Diodes

04

Step 4 of 8

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.

Wire the Rows
Step 4, Wire the Rows

05

Step 5 of 8

Wire the Columns

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

Wire the Columns
Step 5, Wire the Columns

06

Step 6 of 8

Wire & Mount the OLED

The SSD1306 OLED needs four wires over I²C, 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 top plate's 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.

Wire & Mount the OLED
Step 6, Wire & Mount the OLED

07

Step 7 of 8

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.

Connect the Matrix to the RP2040 Zero
Step 7, Connect the Matrix to the RP2040 Zero

08

Step 8 of 8

Flash Arduino Firmware & Test

The MonkiiPad20 sketch is on GitHub, or printed in full further down this page. Open it in the 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, then check the screen draws correctly. The Keymap Editor can generate a version of this sketch with your own keys, layers and macros.

Pro Tip

Install the Adafruit SSD1306 + GFX libraries before compiling, the display code depends on them. The exact includes are at the top of the sketch on GitHub.

Flash Arduino Firmware & Test
Step 8, Flash Arduino Firmware & Test

Firmware

The full Arduino sketch for this build. Open it in the Arduino IDE, match the pins to your wiring, and upload. There is no need to copy it off this page, the same sketch is sitting on GitHub with the rest of the repo.

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);
}

Want a different keymap?

Lay out your own keys, layers and macros in the browser, then download a sketch already wired for this board. No editing C by hand.

Open Keymap Editor →

Everything is on GitHub

Every 3D printing file, every firmware sketch and every build guide we have written lives in one repository, and all of it is free to download and free to change however you like. The plate and case files are there, so is the Arduino code for each board, the wiring diagrams and the PCB designs.

View on GitHub

Join the Discussion

Stuck on a step, got a tip, or just finished your build? Drop it in our form. We read every single one and it helps us make the guide better.

Leave a Comment →