A retro split-flap (Solari board) web display with real-time updates over WebSocket. Runs as a single Docker container — one command on a Raspberry Pi, plug the Pi into a monitor over HDMI, and you have a wall display.
Two display modes, switchable from the setup screen or the API:
- Word Clock — QLOCKTWO-style word clock
- Info Split-Flap — animated split-flap board showing the date + 24h clock
One container, one port. Express serves the REST API, the WebSocket, and the built React client together — so there's nothing else to run.
┌─────────────────────────────────────────┐
│ Docker container (port 3001 → host 80) │
browser ───────▶ │ │
(kiosk / phone) │ Express ──┬── GET / static │
│ │ │ React client (Vite build) │
│ WebSocket │ ├── /api/* REST │
└───────────┼──── ws ─────┴── broadcast to all clients │
│ │
│ state + settings ──▶ /data/.state.json │
└────────────────────────────│──────────────┘
▼
split-flap-data volume
(survives restarts & rebuilds)
Settings (mode, theme, sound, language) and the last message are pushed to every connected client over WebSocket and persisted to a Docker volume, so the display returns in the mode it was last set to after a reboot.
You need Docker + Docker Compose on the Pi. The Pi pulls a prebuilt image from GHCR — no building on the Pi.
git clone https://github.com/jeffstrout/split-flap.git
cd split-flap
cp .env.example .env # optional — defaults work as-is
docker compose pull && docker compose up -dThen open http://<pi-ip> from any browser on your network. The Pi
itself can be the display: plug it into a monitor over HDMI and launch Chromium
in kiosk mode (see KIOSK.md).
Setting up a Pi from a fresh OS install? Follow the step-by-step PI-SETUP.md runbook (Docker install, swap, and Chromium kiosk autostart).
The container has restart: unless-stopped, so it comes back automatically after
a reboot or power loss.
The compose file also runs Watchtower,
which checks GHCR for a newer image every ~20 minutes and updates the display in
place when one appears — so a push to main reaches the wall hands-off. Your
mode/theme/sound/message state lives on a Docker volume and survives updates.
- See what's running:
GET /api/version(also shown at the bottom of/setup) reports the commit + build time. - Pin / roll back: set
IMAGE_TAG=sha-<short>in.env, thendocker compose pull && docker compose up -d. Set back tolatestto resume auto-updates. - Change cadence:
WATCHTOWER_POLL_INTERVAL(seconds) in.env. - Prefer manual updates? Drop the
watchtowerservice and just rundocker compose pull && docker compose up -dwhen you want the latest, or put it on a cron/systemd timer. - Kiosk showing a Pi's own HDMI display? An update refreshes server data
over the WebSocket immediately, but the browser keeps running the page it
already loaded — so client code changes (animations, the
/setupUI) only appear after the page reloads. Reboot the Pi, orsudo systemctl restart getty@tty1, to reload the kiosk. (See PI-SETUP.md.)
All optional — copy .env.example to .env and edit. With no .env, the
defaults below apply.
| Variable | Default | Purpose |
|---|---|---|
HOST_PORT |
80 |
Host port the display + API are served on. Port 80 means the URL needs no port; override if the host already has something there |
TZ |
UTC |
Timezone for the clock, IANA name (e.g. America/Chicago). Set this or the clock shows UTC |
DEFAULT_MODE |
qlock |
Boot mode: qlock (word clock) or flip (split-flap) |
PERSIST_FILE |
/data/.state.json |
State file on the volume; off to disable |
IMAGE_TAG |
latest |
GHCR image tag to run; pin to sha-<short> to freeze/rollback |
WATCHTOWER_POLL_INTERVAL |
1200 |
Seconds between auto-update checks (~20 min) |
FLIP_SPEED |
5 |
Flip-animation speed multiplier (higher = faster). Baked in at build time, so it only applies to local builds (docker-compose.build.yml); the published image bakes 5 |
FLIP_ANIMATE |
true |
Whether letters animate (flip). Set false for instant updates — no flip, no per-row/char stagger, no sound; the whole screen changes at once. Build-time only; the published image bakes false |
Changing the mode/theme/sound from the setup screen (http://<pi-ip>/setup)
applies to all displays instantly and persists across restarts.
Publishing port 80 does not require running the container as root or adding
CAP_NET_BIND_SERVICE. The host side of a Docker port publish is bound by
dockerd, which already runs as root; Express still listens on unprivileged
3001 inside the container. Only the host mapping changed.
The display used to serve on 8080. Watchtower will not make this change for
you — it recreates the container from the config the running container already
has, and never re-reads docker-compose.yml. So an existing Pi keeps serving
8080 until you pull and re-up:
cd ~/split-flap && git pull
docker compose up -d # re-reads the compose file; republishes on 80
curl http://localhost/api/healthIf this Pi is also the kiosk, update the autostart URL in ~/.bash_profile from
http://localhost:8080 to http://localhost in the same pass (see
PI-SETUP.md),
then reboot — otherwise the wall display lands on an error page at the next
reload. To cut over without any gap, publish both ports for the transition by
adding a second mapping (- "8080:3001") before dropping it later.
Any recent Pi runs the server easily — the only heavy part is Chromium rendering the flip animation, and that scales with the model.
Note: the published image ships with the flip animation disabled (
FLIP_ANIMATE=false) — letters snap instantly, which is trivial to render on any Pi, so the notes below only matter if you re-enable it (FLIP_ANIMATE=true) in a local build.
- Pi 4B / 5 — the recommended choice. They drive the full-board split-flap animation GPU-accelerated (VideoCore VI does Chromium's GL ES under Wayland) at the default speed, with cores to spare. No tuning needed; just keep the kiosk's GPU on (see PI-SETUP.md / KIOSK.md).
- Word-clock modes are smooth on any Pi (no flip animation — just letter highlighting).
- Pi 3B+ — works, with caveats. Full-board split-flap transitions animate up
to ~192 tiles at once and stress its single Cortex-A53; expect some jank on big
changes (idle info-screen ticks are fine). Its VideoCore IV can't drive GL ES
under Wayland, so the kiosk renders in software (
--disable-gpu) — fine for this mostly-CSS board. If you re-enable animation and big changes feel busy, build with a gentlerFLIP_SPEED=1via the build override below — or just leave animation off (the published default), which sidesteps the issue entirely. - The client wraps each tile in
React.memoto minimize re-renders on all models.
Normally the Pi just pulls the prebuilt GHCR image. To build from source
instead (e.g. to test an unpushed change, bake a custom FLIP_SPEED, or turn the
animation back on with FLIP_ANIMATE=true), layer the build override — on your
Mac/CI, not a 3B+ (the Vite build is memory-hungry):
FLIP_ANIMATE=true FLIP_SPEED=5 \
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build split-flapIf you must build on a 1 GB Pi 3B+, add swap first:
sudo dphys-swapfile swapoff
sudo sed -i 's/^CONF_SWAPSIZE=.*/CONF_SWAPSIZE=1024/' /etc/dphys-swapfile
sudo dphys-swapfile setup && sudo dphys-swapfile swaponRun the 64-bit Raspberry Pi OS so the standard arm64 Node image is used.
docker compose logs -f # follow logs (app + watchtower)
docker compose pull && docker compose up -d # update now (don't wait for the poll)
curl http://<pi-ip>/api/version # which build is running
docker compose down # stop (keeps the data volume)
docker compose down -v # stop AND wipe persisted stateThe persisted state lives in the split-flap-data volume and survives
down/up and image updates — only down -v clears it.
Full endpoint reference is in CLAUDE.md. Common ones
(replace the host; add :<port> only if you overrode HOST_PORT):
curl -X POST http://<pi-ip>/api/mode/qlock # word clock
curl -X POST http://<pi-ip>/api/mode/flip # split-flap board
curl -X POST http://<pi-ip>/api/message \
-H 'Content-Type: application/json' \
-d '{"lines":["HELLO WORLD"],"align":"center"}'
curl http://<pi-ip>/api/health # liveness probeIn Info Split-Flap mode the board rotates through up to 6 pushable screens
(15s each) in the top rows, with the date/time line pinned to the bottom. Any
process on any machine can publish to a slot (1–6); a slot's content expires
15 minutes after its last push and drops out of the rotation. The setup
screen (/setup) shows a live, view-only preview of all 6 slots.
# Push to slot 3 (up to 7 lines; uppercased, padded to 24 chars)
curl -X POST http://<pi-ip>/api/screens/3 \
-H 'Content-Type: application/json' \
-d '{"lines":["SERVER A","CPU 42%","MEM 71%"],"align":"center"}'
curl http://<pi-ip>/api/screens # inspect all slots + TTLs
curl -X DELETE http://<pi-ip>/api/screens/3 # clear one slotRuns the client and server as two processes (Vite on :3000 proxies /api and
the WebSocket to the server on :3001):
./start.sh # starts both; ./stop.sh / ./status.sh to manageSee CLAUDE.md for the full developer guide, architecture, and the DigitalOcean App Platform deployment path.