Skip to content
Back to blog
Oct 04, 2026
9 min read

KidsPlay: the software, and how to run it

A home server and offline handheld players for music, audiobooks and photos, curated by a parent. Now open source.

I introduced the KidsPlay hardware project in a previous blog post. This post focuses on the software side. Before starting from scratch, I ran into two annoyances using existing iPod-like devices for kids:

  1. They were often difficult, slow, and confusing for young children to use; and
  2. They were almost always a pain to update.

The first version of the KidsPlay software I co-built with Claude Code back when it still sucked at the systems level. I could get it to implement basic tasks, but higher-level reasoning failed resoundingly. One comical example was a requirement I gave to have the different menu screens derive from a base class that could manage the shared rendering and styling. Well, it made a base class and all of the derived classes, just like I asked. But it made one minor mistake: none of the “derived” classes actually inherited from the base class. Claude routinely confused itself when trying to make edits. It would edit the unused base class many times, see nothing change, then decide to modify the derived classes instead, not realizing there was absolutely no connection. There was a ton of dead code, it was extremely sloppy and unmanageable, and I had to intervene significantly to get it even operational.

Fast-forward about a year, with the first Opus release. That was a game-changer. I used Opus to rewrite the KidsPlay application from scratch, which it did very well. I mainly provided the specifications, requirements, and co-planned the design of the new system, then it was able to execute the rest fairly well. There was still quite a bit of back-and-forth, but overall I was extremely impressed. In v2 I made some major design changes. My son has frequent requests for new songs and new pictures on his device, so I wanted a system that made it easy for us to add new content. I moved from the handheld-only implementation to a device client and central server architecture with a web interface that made adding content much easier. Now we can copy-paste media links or images and simply pick where they wind up and on whose device. And the kids get to pick the color theme for their radios.

In the last few days I let Claude perform another overhaul. This retains most of v2, which I thought was already a solid foundation, but it adds some new capabilities, making it easier to host both client and server on the same device or centrally as I have it. I added some parental controls, UI improvements, localization support, and I expanded the CI suite. The main goal was to transition the system to something that could be publicly shared, in the event someone else wanted to use it. This version 3 was completely reworked using Claude in the cloud, performing the entire implementation, testing, and documentation on its own. My kids performed the beta testing, not even realizing the software completely changed beneath them. Overall, I’m very satisfied with the result and the process to produce it. Ten years ago I never would have attempted this. I’m a software engineer by training and I know this would be a huge commitment to develop and maintain. Now it’s something I can kick off with ease and produce something useful with minimal oversight.

So here it is! For the rest of the post, our good friend Opus will guide us through the details.

KidsPlay has two parts:

  • A server on the device or home network, where a parent adds music, audiobooks and photos and decides which child gets what.
  • A player on each child’s handheld. The handheld copies its own library from the server whenever it can reach it, then plays everything with no network at all.

The code is licensed under the GNU AGPL-3.0 (or later) at github.com/anthonytw/kidsplay.

What a parent does

All management happens in a web UI (also usable on a phone) or a command-line client. There is no account system beyond one admin login.

Adding media. Media comes in from:

  • files or folders already on the server;
  • any web URL that points at a file;
  • YouTube links, through an optional plugin built on yt-dlp. A server can leave the plugin out.

Photos can be cropped in the browser before import.

On import the server does all the processing: transcoding, thumbnails, resizing photos and normalizing loudness. The handheld only ever receives finished files.

Importing media.

Profiles. Each child has a profile. Media is assigned to profiles, and a device belongs to one profile. Per-profile settings:

SettingWhat it does
Volume capThe loudest the player can play (0–100 %), whatever the handheld’s volume dial says
BedtimeA schedule per weekday, and what happens at bedtime: a dim sleep screen, or audiobooks only. Sound fades out over about 10 seconds.
LanguageEnglish or Spanish, for the child’s screens
ThemeBlue (default), Night (dim and warm, for bedtime), High contrast, or a custom theme with its own background, font and button sounds
Volume buttonsIn-app volume control, for hardware with no volume dial
Button soundsThe short sound on each button press, on or off

