How I built Unicorn-Pi Galactic for the Pimoroni Galactic Unicorn
I built Unicorn-Pi Galactic to turn the Pimoroni Galactic Unicorn into more than a single demo. I wanted one MicroPython project that could run a full set of animated scenes, respond to the board's buttons, remember where I left off, and add sound when a view needed it. I also wanted the code to stay easy to change and extend one scene at a time.
What the project actually is
At its core, Unicorn-Pi Galactic is a scene runner for the Galactic Unicorn. It ships with a big set of built-in views, including clocks, a stock ticker, and a bunch of visual effects like rainbow, digital rain, fireflies, fireworks, warp speed, snowfall, wave, plasma, DVD bounce, and Nyan Cat.
Some views use optional Wi-Fi, and some also have sound. I wanted all of that to live in one place instead of being spread across a folder of one-off demos. The goal was to make the board feel like a complete little device I could leave running, flip through with the buttons, and keep adding to over time.
Why I structured it this way
The entry point is intentionally tiny. main.py brings up the
Galactic Unicorn, creates the graphics context, loads user options,
initializes sound and Wi-Fi services, creates the view service, and then
starts a background button listener. After that, the main loop just stays
alive while the active view does its work.
That split matters. It keeps the hardware setup in one place and pushes behavior into small focused modules. Instead of writing one giant script with conditionals for every effect, I gave each concern its own job.
ButtonService handles the physical controls, including
view changes, brightness, volume, and sleep.
OptionsService reads and writes
options.json. WiFiService connects if
credentials are present. SoundService manages synth
playback and mute behavior. ViewService owns the list of
available scenes and switches between them.
How view switching works
The part I like most is how simple the scene system is.
ViewService keeps an ordered mapping of names to view
modules. Each view exposes an async run(...) function. When I
switch scenes, the current task is cancelled, the screen is cleared, the
current view name is saved, and a new task is created for the next scene.
That means every scene can be self-contained. A clock can keep its own update loop. Fireflies can manage their own random movement and chirping. Fireworks can spawn particles and sound cues. The shared runtime does not need to know how any of that works internally.
I also persist the selected view to current_view.json, so the
board comes back on the last scene I used instead of always resetting to
the same screen. It is a tiny detail, but it makes the device feel much
better in day-to-day use.
What a scene looks like in practice
Most views are small and direct. The rainbow view is a good example. It builds a hue map across the display width, animates a phase value, redraws the matrix on each tick, and lets me adjust stripe width with the C and D buttons. It also starts a sound helper when the view is created, which gives that scene a little extra character.
The fireflies view takes a different route. It creates a handful of randomized firefly objects, updates each one with a sine-based brightness curve, and runs a second async task that occasionally plays cricket sounds. The result is simple, but it feels more alive than a static effect.
The emergency view is even more straightforward. It alternates red and blue fills and swaps between two siren tones. It is intentionally blunt. The effect works because it is simple.
Where Wi-Fi comes in
I kept Wi-Fi optional. If there are no credentials in
options.json, the project still runs. Only the views that
need network data care about it.
The 12-hour and 24-hour clock views fetch time from World Time API using the configured time zone, then keep ticking locally. If Wi-Fi is unavailable, they fall back to local device time. The stock ticker view uses Finnhub, scrolls the configured symbols across the display, and refreshes after a configurable number of passes. That scene also lets me cycle text color with the C and D buttons.
Here is the kind of configuration I designed the project around:
"wifi_ssid": "MY_WIFI_SSID",
"wifi_password": "MY_WIFI_PASSWORD",
"stocks_finnhub_api_key": "MY_API_KEY",
"stocks_symbols": ["NVDA", "AMD", "MSFT", "GOOGL"],
"stocks_update_after_x_scrolls": 3,
"stocks_update_message": true,
"time_zone": "America/Chicago"
Using the board controls
I wanted the software to feel good without needing a laptop attached all the time, so the onboard buttons matter a lot. A and B step through scenes. The brightness buttons change panel brightness. The volume buttons change synth volume globally. The sleep button blanks the display and mutes sound, then restores both when I wake it back up.
C and D are intentionally left for per-view behavior. In the clocks they cycle text color. In rainbow they change stripe width. In the stock view they rotate the text color. That pattern lets each scene stay playful without losing a shared control scheme.
How to get it running
I kept setup pretty simple because this project is meant to be copied onto the board and used, not babysat.
- Install the Galactic Unicorn MicroPython UF2 on the board.
- Open the device in Thonny.
-
Copy everything from the repository's
scriptsfolder to the root of the board. -
Edit
options.jsonif you want Wi-Fi, stock data, or a custom time zone. - Reboot the board.
main.pystarts automatically.
For local development on my computer, I also included a small Python
toolchain with black, flake8, and
pre-commit. That keeps the repo tidy while still letting the
actual device-side code stay lightweight.
How I made it easy to extend
Adding a new scene is simple, which is exactly what I wanted. To add one,
I create a new file in scripts/views, give it an async
run(...) entry point, and register it in
ViewService.
from views import my_new_view
return OrderedDict([
("Rainbow", rainbow_view.run),
("My New View", my_new_view.run),
])
That is basically it. If the scene needs its own button behavior, it handles that itself. If it needs network access, the Wi-Fi service is already there. If it needs sound, it can use the shared sound service or one of the helper patterns from the existing scenes.
I also kept credits in the repo for third-party Pimoroni code and examples that helped shape some of the work. That felt important to keep visible, especially for a hardware project where example code is often the fastest way to get from blinking pixels to something fun.
Wrapping up
This project is for people who want a complete Galactic Unicorn setup without a lot of extra framework around it. It is a practical MicroPython project for one specific piece of hardware, and I think it works better because of that.
If you have a Galactic Unicorn and want something more usable than a loose collection of demos, this is what I built it for. You can copy it to the board, start moving through the views right away, and then change or add whatever you want once you get into the code.
That was the main goal from the start, something that works well on its own, but is still easy to keep building on.
