Skip to content
Back to blog
Jul 02, 2026
16 min read

Hardware for building a media player safe for kids

No ads, no video, no flashing lights, no infinite scroll, no unapproved content.

My son’s passion in life is letting his imagination go wild. He can walk in circles acting out “his” movies, trailers, stories, and … will even read you the reviews about them so you know for what age they’re appropriate for and why. He’s a trip.

One aspect of today’s media landscape my wife and I find incredibly frustrating is the complete and utter lack of control over what and how a child with access to a device is able to consume. It doesn’t matter which platform you’re talking about: Disney+, YouTube Kids, PBS Kids, Spotify. All of them try to lure kids (and adults) down the path of infinite scrolling to find worse and worse content. None of them allow curation of the media kids consume (they will tell you what’s good for your child). The vast majority of content on the internet, even the content given the “Okay for Kids!” rubber stamp, is, well, garbage: flashing lights, high-adrenaline, questionable dialog, anything to give kids that dopamine rush that keeps them glued to the screen. It’s horrifying, frankly, to think about letting a kid loose with a device with access to those things.

Privately hosted media storage is a great way to solve the curation problem, but for young kids (four and six) none really address the problem in a form usable for children and without all of the addictive bells and whistles. In my spare time (which is not much with little kids) I’ve put together a device, the kids’ “radios”, that allows us to feel secure in sending them off with media that we know is okay for them, delivered in a format that we’re comfortable is not of the damaging “screen time” variety slowly melting their brains and robbing them of their autonomy.

Today, the application itself is easily whipped together with a coding LLM. In my kids stack, I have a web interface running on our home network that makes it easy to add or remove content that gets synced to the kids’ radios automatically. What was really challenging was bringing the hardware up, which is the subject of this post.

I’ll share what was needed to set up the hardware for a kid-friendly handheld media player. Mine serves only music, audiobooks, and photos and nothing else — no video, no autoplay, no recommendations. The library is curated by us, the parents; the device plays only what is loaded onto it and runs entirely offline. The interface produces motion or sound only when moving around the menu.

Here is a picture of the radio, or skip to the video at the end to see it in action!

The device with the radio application running.

This post covers building the hardware into a pygame kiosk: a device that powers on directly into the app, fullscreen, with no visible desktop, while remaining a standard Linux machine that can be reached over SSH. I used a LLM to turn my scattered notes and documentation into coherent instructions, I think it covered everything well.

The hardware

The hardware is excessive, you can get away with much cheaper specs, especially if you go the microcontroller route. I did not. I wanted a fun device that combined audio, a display, and a tactile (non-touch) interface, so a hand-held gaming platform seemed like a nice fit. As for avoiding a microcontroller, it’s much less work to live in a Linux userspace where I benefited from robust tooling for the app itself and easy connection to the homelab for synchronizing media to two kids platforms and keeping the devices up-to-date.

I used three components:

  • Retroflag GPi Case 2. A Game Boy-style shell that takes a Compute Module 4, with a 640×480 display, a D-pad and buttons wired as a USB gamepad, a speaker, and a physical power switch. Found here. I recommend the variant with the docking station, it makes a nice stand.
  • Raspberry Pi Compute Module 4 (CM4). Use a Lite variant (no on-board eMMC) so it boots from microSD, and a wireless model, since WiFi is needed for SSH and syncing.
  • A high-endurance microSD card.

Step 1 — Flash Raspberry Pi OS

Use the Raspberry Pi Imager. Choose Raspberry Pi OS (64-bit) — this guide targets the current Debian 13 “trixie” release, which uses the Wayland desktop (labwc) by default. Pick the CM4 as the device and your microSD as the storage.

Before writing, open the OS customization settings (the gear icon) and configure these bits:

  • Hostname (e.g. radio).
  • Username and password.
  • WiFi SSID, password, and country.
  • Locale / timezone / keyboard.
  • On the Services tab, enable SSH and paste your public key (key auth, not password).

Step 2 — Configure config.txt

The GPi Case 2’s screen is a raw DPI panel, and the Pi will not drive it correctly without configuration. One of the more frustrating aspects of this project was setting it up correctly. Retroflag’s scripts don’t work, so I had to install Recalbox first and try to back out the correct settings. It was a real PITA to get it stable, some specifications would have been nice. (I’m not bitter. Anymore.)

Here is my whole config.txt (in /boot/firmware/) with the stock boilerplate and commented-out interfaces stripped out. Most of it is ordinary defaults; the lines that matter for this build are the audio (a music player is useless without it), the fake-KMS and DPI overlays, and the panel timings at the bottom.

