LED Fidget & Game

Build a fun CircuitPython project for fidgeting that doubles as an LED chase game! 

Powered by the Adafruit Feather RP2040, this project features the ANO navigation encoder to control a 24 NeoPixel ring.

NeoPixel Fidget

In the fidget mode, use the directional buttons to change the color of the LED. The scroll wheel lets you move an LED pixel along the ring. Press the center button to light up the whole ring!

LED Chase

Press and hold the center button to go into LED chase game mode. Press the center button when the LED lands on either of the three lit pixels to advance to the next level. If you win all levels, you are rewarded with a lovely rainbow!

Parts

Angled shot of ANM rotary encoder.
This funky user interface element is reminiscent of the original clicking scroll wheel interface...
$8.95
In Stock
Overhead video of a blue-manicured finger manipulating a rotary encoder connected to a 4-digit LED segment display.
The ANO rotary encoder wheel is a funky user interface element, reminiscent of the original...
$4.95
In Stock
Angled shot of black rectangular microcontroller "Feather RP2040"
A new chip means a new Feather, and the Raspberry Pi RP2040 is no exception. When we saw this chip we thought "this chip is going to be awesome when we give it the Feather...
$11.95
In Stock
Hand holding NeoPixel Ring with 24 x 5050 RGB LED, lit up rainbow
Round and round and round they go! 24 ultra bright smart LED NeoPixels are arranged in a circle with 2.6" (66mm) outer diameter. The rings are 'chainable' - connect the...
$16.95
In Stock
Angled shot of a Lithium Ion Polymer Battery 3.7V 500mAh with JST-PH connector.
Lithium-ion polymer (also known as 'lipo' or 'lipoly') batteries are thin, light, and powerful. The output ranges from 4.2V when completely charged to 3.7V. This...
$7.95
In Stock
1 x Slide Switch
Breadboard-friendly SPDT Slide Switch
1 x STEMMA QT cable
STEMMA QT / Qwiic JST SH 4-Pin Cable - 50mm Long
1 x Silicone Cover Stranded-Core Ribbon Cable
10 Wire 1 Meter Long - 28AWG Black
1 x USB-A to USB-C Cable
Pink and Purple Woven USB A to USB C Cable - 1 meter long

Hardware

The following hardware is required for the assembly.

  • 12x M2.5 x 6mm long machine screws

The diagram below provides a general visual reference for wiring of the components once you get to the Assembly page. This diagram was created using the software package Fritzing.

Adafruit Library for Fritzing

Adafruit uses the Adafruit's Fritzing parts library to create circuit diagrams for projects. You can download the library or just grab individual parts. Get the library and parts from GitHub - Adafruit Fritzing Parts.

Wired Connections

  • The slide switch is connected to the EN and GND pins on the Feather.
  • A 400mAh battery is connected to the battery port on the Feather.
  • The ANO Rotary Encoder is connected to Feather using a STEMMA QT cable.

24x NeoPixel Ring

  • Data Input from NeoPixel ring to D5 on Feather
  • GND from NeoPixel ring to GND on Feather
  • +5V from NeoPixel ring to 3V on Feather

3D Printed Parts

STL files for 3D printing are oriented to print "as-is" on FDM style machines. Parts are designed to 3D print without any support material using PLA filament. Original design source may be downloaded using the links below.

CAD Assembly

The Feather is secured to the PCB mount with screws.

The slide switch press fits into a built-in holder on the PCB mount.

The battery is fitted on top of the Feather.

The PCB mount is secured to the bottom cover using screws. The case and bottom cover snap fit.

The 24x NeoPixel ring press fits into the ring holder.

The ring holder fits on top of the case.

The diffuser is press fitted into the ring holder.

The wheel cover fits into the ring holder.

Build Volume

The parts require a 3D printer with a minimum build volume.

  • 75mm (X) x 75mm (Y) x 30mm (Z)

Design Source Files

The project assembly was designed in Fusion 360. This can be downloaded in different formats like STEP, STL and more.

