📌 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 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
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:
Top comments (0)