📌 TL;DR:
I'm creating a portable console using only a Raspberry Pi Pico and MicroPython's native libraries - no third-party code, no shortcuts. This is the story of how I wrestled with hardware registers and memory constraints to make my first pixels appear.
👉 Follow along if you’ve ever wondered what really goes into making a console!
The Spark: Why build a Console from Scratch
It started with a casual message from my electronics professor:
I thought you had participated
Attached was a link to a competition award ceremony—for Raspberry Pi Pico-based game consoles. I hadn’t entered. But that passing comment lit a fuse.
Modern consoles are engineering marvels (8-core CPUs! Ray tracing! DLSS!). But I became obsessed with a simpler question:
What does it take to build a console’s naked essence?
So I set hard constraints:
✅ No pre-built emulators (sorry, RetroPie)
✅ No game engines (PyGame/Unity = forbidden fruit)
✅ Only native MicroPython modules (machine, framebuf, time)
✅ Bit-bang when necessary
The Goal: A pocket-sized console that:
Runs original games (no ROM hacks),
Uses SD cards as "cartridges",
Channels the Game Boy’s minimalist magic.
(Spoiler: It’s harder than it looks.)
The Blueprint: Breaking Down the Beast
To avoid overwhelm, I broke the project into four pillars:
-
Hardware (The Easy Part?)
- Processor: Raspberry Pi Pico (264KB RAM, Dual-core ARM, 133MHz).
- Display: SH1106 OLED (128x64 pixels, I2C).
- Controls: Tactile buttons (d-pad + actions).
- Sound: MAX98357A amplifier + speaker.
-
Software (Where the Pain Begins)
- Language: MicroPython (for rapid prototyping, but I’ll curse its speed later).
- Graphics: Manual pixel pushing (no OpenGL/SDL crutches)
- Audio: Square waves via PWM = authentic 8-bit ear torture
- Philosophy (Embrace creative constraints)
Resource | Game Boy | My Console |
---|---|---|
CPU Speed | 4.19 MHz | 133 MHz |
RAM | 8 KB | ~200 KB* |
VRAM | 16 KB | 1KB |
*After MicroPython overhead
True From-Scratch development
What "From Scratch" Really Means
When I say I’m building this console from scratch, I’m enforcing hardcore rules:
- No external third party libraries(goodbye sh1106.py)
- Only native MicroPython modules:
- machine for hardware access:
- framebuf for pixel buffering
- time and utime for delays
- etc.
- Bit-banging protocols when necessary
Talking to the Display Without Libraries
Understanding the Datasheet
The SH1106 doesn’t speak Python—it understands 42 cryptic commands. Here’s how we translate them:
class GraphicsEngine:
def init_display(self):
init_sequence = [
0xAE, # Display OFF
# Fundamental configuration
0xD5, 0x80, # Frecuencia del oscilador
0xA8, 0x3F, # Multiplex ratio (1/64 duty)
0xD3, 0x00, # Display offset = 0
# ...more commands
0xAF # Display ON
]
for cmd in init_sequence:
if isinstance(cmd, list):
self.send_command(*cmd)
else:
self.send_command(cmd)
self.send_command(0xAD, 0x30)
Graphics: Bare-Metal Framebuffer Magic
1. Memory Management
I've used MicroPython's framebuf - but strictly as a canvas:
class GraphicsEngine:
def _init_(self, i2c, address=0x3C, width=128, height=64, col_offset=2):
# ... i2c stuff
self.width = width
self.height = height
self.col_offset = col_offset
self.pages = height // 8
self.buffer = bytearray(width * self.pages)
self.framebuf = framebuf.FrameBuffer(self.buffer, width, height, framebuf.MONO_VLSB)
War Story: After 3 hours of silence, I discovered the SH1106 needs 20ms power-on delay - buried in datasheet footnote 72.
2. Page-Based Rendering
The SH1106 reads memory in 8-row pages:
def show(self):
for page in range(self.pages):
col_low = self.col_offset & 0x0F
col_high = (self.col_offset >> 4) | 0x10
self.send_command(
0xB0 | page,
col_high,
col_low
)
start = page * self.width
self.send_data(self.buffer[start:start + self.width])
Performance Hack:
By tracking dirty rectangles, we reduce refresh time from 4.8ms to 0.9ms.
3. Sprites rendering
def pixel(self, x, y, color=1):
self.framebuf.pixel(x, y, color)
def sprite(self, x, y, sprite, w, h):
for dy in range(h):
row = sprite[dy] # Obtiene la fila actual del sprite
for dx in range(w):
if row & (1 << (w - 1 - dx)):
self.pixel(x + dx, y + dy, 1)
The Cost of Purity
Operation | SH1106 (library | My Solution | Penalty |
---|---|---|---|
Refresh Rate | 2.703 kHz | 27.556 HZ | x98 |
Refresh Time | 370 us | 36289 us | x98 |
Time per Pixel | 0.045 us | 4.298 us | x95 |
Total Pixels | 8192 | 8192 | - |
Buffer Size | 1024 | 1024 | - |
Ram Used | 0.094 KB | 7.437 KB | x79 |
Why It's Worth It:
- Total control over every pixel
- Deep hardware understanding (which is my main goal)
- Bragging rights: "I even wrote the display driver"
The First Flickering Signs of Life
After days of staring at hexadecimal commands and debugging I2C signals, the moment arrived. I uploaded this test code:
from GraphicsEngine import GraphicsEngine
from machine import Pin, I2C
import time
i2c = I2C(1,scl=Pin(27), sda=Pin(26), freq=400000)
frames = [
bytearray([0x7e, 0x81, 0xa3, 0xa3, 0x81, 0x81, 0xff, 0xc3]),
bytearray([0x00, 0x7e, 0x81, 0xa3, 0xa3, 0xc1, 0x79, 0x07]),
bytearray([0x7e, 0x81, 0xa3, 0xa3, 0x81, 0x99, 0x7e, 0x38]),
bytearray([0x00, 0x7e, 0x81, 0xa3, 0xa3, 0x81, 0x7e, 0x66])
]
display = GraphicsEngine(i2c)
current_frame = 0
while True:
display.clear()
display.text("Hello World", 10, 10)
display.rect(5, 30, 8, 10)
display.sprite(10, 50, frames[0], 8, 8)
display.sprite(60, 40, frames[current_frame], 8, 8)
display.show()
current_frame = (current_frame + 1) % len(player_frames)
time.sleep_ms(80)
And there it was - crisp, pixelated text glowing on the tiny OLED. My console had spoken its first words.
What We've Achieved So Far
- Hardware Control: We've tamed the SH1106 OLED at the register level
- Memory Mastery: Built a functional framebuffer within MicroPython's constraints
- Rendering Pipeline: Created sprite and text drawing from scratch
- Performance Baseline: Established metrics for future optimization
The Hardest Lessons Learned
- Timing is Everything: That 20ms power-on delay wasn't optional
- Memory Fragmentation: MicroPython's GC can wreak havoc if not managed
- Page Addressing: SH1106's memory layout is quirky but logical
Where We Go From Here
The roadmap ahead looks exciting:
- Game Loop: Creating a stable 30FPS update cycle
- Audio Engine: PWM-based sound effects
- Cartridge System: SD memory card game loading
Join Me on This Journey
This is just the beginning. As I continue building:
- Follow my progress on GitHub and Twitter
- Try the code yourself - all projects are MIT licensed
- Share your ideas in the comments below
Discussion Questions:
- What game would you develop for such limited hardware?
- How would you optimize the sprite rendering further?
- Have you ever bit-banged a display protocol? Share your war stories!
Top comments (0)