Electronic components like Adafruit's boards, displays, connectors and more can be downloaded from the Adafruit CAD parts GitHub Repo.

CircuitPython is a derivative of MicroPython designed to simplify experimentation and education on low-cost microcontrollers. It makes it easier than ever to get prototyping by requiring no upfront desktop software downloads. Simply copy and edit files on the CIRCUITPY drive to iterate.

CircuitPython Quickstart

Follow this step-by-step to quickly get CircuitPython running on your board.

Click the link above to download the latest CircuitPython UF2 file.

Save it wherever is convenient for you.

To enter the bootloader, hold down the BOOT/BOOTSEL button (highlighted in red above), and while continuing to hold it (don't let go!), press and release the reset button (highlighted in blue above). Continue to hold the BOOT/BOOTSEL button until the RPI-RP2 drive appears!

If the drive does not appear, release all the buttons, and then repeat the process above.

You can also start with your board unplugged from USB, press and hold the BOOTSEL button (highlighted in red above), continue to hold it while plugging it into USB, and wait for the drive to appear before releasing the button.

A lot of people end up using charge-only USB cables and it is very frustrating! Make sure you have a USB cable you know is good for data sync.

You will see a new disk drive appear called RPI-RP2.

 

Drag the adafruit_circuitpython_etc.uf2 file to RPI-RP2.

The RPI-RP2 drive will disappear and a new disk drive called CIRCUITPY will appear.

That's it, you're done! :)

Safe Mode

You want to edit your code.py or modify the files on your CIRCUITPY drive, but find that you can't. Perhaps your board has gotten into a state where CIRCUITPY is read-only. You may have turned off the CIRCUITPY drive altogether. Whatever the reason, safe mode can help.

Safe mode in CircuitPython does not run any user code on startup, and disables auto-reload. This means a few things. First, safe mode bypasses any code in boot.py (where you can set CIRCUITPY read-only or turn it off completely). Second, it does not run the code in code.py. And finally, it does not automatically soft-reload when data is written to the CIRCUITPY drive.

Therefore, whatever you may have done to put your board in a non-interactive state, safe mode gives you the opportunity to correct it without losing all of the data on the CIRCUITPY drive.

Entering Safe Mode

To enter safe mode when using CircuitPython, plug in your board or hit reset (highlighted in red above). Immediately after the board starts up or resets, it waits 1000ms. On some boards, the onboard status LED (highlighted in green above) will blink yellow during that time. If you press reset during that 1000ms, the board will start up in safe mode. It can be difficult to react to the yellow LED, so you may want to think of it simply as a slow double click of the reset button. (Remember, a fast double click of reset enters the bootloader.)

In Safe Mode

If you successfully enter safe mode on CircuitPython, the LED will intermittently blink yellow three times.

If you connect to the serial console, you'll find the following message.

Auto-reload is off.
Running in safe mode! Not running saved code.

CircuitPython is in safe mode because you pressed the reset button during boot. Press again to exit safe mode.

Press any key to enter the REPL. Use CTRL-D to reload.

You can now edit the contents of the CIRCUITPY drive. Remember, your code will not run until you press the reset button, or unplug and plug in your board, to get out of safe mode.

Flash Resetting UF2

If your board ever gets into a really weird state and CIRCUITPY doesn't show up as a disk drive after installing CircuitPython, try loading this 'nuke' UF2 to RPI-RP2. which will do a 'deep clean' on your Flash Memory. You will lose all the files on the board, but at least you'll be able to revive it! After loading this UF2, follow the steps above to re-install CircuitPython.

Once you've finished setting up your Feather RP2040 with CircuitPython, you can access the code and necessary libraries by downloading the Project Bundle.

To do this, click on the Download Project Bundle button in the window below. It will download to your computer as a zipped folder.

# SPDX-FileCopyrightText: 2023 Liz Clark for Adafruit Industries
# SPDX-License-Identifier: MIT

import time
import random
import board
import neopixel
from adafruit_seesaw import seesaw, rotaryio, digitalio
from adafruit_debouncer import Button
from rainbowio import colorwheel
from adafruit_led_animation import color

# NeoPixel ring setup. Update num_pixels if using a different ring.
num_pixels = 24
pixels = neopixel.NeoPixel(board.D5, num_pixels, auto_write=False)

i2c = board.STEMMA_I2C()
seesaw = seesaw.Seesaw(i2c, addr=0x49)

buttons = []
for b in range(1, 6):
    seesaw.pin_mode(b, seesaw.INPUT_PULLUP)
    ss_pin = digitalio.DigitalIO(seesaw, b)
    button = Button(ss_pin, long_duration_ms=1000)
    buttons.append(button)

encoder = rotaryio.IncrementalEncoder(seesaw)
last_position = 0

button_names = ["Select", "Up", "Left", "Down", "Right"]
colors = [color.RED, color.YELLOW, color.ORANGE, color.GREEN,
          color.TEAL, color.CYAN, color.BLUE, color.PURPLE, color.MAGENTA]

# rainbow cycle function
def rainbow_cycle(wait):
    for j in range(255):
        for i in range(num_pixels):
            rc_index = (i * 256 // num_pixels) + j
            pixels[i] = colorwheel(rc_index & 255)
        pixels.show()
        time.sleep(wait)

color_index = 0
game_mode = False
pixel = 0
num = 0
last_num = 0
now_color = 0
next_color = 1
speed = 0.1
level = 0.005
final_level = 0.001
new_target = True

while True:
    if not game_mode:
        for b in range(5):
            buttons[b].update()
            if buttons[b].released or buttons[b].pressed:
                pixels.fill(color.BLACK)
        position = encoder.position
        if position != last_position:
            pixels[last_position % num_pixels] = color.BLACK
            pixels[position % num_pixels] = colors[color_index]
            # print("Position: {}".format(position))
            last_position = position

        if buttons[0].pressed:
            # print("Center button!")
            pixels.fill(colors[color_index])

        elif buttons[0].long_press:
            # print("long press detected")
            pixels.fill(color.BLACK)
            new_target = True
            game_mode = True

        if buttons[1].pressed:
            # print("Up button!")
            color_index = (color_index + 1) % len(colors)
            pixels[10] = colors[color_index]

        if buttons[2].pressed:
            # print("Left button!")
            color_index = (color_index + 1) % len(colors)
            pixels[4] = colors[color_index]

        if buttons[3].pressed:
            # print("Down button!")
            color_index = (color_index - 1) % len(colors)
            pixels[22] = colors[color_index]

        if buttons[4].pressed:
            # print("Right button!")
            color_index = (color_index - 1) % len(colors)
            pixels[16] = colors[color_index]

        pixels.show()
    if game_mode:
        buttons[0].update()
        if buttons[0].long_press:
            # print("long press detected")
            pixels.fill(color.BLACK)
            pixels.show()
            game_mode = False
            pixels.fill(colors[color_index])
        #  if new level starting..
        if new_target:
            if buttons[0].released:
            #  randomize target location
                y = random.randint(5, 22)
                x = y - 1
                z = y + 1
                new_target = False
                pixels[x] = color.WHITE
                pixels[y] = colors[next_color]
                pixels[z] = color.WHITE
        else:
            #  delay without time.sleep()
            if (pixel + speed) < time.monotonic():
                #  turn off pixel behind chaser
                if num > 0:
                    last_num = num - 1
                    pixels[last_num] = color.BLACK
                    pixels.show()
                #  keep target pixels their colors when the chaser passes
                if last_num in (x, y, z):
                    pixels[x] = color.WHITE
                    pixels[y] = colors[next_color]
                    pixels[z] = color.WHITE
                #  move chaser pixel by one
                if num < num_pixels:
                    pixels[num] = colors[now_color]
                    pixels.show()
                    num += 1
                #  send chaser back to the beginning of the circle
                if num == num_pixels:
                    last_num = num - 1
                    pixels[last_num] = color.BLACK
                    pixels.show()
                    num = 0
                #  if the chaser hits the target...
                if last_num in [x, y, z] and not buttons[0].value:
                    #  fills with the next color
                    pixels.fill(colors[next_color])
                    pixels.show()
                    #  chaser resets
                    num = 0
                    time.sleep(0.5)
                    pixels.fill(color.BLACK)
                    pixels.show()
                    #  speed increases for next level
                    speed = speed - level
                    #  color updates
                    next_color = (next_color + 1) % 9
                    now_color = (now_color + 1) % 9
                    #  setup for new target
                    new_target = True
                #  if the chaser misses the target...
                if last_num not in [x, y, z] and not buttons[0].value:
                    #  fills with current chaser color
                    pixels.fill(color.BLACK)
                    pixels.show()
                    #  chaser is reset
                    num = 0
                    #  speed is reset to default
                    speed = 0.1
                    #  colors are reset
                    next_color = 1
                    now_color = 0
                    #  setup for new target
                    new_target = True
                #  when you have beaten all the levels...
                if speed < final_level:
                    #  rainbows!
                    rainbow_cycle(0.01)
                    time.sleep(1)
                    #  chaser is reset
                    num = 0
                    pixels.fill(color.BLACK)
                    pixels.show()
                    #  speed is reset to default
                    speed = 0.1
                    #  colors are reset
                    next_color = 1
                    now_color = 0
                    #  setup for new target
                    new_target = True
                #  time.monotonic() is reset for the delay
                pixel = time.monotonic()

Upload the Code and Libraries to the Feather RP2040

After downloading the Project Bundle, plug your Feather RP2040 into the computer's USB port with a known good USB data+power cable. You should see a new flash drive appear in the computer's File Explorer or Finder (depending on your operating system) called CIRCUITPY. Unzip the folder and copy the following items to the Feather RP2040's CIRCUITPY drive.

  • lib folder
  • code.py

Your Feather RP2040 CIRCUITPY drive should look like this after copying the lib folder and the code.py file.

CIRCUITPY

How the CircuitPython Code Works

The code begins by setting up the NeoPixel ring and the ANO rotary encoder. The rotary encoder uses a seesaw chip to communicate over I2C. The five buttons are created as Button objects from the adafruit_debouncer library.

# NeoPixel ring setup. Update num_pixels if using a different ring.
num_pixels = 24
pixels = neopixel.NeoPixel(board.D5, num_pixels, auto_write=False)

i2c = board.STEMMA_I2C()
seesaw = seesaw.Seesaw(i2c, addr=0x49)

buttons = []
for b in range(1, 6):
    seesaw.pin_mode(b, seesaw.INPUT_PULLUP)
    ss_pin = digitalio.DigitalIO(seesaw, b)
    button = Button(ss_pin, long_duration_ms=1000)
    buttons.append(button)

encoder = rotaryio.IncrementalEncoder(seesaw)
last_position = 0

Colors

The colors list indexes predefined colors from the adafruit_led_animation library.

colors = [color.RED, color.YELLOW, color.ORANGE, color.GREEN,
          color.TEAL, color.CYAN, color.BLUE, color.PURPLE, color.MAGENTA]

The Loop

The state of game_mode determines what functionality is active in the loop. If game_mode is False, then the fidget acts as a fidget toy. When you press any of the arrow buttons, the NeoPixel color changes by either increasing or decreasing the color_index value. If you spin the rotary encoder, a single pixel will move around the ring. If you press the center button, the entire ring will light up. If you hold the center button down for a full second or longer, a long_press is detected and game_mode changes to True.

if not game_mode:
        for b in range(5):
            buttons[b].update()
            if buttons[b].released or buttons[b].pressed:
                pixels.fill(color.BLACK)
        position = encoder.position
        if position != last_position:
            pixels[last_position % num_pixels] = color.BLACK
            pixels[position % num_pixels] = colors[color_index]
            # print("Position: {}".format(position))
            last_position = position

        if buttons[0].pressed:
            # print("Center button!")
            pixels.fill(colors[color_index])

        elif buttons[0].long_press:
            # print("long press detected")
            pixels.fill(color.BLACK)
            new_target = True
            game_mode = True

        if buttons[1].pressed:
            # print("Up button!")
            color_index = (color_index + 1) % len(colors)
            pixels[10] = colors[color_index]

        if buttons[2].pressed:
            # print("Left button!")
            color_index = (color_index + 1) % len(colors)
            pixels[4] = colors[color_index]

        if buttons[3].pressed:
            # print("Down button!")
            color_index = (color_index - 1) % len(colors)
            pixels[22] = colors[color_index]

        if buttons[4].pressed:
            # print("Right button!")
            color_index = (color_index - 1) % len(colors)
            pixels[16] = colors[color_index]

        pixels.show()

When game_mode is True, a modified version of the NeoPixel chase game begins. 

The goal is to press the center button when the moving NeoPixel lands on the target pixels. The target pixels are placed randomly so it’s different for each level. The game advances and speeds up as you score and hit the targets. The running pixel changes color as you level up and goes in the order of ROYGBIV. If you miss a target, the game starts over.

To change game_mode to False, you can hold down the center button for a long_press to return to the fidget toy mode.

if game_mode:
        buttons[0].update()
        if buttons[0].long_press:
            # print("long press detected")
            pixels.fill(color.BLACK)
            pixels.show()
            game_mode = False
            pixels.fill(colors[color_index])
        #  if new level starting..
        if new_target:
            if buttons[0].released:
            #  randomize target location
                y = random.randint(5, 22)
                x = y - 1
                z = y + 1
                new_target = False
                pixels[x] = color.WHITE
                pixels[y] = colors[next_color]
                pixels[z] = color.WHITE
        else:
            #  delay without time.sleep()
            if (pixel + speed) < time.monotonic():
                #  turn off pixel behind chaser
                if num > 0:
                    last_num = num - 1
                    pixels[last_num] = color.BLACK
                    pixels.show()
                #  keep target pixels their colors when the chaser passes
                if last_num in (x, y, z):
                    pixels[x] = color.WHITE
                    pixels[y] = colors[next_color]
                    pixels[z] = color.WHITE
                #  move chaser pixel by one
                if num < num_pixels:
                    pixels[num] = colors[now_color]
                    pixels.show()
                    num += 1
                #  send chaser back to the beginning of the circle
                if num == num_pixels:
                    last_num = num - 1
                    pixels[last_num] = color.BLACK
                    pixels.show()
                    num = 0
                #  if the chaser hits the target...
                if last_num in [x, y, z] and not buttons[0].value:
                    #  fills with the next color
                    pixels.fill(colors[next_color])
                    pixels.show()
                    #  chaser resets
                    num = 0
                    time.sleep(0.5)
                    pixels.fill(color.BLACK)
                    pixels.show()
                    #  speed increases for next level
                    speed = speed - level
                    #  color updates
                    next_color = (next_color + 1) % 9
                    now_color = (now_color + 1) % 9
                    #  setup for new target
                    new_target = True
                #  if the chaser misses the target...
                if last_num not in [x, y, z] and not buttons[0].value:
                    #  fills with current chaser color
                    pixels.fill(color.BLACK)
                    pixels.show()
                    #  chaser is reset
                    num = 0
                    #  speed is reset to default
                    speed = 0.1
                    #  colors are reset
                    next_color = 1
                    now_color = 0
                    #  setup for new target
                    new_target = True
                #  when you have beaten all the levels...
                if speed < final_level:
                    #  rainbows!
                    rainbow_cycle(0.01)
                    time.sleep(1)
                    #  chaser is reset
                    num = 0
                    pixels.fill(color.BLACK)
                    pixels.show()
                    #  speed is reset to default
                    speed = 0.1
                    #  colors are reset
                    next_color = 1
                    now_color = 0
                    #  setup for new target
                    new_target = True
                #  time.monotonic() is reset for the delay
                pixel = time.monotonic()

ANO Rotary Breakout and Encoder Wheel

Install the rotary wheel to the ANO STEMMA QT breakout by orienting pins in the correct the holes.

Solder all of the pins on the ANO rotary encoder wheel to the ANO STEMMA QT breakout.

Slide Switch

Use the ribbon cable to create a 2-pin wire that is 6 inches / 15 cm in length.

Cut the pins on the slide switch so they're half their original length. Remove the third pin (either far left or right, but not the middle).

Solder the 2-pin wire to the two pins on the slide switch.

Feather Switch

Solder the 2-pin wire from the switch to the EN and GND pins on the bottom of the Feather RP2040.

NeoPixel Ring

Use the ribbon cable to create a 3-pin wire that is 12 inches / 30 cm in length.

Solder the 3-pin wire to the Data Input, GND, and PWR 5+V pins on the bottom of the 24x NeoPixel ring.

Connect NeoPixel Ring to Feather

Solder the 3-pin wire from the NeoPixel ring to the Feather RP2040.

  • Data In from NeoPixel to Pin 5 on Feather
  • GND from NeoPixel to GND on Feather
  • PWR +5V from NeoPixel to 3V on Feather

Wired NeoPixel Ring

Take a moment to inspect the wires have been properly soldered to the pin on the Feather RP2040.

Connect ANO Rotary

Use the short STEMMA QT cable to connect the ANO rotary breakout to the Feather RP2040.

Connect Battery

Plugin the 500mAh lipo battery to the battery port on the Feather RP2040.

Use the slide switch to power the Feather RP2040 on.

Install Slide Switch

Get the 3D printed PCB mount and circuit ready.

Insert the slide switch into the switch holder on the PCB mount by inserting it at an angle and fitting until it sits flush.

Install Feather

Place the Feather over the PCB Mount with the mounting holes in the correct orientation.

Use 4x M2.5 x 6mm long machine screws to secure the Feather to the PCB mount.

Install Battery

Place the 500 mAh battery over the Feather so it fits in between the standoffs. 

Install ANO Rotary

Place the ANO rotary breakout over the four tall standoffs with the mounting holes line up.

Secure ANO Rotary

Use 4x M2.5 x 6mm long machine screws to secure the ANO rotary breakout to the PCB mount.

Test Circuit

Use the slide switch to power on the Feather RP2040 and test out the circuit.

Turn off the Feather when ready to proceed.

Bottom Cover

Orient the bottom cover with the Feather RP2040.

Secure Bottom Cover

Place the PCB mount assembly over the bottom cover with the mounting holes and slide switch correctly oriented.

Use 4x M2.5 x 6mm long screws to secure the bottom cover to the PCB mount.

Install Case

Fit the 24x NeoPixel ring through the 3D printed case with the USB cutout properly oriented.

Secure Case to Bottom Cover

Fit the case onto the bottom cover and snap fit them together.

NeoPixel Ring Holder

Get the NeoPixel ring holder ready to install.

Secure NeoPixel Ring to Holder

Orient the 24x NeoPixel ring with the ring holder so the NeoPixel LEDs are fitted inside the spots in the grid.

Press fit the NeoPixel ring into the holder until it sits flush.

NeoPixel Wire

Adjust the 3-pin wire from the NeoPixel ring so they're fitted along the bottom of the PCB.

Installing Holder

Turn on the Feather RP2040 using the slide switch. Press one of the directional buttons on the ANO wheel to power a single NeoPixel LED.

Rotate the ring holder until the lit LED is lined up correctly with the directional button. 

Secure Ring Holder

Press the ring holder down into the case until it is fully seating into the case.

Install Rotary Cover

Place the rotary wheel cover over the ANO rotary wheel.

Secured Rotary Cover

Press the wheel cover down until it sits flush with the ANO rotary wheel.

Install Diffuser

Orient the 3D printed diffuser so the squares can be fitted into the ring holders grid.

Press the diffuser into the ring holder until it's been fully seated.

Final Build

Use the slide switch to turn the Feather RP2040 on and off.

Congratulations on your build!

This guide was first published on Jan 16, 2024. It was last updated on Jul 12, 2024.