Your Next Side Project Should Be Haunted

Build a prop with a little personality: four spooky project ideas, original MicroPython examples, and the timing tricks that make a small haunting work.

Give a plastic pumpkin a seven-hundred-millisecond pause before it lights up. Suddenly, pressing the button feels less like operating a decoration and more like interrupting something.

That is a very good excuse to build a side project. The finish line is small enough to reach before Halloween: make one object do something strange. You get to write software that has a physical punchline. You also get a perfectly respectable reason to name a variable dread.

Raspberry Pi's 19 October 2026 Halloween roundup is a timely starting point. It gathers Pico-powered lighting upgrades, animated decorations and a portrait with wandering eyes. The appealing part is how much personality makers get from a small amount of movement.

Borrow the trick, then write your own ghost

Two builds are worth studying before buying a basket of sensors.

In the animated skeleton tutorial published on 28 September 2026, servos move a flat plastic skeleton, and a distance sensor tells it when someone is nearby. The useful mechanical lesson is to establish the servo's position before attaching the limbs. Software cannot rescue a horn installed at the wrong angle.

The eye-following portrait is an older project, covered in August 2024 and featured again in this year's roundup. Ultrasonic sensors and servo-driven pieces behind the portrait create the effect. It is a lovely reminder that the audience only sees the front of the frame.

Neither project needs to become your shopping list. Pick a behavior that makes you smile, then prototype the smallest version.

Here are four original directions to try. (Build concepts)

The cupboard that answers late

Put a translucent paper window on a small cardboard cupboard. A hidden button summons a dim amber light, but only after a pause. The visitor taps again because nothing happened. Then the window wakes up.

The important feature is a cooldown. Without one, the visitor can mash the button until your haunting becomes a doorbell. The first code example below gives the cupboard a little patience.

A portrait that looks away

Mount lightweight paper pupils on a slider behind a frame. After the visitor presses a button, the eyes slowly turn toward an empty corner of the room. Leave them there for a beat before returning.

That tiny choice gives the prop something to be worried about. A single calibrated positional servo can drive the slider. Build and test the linkage with the horn disconnected first, and keep fingers away from the mechanism.

A specimen jar that holds its breath

Diffuse a low-current LED behind an empty plastic jar and let its brightness rise and fall slowly. A button makes it stop at the dim end of the cycle. After a few seconds, it resumes a little faster.

Try changing the pause before adding more hardware. The interesting part is the interrupted rhythm. Keep this one gentle: no rapid flashing, no liquid around the circuit, and no glass that a startled guest might knock over.

The haunted build-status box

Make a desk prop with a tiny cardboard lid and a label reading “DO NOT DEPLOY.” A button stands in for a failed build; a servo raises the lid just far enough for a red LED to peek out.

Get that manual version working first. A later version could consume a narrowly scoped status message from your own computer. Keep credentials on the computer, and make a lost connection produce a quiet, closed box. A network outage should not summon the entire underworld.

Start with one LED and a button

The cupboard prototype needs a Pico running MicroPython, a breadboard, jumper wires, a momentary button, one ordinary low-current LED and a 1 kΩ series resistor. Use a board with correctly fitted headers. Unplug it while wiring.

Connect GP15 through the resistor to the LED's anode; connect its cathode to GND. Connect the button between GP14 and GND. The internal pull-up holds the input high until the button is pressed. These are GPIO labels, not physical header-pin numbers. Check the pinout for your exact Pico board.

This is a single indicator LED circuit. Do not substitute a strip, motor or lamp on the GPIO pin. Keep the electronics outside a damp carved pumpkin; a dry cardboard or plastic prop is easier to work with. Use an enclosed, suitable USB supply and keep the whole prototype indoors.

Here is the complete timing loop. It waits for the button to be released before arming, including at startup. One press starts a roughly 700 ms pause, followed by 1.2 seconds of light and an eight-second cooldown. Polling and interpreter overhead make the real timing approximate.

from machine import Pin
from time import ticks_ms, ticks_add
from time import ticks_diff, sleep_ms


def haunt():
    # Use GPIO numbers, not header-pin numbers.
    led = Pin(15, Pin.OUT, value=0)
    button = Pin(14, Pin.IN, Pin.PULL_UP)
    state, due, up = "release", None, None

    try:
        while True:
            now = ticks_ms()
            pressed = button.value() == 0

            if state == "release":
                # Require 30 ms released.
                if pressed:
                    up = None
                elif up is None:
                    up = now
                elif ticks_diff(now, up) >= 30:
                    state = "armed"
            elif state == "armed":
                if pressed:
                    state = "dread"
                    due = ticks_add(now, 700)
            elif ticks_diff(now, due) >= 0:
                if state == "dread":
                    led.on()
                    state = "glow"
                    due = ticks_add(now, 1200)
                elif state == "glow":
                    led.off()
                    state = "cooldown"
                    due = ticks_add(now, 8000)
                else:
                    state = "release"
                    up = None

            sleep_ms(5)
    finally:
        led.off()  # Also runs after Ctrl-C.