Settings travel to the device with its next sync (every 15 minutes by default) and apply without a restart.

One child's settings: a 60% volume cap and a bedtime on every day of the week.

Loudness. Tracks from different sources are normalized to the same loudness (−16 LUFS by default, with separate targets possible for music and audiobooks). Originals are kept, so changing the target re-normalizes the library from them.

Backups. kidsplay-server backup writes the database and media store either to one archive or to a directory that is updated incrementally (only new media is copied). --verify re-checks a directory backup and repairs corrupt files; restore brings a backup back.

The media library in the web UI.

What the child sees

The player is a full-screen app with large text and no video. It has a home screen, lists of music, audiobooks and photo albums, a play screen, and a settings screen where the child can choose a color (unless a parent has set the theme). Everything works offline. If the server is unreachable, the device keeps playing what it already has.

The player on the handheld (640×480), driven by its buttons.

At bedtime the player fades the sound out and shows a dim screen until wake time (or, in the other mode, allows audiobooks only):

Setting up a handheld

On the device, one command does the setup. It has two modes:

# Connect to a KidsPlay server you already run:
packages/kidsplay-device/deploy/install-device.sh \
    --server https://kidsplay.example.net --timezone America/New_York

# Or put the server on the handheld itself:
packages/kidsplay-device/deploy/install-device.sh \
    --standalone --profile-name "Alice" --timezone America/New_York

In the first mode the handheld boots straight to a pairing code and a QR code. Scanning the QR code with a phone (or typing the code into the web UI) and approving it gives the device its own key. Nothing is typed on the handheld. The server address can also be put in a text file on the SD card’s boot partition before first boot.

The pairing screen on a new handheld.

The second mode (“all-in-one”) is for families without a home server. The server and player share one SD card, and media is stored once.

Either way the device boots straight into the player, with no desktop.

Running the server

The server is a FastAPI application with SQLite and a content-addressed media store. It runs in Docker:

docker compose -f docker/docker-compose.yml up -d --build

It can sit behind a reverse proxy. It can also sit behind a single sign-on proxy such as Authelia, with the app’s own login turned off; the docs list the few device routes that must stay open.

Hardware

The reference device is a Raspberry Pi CM4 in a Retroflag GPi Case 2 (640×480 screen, game-controller buttons), as in the hardware post. The player is a plain pygame-ce application, so it also runs on other Linux devices and desktops:

  • screen sizes from 240×180 to 1280×720;
  • input profiles for the GPi Case 2, a keyboard, or any SDL gamepad, plus per-device button remapping.

How it is built and tested

  • Python, managed with uv, in five packages:
    • models: the shared data contract;
    • server;
    • cli;
    • device: the player;
    • importer-ytdlp: the optional YouTube import plugin.
  • About 2,600 automated tests run on every change. They include:
    • headless runs of the player, with screenshots checked for clipped text;
    • real audio output measured through SDL’s disk driver, to check the volume cap;
    • browser tests of the web UI at phone and desktop widths, in both languages;
    • an arm64 Debian job for the device package.
  • Hardware-only checks are in a release checklist (docs/RELEASE_CHECKLIST.md) that is run on the reference device before a release.

Status and limits

  • Early. v2 has run one family’s devices every day for about a year. v3 (this release) is roughly the same system with expanded capabilities; it has been in testing and daily use for about a week now. But expect rough edges on other hardware.
  • One admin account. No per-parent accounts.
  • Two languages: English and Spanish. Adding one is documented in docs/TRANSLATING.md.
  • Bedtime needs a trustworthy clock. The Pi has no real-time clock. If the time can’t be confirmed (from the server or NTP) after a power-off, bedtime is not enforced rather than enforced at the wrong time.
  • YouTube import depends on yt-dlp, which has to keep up with YouTube.

Trying it

With uv and ffmpeg installed:

git clone https://github.com/anthonytw/kidsplay.git
cd kidsplay
just demo

This starts a server with sample media, two profiles and a player window. The README and the docs/ folder cover the rest.