# Onboard audio (snd_bcm2835) — needed for music and audiobooks.
dtparam=audio=on

# The DPI panel takes over the GPIO header; skip the firmware safe-mode check.
avoid_safe_mode=1

# Let the kernel own the display mode: don't have the firmware inject a video=
# line into cmdline.txt — we set the DPI mode there ourselves (see below).
disable_fw_kms_setup=1

# Stock Raspberry Pi OS defaults and appliance tweaks, left in place.
display_auto_detect=1
auto_initramfs=1
disable_overscan=1
arm_boost=1
enable_uart=0

[cm4]
arm_64bit=1
# USB host mode for the case's built-in controls (a USB gamepad).
dtoverlay=dwc2,dr_mode=host
# Fake KMS video driver (see the warning below).
dtoverlay=vc4-fkms-v3d
dtoverlay=dpi18

[all]
max_framebuffers=2
hdmi_ignore_hotplug=1

# --- 640x480 DPI panel timings for the GPi Case 2 ---
gpio=0-17,19-25=a2
enable_dpi_lcd=1
display_default_lcd=1
dpi_group=2
dpi_mode=87
dpi_output_format=0x00016
dpi_timings=640 0 41 40 41 480 0 18 9 18 0 0 0 60 0 24000000 1

Step 3 — First boot, SSH, and raspi-config

Insert the card, assemble the case, and power on. Give it a minute, then SSH in using the hostname you set:

ssh [email protected]

The panel may or may not come up correctly on this first boot. The display overlays are set in config.txt, but the kernel’s video mode isn’t pinned until you edit cmdline.txt (below), so the screen can be blank, garbled, or off-center at this point. That’s expected — everything in this step is done headless over SSH.

Then run the configuration tool:

sudo raspi-config

Configure these:

  • Advanced → Expand Filesystem, so the OS can use the whole card.
  • System → Boot / Auto Login → Desktop Autologin. This changes to console boot later; the desktop is brought up first as an intermediate step.
  • Display → Screen Blanking → Disable.
  • Interface → VNC → Enable. On trixie this enables wayvnc, used during setup and kept on reserve.
  • Verify Localisation (locale, timezone, WiFi country).

Set the display mode in cmdline.txt

config.txt handles the panel on the firmware side, but the kernel command line — cmdline.txt, in /boot/firmware/ — needs a few entries of its own for the display and controls to behave. Edit it here, after the first boot and over SSH, rather than on the card beforehand: the Imager has already written the correct root=PARTUUID=... for your card into this file, and you only want to append the flags below to the line it wrote. It is a single line — keep everything space-separated on one line.

dwc_otg.fiq_fix_enable=1 dwc_otg.lpm_enable=0 console=tty3 consoleblank=0 root=PARTUUID=da86f6bf-02 rootfstype=ext4 fsck.repair=yes splash rootwait fastboot noswap plymouth.ignore-serial-consoles cfg80211.ieee80211_regdom=US video=DPI-1:640x480@60D quiet usbcore.autosuspend=-1

The parts that matter for this build:

  • video=DPI-1:640x480@60D — sets the kernel modeset for the DPI connector to match the dpi_timings in config.txt. Under fake KMS the kernel still brings up a framebuffer console, and without this it can pick the wrong mode — which is exactly why the panel may have looked wrong on that first boot. The trailing D requests the digital DPI mode rather than an analog/HDMI fallback.
  • console=tty3 — moves the boot/kernel console off tty1. The kiosk app owns tty1 (Step 7), so parking kernel log output on tty3 keeps that spew from drawing over the app’s terminal during and after boot.
  • consoleblank=0 — disables the kernel console’s blanking timeout, the console-level counterpart to the Screen Blanking → Disable you just set in raspi-config; the panel should never go black on its own.
  • usbcore.autosuspend=-1 — disables USB autosuspend. The case’s D-pad and buttons are a USB gamepad, and with autosuspend on, the first input after an idle period can be dropped or laggy. Disabling it keeps the controls instant.

The rest is appliance hygiene: quiet splash plymouth.ignore-serial-consoles for a quiet, splash-only boot, noswap and fastboot to skip swap and the boot-time fsck, and cfg80211.ieee80211_regdom=US to pin the WiFi regulatory domain.

Then reboot again.

Step 4 — Update and install tools