if __name__ == "__main__":
    haunt()

The 30 ms release check keeps contact bounce from immediately rearming the prop. Holding the button down through startup or cooldown will not repeatedly trigger it. The short polling sleep leaves room to add another input later; there is no eight-second blocking sleep in the cooldown.

MicroPython's millisecond counter wraps around. Use ticks_add to create deadlines and ticks_diff to compare them, as the time documentation specifies. Plain subtraction can give the wrong answer at the wrap. These short intervals are comfortably within the supported comparison window on Pico.

Run it from your editor first. Once the wiring and behavior are right, save it on the board as main.py for standalone startup. For the LED wiring and pull-up API, see MicroPython's RP2 quick reference.

Give the eyes a slow glance

A servo that snaps between endpoints looks like a mechanism. Slowing its command near the start and finish gives the portrait a more deliberate glance.

The following separate example uses a 50 Hz PWM signal on GP16 and a smoothstep curve between pulse widths. Use a positional hobby servo whose datasheet supports that signal and a 3.3 V logic input. The 1300–1700 μs window is an illustrative starting range, not a universal safe limit or a promise of particular angles. Pololu explains why pulse ranges need calibration. Check the servo specification, test unloaded, and calibrate the limits before attaching the prop.

Power the servo from a separate regulated supply at its specified voltage, with enough current for its stall demand. Join that supply's ground to Pico GND. Keep the positive servo supply separate from the USB-powered Pico's power pins. Never power a servo from a GPIO or the Pico's 3V3 pin. If the servo cannot accept a 3.3 V control signal, use a suitable level shifter. Adafruit's servo wiring guide explains the external supply and shared-ground arrangement.

from machine import Pin, PWM
from time import ticks_ms, ticks_diff, sleep_ms

# Check and calibrate for your servo.
LOW_US, HIGH_US = 1300, 1700


def ease(pwm, start_us, end_us,
         duration_ms=800):
    if not (LOW_US <= start_us <= HIGH_US
            and LOW_US <= end_us <= HIGH_US):
        raise ValueError(
            "Pulse outside the "
            "calibrated window"
        )
    if not 20 <= duration_ms <= 10000:
        raise ValueError(
            "Use a duration from 20 to 10000 ms"
        )
    began = ticks_ms()
    while True:
        elapsed = ticks_diff(ticks_ms(), began)
        t = min(1.0, elapsed / duration_ms)
        smooth = t * t * (3 - 2 * t)
        span = end_us - start_us
        pulse_us = start_us + span * smooth
        pwm.duty_ns(round(pulse_us * 1000))
        if t >= 1.0:
            break
        sleep_ms(20)


def demo():
    servo = PWM(
        Pin(16), freq=50, duty_ns=1500000
    )
    try:
        # Initial centering may move suddenly.
        sleep_ms(300)
        ease(servo, 1500, 1700)
        sleep_ms(250)
        ease(servo, 1700, 1500)
        sleep_ms(100)
    finally:
        servo.deinit()
        Pin(16, Pin.OUT, value=0)


if __name__ == "__main__":
    demo()

duty_ns sets the high-pulse duration in nanoseconds, so a 1500 μs pulse is 1,500,000 ns. See the PWM reference. The helper bounds the commands, but it cannot detect a jam, know the shaft's actual position or enforce a safe mechanical load. Initial centering can move suddenly. Switch off the servo supply before touching the linkage; stopping PWM is not a mechanical brake. Leave other PWM outputs sharing GP16's hardware slice unused in this demo, because deinitializing PWM can stop both channels.

This servo demo deliberately blocks while it moves. Keep it separate from the responsive button loop above. Combining them properly means advancing the servo a little on each pass, alongside the light's state machine.

Testing time

Put the prop on its final surface and trigger it from where visitors will actually stand. Check what happens when someone holds the button down, presses repeatedly, unplugs the board or turns it on with the button already pressed. Leave an accessible power switch and keep wires out of walking routes.

For any sensor upgrade, verify the exact module's supply and output levels before connecting it. Some ultrasonic modules produce a 5 V echo signal. Pico GPIO uses 3.3 V logic; a 5 V output needs a properly designed level interface. Start with the button until the performance is convincing.

Test the physical installation gently. Keep moving parts guarded, avoid startling people near steps, and give anyone who dislikes flashing lights or sudden noises an easy way past. A softly glowing cupboard can be plenty strange.

Testing note: The original snippets passed 14 CPython tests using simulated GPIO, PWM and wraparound clocks. The checks covered held buttons, cooldown behavior, exception cleanup and servo command bounds. This verifies control logic, not electrical behavior or timing on a microcontroller. No Pico, LED, servo or physical prop was tested for this article. The four build ideas remain suggestions to prototype, and the servo still needs hardware-specific calibration.

Happy building 🎃