DEV Community

Cover image for Building a Retro Console from Scratch (Part 1): The Vision, Plan, and Pixel Grunt Work
Caleb García
Caleb García

Posted on • Edited on

Building a Retro Console from Scratch (Part 1): The Vision, Plan, and Pixel Grunt Work

📌 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:

  1. No external third party libraries(goodbye sh1106.py)
  2. Only native MicroPython modules:
    • machine for hardware access:
    • framebuf for pixel buffering
    • time and utime for delays
    • etc.
  3. 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)
Enter fullscreen mode Exit fullscreen mode

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)

Enter fullscreen mode Exit fullscreen mode

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])
Enter fullscreen mode Exit fullscreen mode

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)  
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

And there it was - crisp, pixelated text glowing on the tiny OLED. My console had spoken its first words.

Image description

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:

  1. Game Loop: Creating a stable 30FPS update cycle
  2. Audio Engine: PWM-based sound effects
  3. 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)