sudo apt update && sudo apt full-upgrade -y
sudo apt install -y htop vim git # whatever you plan to use

I installed uv to manage Python and keep each project’s environment self-contained, but you do you.

curl -LsSf https://astral.sh/uv/install.sh | sh

I also recommend replacing the default desktop environment with LXDE. Gnome and KDE are slow on a microSD card and seemed to make the display less reliable, no clue why.

Step 5 — A minimal pygame app

The stand-in app opens a fullscreen 640×480 window, hides the mouse pointer, and quits on Esc. Replace it with your real app later.

# ~/radio-app/hello_pygame.py
import sys
import pygame

pygame.init()
screen = pygame.display.set_mode((640, 480), pygame.FULLSCREEN)
pygame.mouse.set_visible(False)
font = pygame.font.SysFont(None, 56)
clock = pygame.time.Clock()

running = True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
            running = False

    screen.fill((20, 24, 40))
    text = font.render("Hello from the GPi!", True, (235, 235, 245))
    screen.blit(text, text.get_rect(center=(320, 240)))
    pygame.display.flip()
    clock.tick(30)

pygame.quit()
sys.exit()

Set up its environment with uv and confirm it runs from the desktop first:

mkdir -p ~/radio-app && cd ~/radio-app
uv init --bare
uv add pygame-ce
uv run python hello_pygame.py

Step 6 — Easy approach: autostart in the desktop

The simplest autostart method is an XDG .desktop file in ~/.config/autostart that launches the app inside the desktop session. This works and is sufficient if a desktop boot is acceptable.

However, it boots the entire desktop first — compositor, panel, file manager, wallpaper — and then opens the app on top. This is slower, uses more memory, and can briefly show desktop elements before the app takes over. It’s fine, my kids questioned it but otherwise didn’t care. But it’s much more slick to see it boot right to the radio app.

Step 7 — Boot straight into the app

Two approaches did not work on this hardware; they are documented first.

The working approach keeps the app on its X11 render path via XWayland but drops the desktop shell. Boot to a console, auto-log-in on the first virtual terminal, and launch a bare labwc compositor whose only job is to host the app — no panel, file manager, or greeter.

7a. Autologin on tty1

Create a systemd drop-in so pi is logged in automatically on tty1:

