Building a Retro Console from Scratch (Part 2): Game Engine Foundations and Hardware Wrestling
TL;DR:
I’ve built a complete game engine core for my Pico console – custom sprite system, input handling, and game loop – while fighting hardware limitations. This post covers:
- The anatomy of a bare-metal game loop
- Input handling
- Sprite animation system
- Hardware deep dive: Why my breadboard looks like a spider’s nest
For those who missed: Part 1
Hardware Odyssey: From Datasheets to Reality
The OLED That Wouldn’t Speak
When I first wired the SH1106 display, I faced a blank screen of judgment. The problem? I assumed all I2C OLEDs were created equal.
Hardware Truth Bombs:
- Address Confusion: Some SH1106 use 0x3C, others 0x3D (mine needed a solder bridge change)
- Voltage Sag: Running both screen and Pico from USB caused intermittent resets
OLED Connections
*(War Story Sidebar: “The 20ms Init Delay Mystery” – Recap your datasheet discovery from Part 1)
Button Matrix: Input on a Budget
buttons = {
'A': Pin(17, Pin.IN, Pin.PULL_DOWN),
'B': Pin(18, Pin.IN, Pin.PULL_DOWN),
'UP': Pin(19, Pin.IN, Pin.PULL_DOWN),
'DOWN': Pin(20, Pin.IN, Pin.PULL_DOWN),
'LEFT': Pin(21, Pin.IN, Pin.PULL_DOWN),
'RIGHT': Pin(22, Pin.IN, Pin.PULL_DOWN)
}
why this matters
- Pull-ups vs Pull-downs: Chose internal pull-ups to simplify wiring
- Pin Selection: Avoided ADC-capable pins (GP26-28) for future audio use
- Current Draw: Each button uses ~0.3mA when pressed
Software Architecture: Building a Console, Not Just Games
1. The Console Mentality
Unlike typical DIY projects that hardcode games, I’m architecting a true development ecosystem:
- Cartridge System: Games live on separate files (/games/pong.py)
- Engine API: Standardized methods games must implement
Why this matters?
This brings authentic Retro Experience by using swappable “cartridges” via flash memories, also a developer friendly through letting others create games without touching core code. Future Proof New games won’t require firmware updates
2. Core Engine Components
The Game Loop: Heartbeat of the Console
class PicoEngine:
def _init_(self, screen):
self.objects = []
self.input_handler = Input_Handler()
self.target_fps = 30
self.frame_duration = 1 / self.target_fps
def loop(self):
while True:
frame_start = time.time()
self.input_handler.scan()
for obj in self.objects:
obj.update(self.input_handler.state)
self.draw()
frame_time = time.time() - frame_start
if frame_time < self.frame_duration:
Key Features:
- Fixed timestep for consistent gameplay
- Frame budget tracking (logs dropped frames)
- Clean separation between engine and game
Input System: Beyond Hardcoded Buttons
class Input_Handler:
BUTTONS = {
'A': Pin(17, Pin.IN, Pin.PULL_DOWN),
'B': Pin(18, Pin.IN, Pin.PULL_DOWN),
'UP': Pin(19, Pin.IN, Pin.PULL_DOWN),
'DOWN': Pin(20, Pin.IN, Pin.PULL_DOWN),
'LEFT': Pin(21, Pin.IN, Pin.PULL_DOWN),
'RIGHT': Pin(22, Pin.IN, Pin.PULL_DOWN)
}
def _init_(self):
self.state = {
'A': False,
#... the rest of the buttons
}
def scan(self):
for button_name, button in self.BUTTONS.items():
self.state[button_name] = button.value()
Important Design Choices
- Abstract Physical Layout: Games check input.state[‘a’] without knowing GPIO pins
- Future Expansion: Can add joystick support later (maybe)
3. Sprites: Breathing Life into Pixels
1. Why Sprites Define Retro Gaming
In the golden age of consoles, sprites were magic:
- Hardware-accelerated bitting on vintage systems
- Memory constraints forced creative solutions
- Limited palettes birthed iconic art styles
My Pico faces similar challenges with a maximum of 16 sprites and a VRAM of 1KB.
2. The Naked Sprite Class
class Sprite:
def __init__(self, x, y, sprite, w, h):
self.x = x
self.y = y
self._sprite_data = sprite
self.width = w
self.height = h
def move(self, dx, dy):
self.x += dx
self.y += dy
A little bit of Memory Math
A 16×16 sprite consumes 32 bytes (16×16 / 8). My entire VRAM fits: 1024/32 = 32 sprites… but i’ll reserve half for UI/fonts.
3. Animation System
class Animated_Sprite(Sprite):
def __init__(self, x, y, animations, default_animation, w, h, speed):
self.animations = animations
self.curr_animation = 'idle'
self.frame_index = 0
self.anim_speed = speed #fps(framse per step)
self.counter = 0
super().__init__( x, y, self.animations['idle'][0], w, h)
def set_animation(self, name):
if name != self.curr_animation:
self.curr_animation = name
self.frame_index = 0
self.counter = 0
self.update()
def update(self):
self.counter += 1
if self.counter >= self.anim_speed:
self.counter = 0
self.frame_index = (self.frame_index + 1) % len(self.animations[self.curr_animation])
self._update_frame()
def _update_frame(self):
self._sprite_data = self.animations[self.curr_animation][self.frame_index]
The Pixel Promise Fulfilled
What Works Today:
Smooth Animation – 4-frame walk cycles at 30FPS
Where We Go From Here
The roadmap ahead looks exciting:
- Game Loop: Creating Game Objects.
- Audio Engine: PWM-based sound effects
- Cartridge System: SD card game loading
Join Me on This Journey
This is just the beginning. As I continue building: