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
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.
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.
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.
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.
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.
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.
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.
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.
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.
#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 →