sudo mkdir -p /etc/systemd/system/[email protected]
sudo tee /etc/systemd/system/[email protected]/autologin.conf >/dev/null <<'EOF'
[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin pi --noclear %I $TERM
EOF

7b. A launcher for the app

This script runs the app under XWayland, restarts it if it ever exits, and logs what happened:

mkdir -p ~/.local/bin
tee ~/.local/bin/radio-kiosk.sh >/dev/null <<'EOF'
#!/bin/sh
LOG="$HOME/radio-kiosk.log"
export SDL_VIDEODRIVER=x11
while :; do
  echo "=== $(date -Is) start ===" >>"$LOG"
  cd "$HOME/radio-app" && uv run python hello_pygame.py >>"$LOG" 2>&1
  echo "=== $(date -Is) exit rc=$? ===" >>"$LOG"
  sleep 2
done
EOF
chmod +x ~/.local/bin/radio-kiosk.sh

7c. Start labwc on login — but only on the console

Add this to ~/.bash_profile. The guard fires only on the physical console, so logging in over SSH still gives a normal shell.

cat >> ~/.bash_profile <<'EOF'

# Kiosk: on tty1 only, launch the app under a bare labwc compositor.
if [ "$(tty)" = "/dev/tty1" ] && [ -z "$DISPLAY" ] && [ -z "$WAYLAND_DISPLAY" ] && [ -z "$SSH_TTY" ]; then
  exec labwc -S "$HOME/.local/bin/radio-kiosk.sh"
fi
EOF

7d. Silence the desktop shell

A bare labwc still reads the system autostart file, which launches the Raspberry Pi panel and file manager. Override it with an empty user copy so only the app runs:

mkdir -p ~/.config/labwc
tee ~/.config/labwc/autostart >/dev/null <<'EOF'
#!/bin/sh
# Intentionally empty: suppress the desktop panel / file manager / xdg-autostart.
exit 0
EOF
chmod +x ~/.config/labwc/autostart

7e. Hide the mouse pointer

With no mouse attached, labwc parks a cursor somewhere random on the screen and never moves it. This is the compositor’s cursor, so X-side tools like unclutter don’t affect it. The fix is to give labwc a fully transparent cursor theme:

python3 - <<'PY'
import struct, pathlib
cur = pathlib.Path.home() / ".icons/blank/cursors"
cur.mkdir(parents=True, exist_ok=True)
# Minimal Xcursor file: a single 1x1 fully-transparent image.
img_hdr = [36, 0xfffd0002, 24, 1, 1, 1, 0, 0, 0]
file_hdr = b"Xcur" + struct.pack("<III", 16, 0x00010000, 1)
toc = struct.pack("<III", 0xfffd0002, 24, 28)
body = b"".join(struct.pack("<I", x) for x in img_hdr) + struct.pack("<I", 0)
(cur / "default").write_bytes(file_hdr + toc + body)
for name in ("left_ptr", "arrow", "xterm", "hand1", "hand2", "watch", "cross"):
    link = cur / name
    if link.exists() or link.is_symlink():
        link.unlink()
    link.symlink_to("default")
(cur.parent / "index.theme").write_text("[Icon Theme]\nName=blank\nInherits=core\n")
print("wrote transparent cursor theme:", cur)
PY

# tell labwc to use it
tee ~/.config/labwc/environment >/dev/null <<'EOF'
XCURSOR_THEME=blank
XCURSOR_SIZE=24
EOF

7f. Flip the boot target

Switch the machine to boot to a console and stop autostarting the desktop and VNC. Both stay installed and can be started manually:

sudo systemctl set-default multi-user.target
sudo systemctl disable lightdm
sudo systemctl disable wayvnc

Reboot:

sudo reboot

It comes up on a console briefly and then lands directly in the app, fullscreen, with no pointer. Confirm it is running from another machine:

ssh [email protected] 'systemctl get-default; pgrep -a labwc'

Step 8 — Safe shutdown

The GPi Case 2’s physical power switch cuts power immediately by default, which is the mid-write power loss that corrupts SD cards. The case exposes the switch state on a GPIO pin so the Pi can shut down cleanly first. Monitor script:

# /home/pi/scripts/safe_shutdown.py
import os
from signal import pause
from gpiozero import Button, DigitalOutputDevice

# GPIO 27: "keep-alive" — drive HIGH on start so the case keeps power on.
power_en = DigitalOutputDevice(27, initial_value=True)

# GPIO 26: the case pulls this LOW when the switch is flipped off.
shutdown_switch = Button(26, pull_up=True, bounce_time=0.1)

def on_shutdown_request():
    print("Power switch off — shutting down cleanly...")
    os.system("sudo shutdown -h now")

shutdown_switch.when_pressed = on_shutdown_request

print("Safe-shutdown monitor running (keep-alive on GPIO 27, trigger on GPIO 26).")
pause()

Run it as a system service:

sudo tee /etc/systemd/system/gpicase-shutdown.service >/dev/null <<'EOF'
[Unit]
Description=GPi Case 2 safe-shutdown monitor
After=multi-user.target

[Service]
Type=simple
ExecStart=/usr/bin/python3 /home/pi/scripts/safe_shutdown.py
Restart=always
User=root

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now gpicase-shutdown.service
systemctl status gpicase-shutdown.service

The GPi case also has a sleep button. I don’t know if it’s exactly sleep, but the default behavior seems to turn off the screen, blink the power LED, but keep the app running (e.g., music keeps playing). You can probably intercept this as well to do something special, I didn’t bother.

Optional — Shaving the boot time

Console boot and remove unnecessary startup services. Even trimmed down, it still takes a good 15-20 seconds or so to start the app. It’s not the end of the world, my kids learned to be patient. (Less slow before trimming the fat, when it took almost a minute to boot and open the app.) Take a look at what’s slowing you down:

systemd-analyze
systemd-analyze blame | head -20
systemd-analyze critical-chain

I do this on occasion because sometimes a rogue update adds something silly and unnecessary. I disabled a lot of services, but the most impactful was probably the default wait for network connection at boot. Definitely remove that one unless you reaaalllyyy need a network share available at boot.

Some ideas:

  • Don’t wait for network: sudo systemctl mask systemd-networkd-wait-online.service
  • Disable services unused on an offline appliance — Bluetooth, printing (cups), modem manager: sudo systemctl disable bluetooth cups ModemManager.
  • Drop plymouth if the boot splash is not needed.

The result

The result is a standard Linux machine that behaves like an application kiosk. It will boot directly into your pygame application now. Here’s it booting into mine!

VIDEO: powering on the device and navigating music, an audiobook, and photos — the single-purpose interface.

I hope this was useful.