DEV Community

Cover image for Building a Retro Console from Scratch (Part 2): Game Engine Foundations and Hardware Wrestling
Caleb GarcĂ­a
Caleb GarcĂ­a

Posted on

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:

  1. Address Confusion: Some SH1106 use 0x3C, others 0x3D (mine needed a solder bridge change)
  2. Voltage Sag: Running both screen and Pico from USB caused intermittent resets

OLED Connections

Oled connections

*(War Story Sidebar: "The 20ms Init Delay Mystery" - Recap your datasheet discovery from Part 1)

Button Matrix: Input on a Budget

Buttons conections
Current Minimalist Setup

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

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

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

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

A little bit of Memory Math
A 16x16 sprite consumes 32 bytes (16x16 / 8). My entire VRAM fits: 1024/32 = 32 sprites... but i'll reserve half for UI/fonts.

3. Animation System

Image description

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

🎮 The Pixel Promise Fulfilled

Image description

What Works Today:

✔️ Smooth Animation - 4-frame walk cycles at 30FPS

Where We Go From Here

The roadmap ahead looks exciting:

  1. Game Loop: Creating Game Objects.
  2. Audio Engine: PWM-based sound effects
  3. Cartridge System: SD 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

Top comments (0)