← Selected projects

Skeleton Symposium

A hobby project where software, language models and hardware meet: three animatronic skeletons that talk to each other.

The idea

Three skeletons stand side by side, each with its own voice, personality and LED colour. I give them an opening topic, and they carry on from there: one speaks, the others react with a nod or a head shake, and the next one picks up the thread. The topic can be changed at any time to steer them somewhere else. It is a tinkering project rather than a product, born from my enjoyment of building hardware and of writing the software and firmware that brings it to life, from the prompt to the servo.

Three skeletons seated around a white table with a bowl of snacks. Their skulls glow in turquoise, green and magenta.
The three skeletons chatting at the table.

How it works

A Python program, the orchestrator, runs on a laptop or small computer on the same network and does the thinking. For each turn it:

  1. asks Claude for the next line, with a separate system prompt and a rolling history for each character,
  2. turns the text into speech with Kokoro, a local text-to-speech model, so no audio leaves the machine, using an individual voice per character,
  3. plays the audio panned across stereo channels (left, center, right), so the voice comes from the right skeleton,
  4. sends the matching skeleton a command to move its jaw for the length of the audio and to glow in its colour, while the listeners nod, shake their heads or look at the speaker.

The interesting part is latency. While one skeleton is still speaking, the orchestrator already requests and synthesizes the next turn in the background, so the conversation flows without long silences. Everything runs on a single asyncio event loop; blocking work such as audio playback and MP3 conversion is pushed into executors. The terminal UI, built with Textual, shows a panel per skeleton and accepts commands such as/topic to change the subject. Each session is recorded as audio together with a text transcript.

The skeletons

Each skeleton is driven by an ESP32 with three servos (two for the head, one for the jaw) and a short LED strip inside the skull. All units run exactly the same firmware binary. Wi-Fi credentials and the skeleton’s ID are not compiled in; on first boot, or after a reset, the board opens a captive portal where both are entered and stored in flash. After joining the network, a skeleton advertises itself over mDNS, so the orchestrator finds it without any IP configuration, and it listens for plain-text UDP commands. New firmware can be flashed over the air, which matters when the skeletons are installed in place.

Close-up portrait of a plastic skeleton in a white blouse and a beaded necklace, its jaw slightly open, in front of a window with yellow-green blinds.
One of the skeletons up close.
UDP commands understood by the skeletons
CommandEffect
COLOR:<hex>Set the character colour, lift the head and activate the skeleton
TALK:<secs>:<hex>Chatter the jaw for the given time while the skull glows in a colour
NOD / SHAKEHead gestures, used as listener reactions
LOOK:<direction>Turn the head left, right or straight ahead
STOPHalt jaw and gestures, return to the idle pose
PINGThe skeleton answers with its ID, used for health checks

The firmware’s main loop is cooperative and never blocks, so jaw movement, gestures, LED animation, OTA updates and incoming packets all share it without stuttering. Motion commands are ignored until the first colour command has arrived, which keeps stale network packets from moving a freshly booted skeleton.

Lessons from the hardware side

Never let a connection problem erase configuration. The firmware I started from wiped its stored settings after a failed Wi-Fi attempt, so skeletons that had merely been moved came back in setup mode. It now keeps its configuration and retries.

ESP32 clients do not roam. With two access points sharing one network name, a skeleton can stick to the distant one. The firmware now scans all channels and joins the strongest signal.

Servo timers and Wi-Fi do not mix well. Servos are attached only after the connection is established, because their interrupts disturbed the connect phase.

Status and public appearance

The orchestrator and firmware work together end to end, and the skeletons run. I work on the code sporadically in my spare time (usually shortly before Halloween).

A few years ago, an early -- non-networked and voiceless -- iteration of the skeleton served a stint at the local cinema ticket counter over Halloween, entertaining kids on their way to the movies.

A skeleton in a green shirt sits behind a cinema ticket counter. Film posters hang on the wall behind it, and a drinks fridge and snack shelves stand beside it.
Manning the ticket counter.
Close-up of the skeleton in the green shirt, seated at the counter with its bony hands resting on either side of a tablet.
Ready to greet the visitors.

Inspiration & acknowledgments

The initial spark for this project came after watching a video by Catherine Alvord (watch on YouTube (opens in a new tab)), where she demonstrated building an interactive skeleton. I loved the concept and decided to explore my own architectural path, building an end-to-end distributed system with three networked skeletons, custom ESP32 firmware, local voice synthesis, and an asynchronous orchestration pipeline.

Catherine, if you happen to read this: thank you for the inspiration!