I played a lot of Ultimate in college, and I've wanted to build a programmable, sensor-equipped disc ever since. This project is intended as a basic starting point for DIY disc projects: It can be programmed in the Arduino IDE or with CircuitPython, it looks cool when flying, and it responds to being thrown and caught.

This guide was written for the 'original' Gemma board, but can be done with either the original or M0 Gemma. We recommend the Gemma M0 as it is easier to use and is more compatible with modern computers!

You'll need:

Assembly is fairly simple and goes quickly, but you'll want to factor in at least 24 hours of curing time for the E6000.

Choosing a Disc

As afficionados probably know, Frisbee is a trademark owned by Wham-O, while a lot of the better options in the "flying plastic disc" market are sold by companies like Discraft.

The Flashflight Disc-O, designed in Boulder, CO and widely distributed by Nite Ize, is a good quality disc with an RGB LED and a couple of coin cells mounted in the center. A handful of fiber-optic light pipes run from the center out to the edges. At 185g, it weighs a bit more than a standard Ultimate disc, but generally flies well and has a good feel.

I decided to modify one of these since I figured I could repurpose the light pipes. They already look pretty good, after all:

Any decent disc should work for this project, but something that will diffuse or scatter light will look the coolest.

Disc Prep

The Flashflight has an enclosure for its LED and batteries mounted on the underside. This also doubles as a power button.

First, pry the lid / button case off. You should be able to do this with your bare hands, but it's fairly stiff.

Remove the LED assembly, and you'll be left with a hard plastic enclosure, attached to the disc with little plastic globs / studs / rivets.

Get a utility knife or X-Acto and (carefully!) cut through the globs of plastic.

Next, we'll put together the electronics and make sure a basic lighting sketch is working before attaching to the disc.

Gemma & NeoPixel Ring

The following diagram uses the original Gemma but you can also use the Gemma M0 with the exact same wiring!

Start by soldering the NeoPixel ring to the Gemma like so:

  • GNDGND
  • D0Data Input
  • VoutPwr

You'll want to arrange things so that the top of the Gemma (the side with the LEDs, button, switch, USB, and battery connector) faces out from the disc, while the top of the NeoPixel ring (the side with the LEDs on it) can make contact with the disc. It's helpful to lay things out on the disc first to get a sense of what this looks like:

You'll want a little bit of slack in the wire for positioning, but not too much. I wound up with longer wire than I strictly needed.

Push the wires through the NeoPixel ring from the top (the LED side) and solder them from the bottom. Once in place, they should fit in-between the LEDs on the top. I did the same with the Gemma on my first disc, though it might be safer to run them up from underneath and keep the profile lower.

Vibration Switch

Next, add the vibration switch like so:

  • GNDOne leg of sensor
  • D1Other leg of sensor

The plan is to tuck the sensor in between the Gemma and the NeoPixel ring and apply hot glue, so don't use too much wire. I wound up using the end of a jumper wire for the stiffer leg of the sensor, and putting a bit of heat shrink around a blob of solder on the other, flimsier leg, but this is probably overcomplicated. The main thing is that you want connections which will hold up as the disc is thrown.

The Arduino code presented below works equally well on all versions of GEMMA: v1, v2 and M0. But if you have an M0 board, consider using the CircuitPython code on the next page of this guide, no Arduino IDE required!

With the circuit fully assembled, you're ready for some code.

Arduino IDE Setup

First, if you haven't programmed your Gemma before, you'll need to get the IDE up and running. Check out the introductory guide, particularly the sections on drivers and IDE setup:

or

Once you've got the IDE configured, make sure you've got the Adafruit NeoPixel library installed. There's a full guide here:

You should be able to install this using the LIbrary Manager in the IDE - just do Sketch -> Include Library -> Manage Libraries...

Select "All" in the Type dropdown, and search for "neopixel", then click the row and you should get an "Install" button.

Again, if you're having trouble here, refer to the full NeoPixel guide.

Blinkendisc Sketch

Next, copy and paste the following code in a new sketch:

#include <Adafruit_NeoPixel.h>

#define NUM_LEDS              24 // 24 LED NeoPixel ring
#define NEOPIXEL_PIN           0 // Pin D0 on Gemma
#define VIBRATION_PIN          1 // Pin D1 on Gemma
#define ANALOG_RANDOMNESS_PIN A1 // Not connected to anything

#define DEFAULT_FRAME_LEN     60
#define MAX_FRAME_LEN        255
#define MIN_FRAME_LEN          5
#define COOLDOWN_AT         2000
#define DIM_AT              2500
#define BRIGHTNESS_HIGH      128
#define BRIGHTNESS_LOW        32

Adafruit_NeoPixel pixels = Adafruit_NeoPixel(NUM_LEDS, NEOPIXEL_PIN);

uint32_t color          = pixels.Color(0, 120, 30);
uint8_t  offset         = 0;
uint8_t  frame_len      = DEFAULT_FRAME_LEN;
uint32_t last_vibration = 0;
uint32_t last_frame     = 0;

void setup() {
  // Random number generator is seeded from an unused 'floating'
  // analog input - this helps ensure the random color choices
  // aren't always the same order.
  randomSeed(analogRead(ANALOG_RANDOMNESS_PIN));
  // Enable pullup on vibration switch pin.  When the switch
  // is activated, it's pulled to ground (LOW).
  pinMode(VIBRATION_PIN, INPUT_PULLUP);
  pixels.begin();
}

void loop() {
  uint32_t t;

  // Compare millis() against lastFrame time to keep frame-to-frame
  // animation timing consistent.  Use this idle time to check the
  // vibration switch for activity.
  while(((t = millis()) - last_frame) <= frame_len) {
    if(!digitalRead(VIBRATION_PIN)) { // Vibration sensor activated?
      color = pixels.Color(           // Pick a random RGB color
        random(256), // red
        random(256), // green
        random(256)  // blue
      );
      frame_len = DEFAULT_FRAME_LEN; // Reset frame timing to default
      last_vibration = t;            // Save last vibration time
    }
  }

  // Stretch out frames if nothing has happened in a couple of seconds:
  if((t - last_vibration) > COOLDOWN_AT) {
    if(++frame_len > MAX_FRAME_LEN) frame_len = MIN_FRAME_LEN;
  }

  // If we haven't registered a vibration in DIM_AT ms, go dim:
  if((t - last_vibration) > DIM_AT) {
    pixels.setBrightness(BRIGHTNESS_LOW);
  } else {
    pixels.setBrightness(BRIGHTNESS_HIGH);
  }

  // Erase previous pixels and light new ones:
  pixels.clear();
  for(int i=0; i<NUM_LEDS; i += 6) {
    pixels.setPixelColor((offset + i) % NUM_LEDS, color);
  }

  pixels.show();

  // Increase pixel offset until it hits 6, then roll back to 0:
  if(++offset == 6) offset = 0;

  last_frame = t;
}

From the Tools→Board menu, select the device you are using: 

  • Adafruit Gemma M0
  • Adafruit Gemma 8 MHz 
  • Connect the USB cable between the computer and your device. The original Gemma (8 MHz) need the reset button pressed on the board, then click the upload button (right arrow icon) in the Arduino IDE. You do not need to press the reset on the newer Gemma M0 or Trinket M0.

If all went well, the NeoPixel ring will start a rotating pattern with 4 LEDs lit at once, and tapping the vibration sensor will change the colors.

Don't worry too much if uploading the sketch fails on the first few tries. Usually things work right off the bat, but sometimes it can take multiple attempts.

Once the sketch is running, it's probably a good idea to connect a charged LiPo to the Gemma and disconnect the USB cable to make sure the whole thing runs on battery power.

GEMMA M0 boards can run CircuitPython — a different approach to programming compared to Arduino sketches. In fact, CircuitPython comes factory pre-loaded on GEMMA M0. If you’ve overwritten it with an Arduino sketch, or just want to learn the basics of setting up and using CircuitPython, this is explained in the Adafruit GEMMA M0 guide.

These directions are specific to the “M0” GEMMA board. The original GEMMA with an 8-bit AVR microcontroller doesn’t run CircuitPython…for those boards, use the Arduino sketch on the “Arduino code” page of this guide.

Below is CircuitPython code that works similarly (though not exactly the same) as the Arduino sketch shown on a prior page. To use this, plug the GEMMA M0 into USB…it should show up on your computer as a small flash drive…then edit the file “main.py” with your text editor of choice. Select and copy the code below and paste it into that file, entirely replacing its contents (don’t mix it in with lingering bits of old code). When you save the file, the code should start running almost immediately (if not, see notes at the bottom of this page).

If GEMMA M0 doesn’t show up as a drive, follow the GEMMA M0 guide link above to prepare the board for CircuitPython.

import time

import analogio
import board
import digitalio
import neopixel

try:
    import urandom as random  # for v1.0 API support
except ImportError:
    import random

num_leds = 24  # 24 LED NeoPixel ring
neopixel_pin = board.D0  # Pin where NeoPixels are connected
vibration_pin = board.D1  # Pin where vibration switch is connected
analog_pin = board.A0  # Not connected to anything
strip = neopixel.NeoPixel(neopixel_pin, num_leds)

default_frame_len = 0.06  # Time (in seconds) of typical animation frame
max_frame_len = 0.25  # Gradually slows toward this
min_frame_len = 0.005  # But sometimes as little as this
cooldown_at = 2.0  # After this many seconds, start slowing down
dim_at = 2.5  # After this many seconds, dim LEDs
brightness_high = 0.5  # Active brightness
brightness_low = 0.125  # Idle brightness

color = [0, 120, 30]  # Initial LED color
offset = 0  # Animation position
frame_len = default_frame_len  # Frame-to-frame time, seconds
last_vibration = 0.0  # Time of last vibration
last_frame = 0.0  # Time of last animation frame

# Random number generator is seeded from an unused 'floating'
# analog input - this helps ensure the random color choices
# aren't always the same order.
pin = analogio.AnalogIn(analog_pin)
random.seed(pin.value)
pin.deinit()

# Set up digital pin for reading vibration switch
pin = digitalio.DigitalInOut(vibration_pin)
pin.direction = digitalio.Direction.INPUT
pin.pull = digitalio.Pull.UP

while True:  # Loop forever...

    while True:
        # Compare time.monotonic() against last_frame to keep
        # frame-to-frame animation timing consistent.  Use this
        # idle time to check the vibration switch for activity.
        t = time.monotonic()
        if t - last_frame >= frame_len:
            break
        if not pin.value:  # Vibration switch activated?
            color = [  # Pick a random RGB color...
                random.randint(32, 255),
                random.randint(32, 255),
                random.randint(32, 255)]
            frame_len = default_frame_len  # Reset frame timing
            last_vibration = t  # Save last trigger time

    # Stretch out frames if nothing has happened in a couple of seconds:
    if (t - last_vibration) > cooldown_at:
        frame_len += 0.001  # Add 1 ms
        if frame_len > max_frame_len:
            frame_len = min_frame_len

    # If we haven't registered a vibration in dim_at ms, go dim:
    if (t - last_vibration) > dim_at:
        strip.brightness = brightness_low
    else:
        strip.brightness = brightness_high

    # Erase previous pixels and light new ones:
    strip.fill([0, 0, 0])
    for i in range(0, num_leds, 6):
        strip[(offset + i) % num_leds] = color

    strip.write()  # and issue data to LED strip

    # Increase pixel offset until it hits 6, then roll back to 0:
    offset = (offset + 1) % 6

    last_frame = t

This code requires the neopixel.py library. A factory-fresh board will have this already installed. If you’ve just reloaded the board with CircuitPython, create the “lib” directory and then download neopixel.py from Github.

 

The Blinkendisc sketch as-written doesn't do too much, but it does offer a handful of configuration constants that you can tweak to change the behavior of the disc without rewriting the code.

Building Frames of Animation

First, let's look at the basic idea that drives the sketch. Here's a really simplified version of the loop() function:

void loop() {
  uint32_t t;

  while(((t = millis()) - last_frame) <= frame_len) {
    if(!digitalRead(VIBRATION_PIN)) { // Vibration sensor activated?
      last_vibration = t;             // Save last vibration time
    }
  }

  // Make any changes that need t, last_frame or last_vibration here.

  // Draw new frame of pixel animation here.

  pixels.show();
  last_frame = t;
}

This loop() will get called over and over again for as long as the Gemma runs. Each time through, we:

  1. Set t to the current return value of millis(), which should tell us the number of milliseconds since the sketch started.
  2. Repeatedly compare this against the time the last animation frame was drawn (last_frame). While we're waiting for the correct time interval to have passed…
  3. Use if(!digitalRead(VIBRATION_PIN)) { ... } to check for a vibration, and if we got one, set last_vibration to t. (The full Arduino sketch also uses this opportunity to pick a new random color.)

At this point we know:

  • How long ago we last advanced a frame.
  • How long ago we last detected a vibration.

The rest of the code makes decisions about what to do with this information, and translates those decisions into lit-up LEDs on the NeoPixel ring. This all happens fast enough that the vibration switch should be responsive and the animation fairly smooth.

Configuration Constants

If you look at the top of the sketch, you'll see the following configuration values:

#define NUM_LEDS              24 // 24 LED NeoPixel ring
#define NEOPIXEL_PIN           0 // Pin D0 on Gemma
#define VIBRATION_PIN          1 // Pin D1 on Gemma
#define ANALOG_RANDOMNESS_PIN A1 // Not connected to anything

#define DEFAULT_FRAME_LEN     60
#define MAX_FRAME_LEN        255
#define MIN_FRAME_LEN          5
#define COOLDOWN_AT         2000
#define DIM_AT              2500
#define BRIGHTNESS_HIGH      128
#define BRIGHTNESS_LOW        32

The first four are used to set up hardware pins on the Gemma, and should be left alone unless you wire things up differently or translate the sketch to another board with a different layout.

The rest offer various ways to change the timing and brightness of the animation:

DEFAULT_FRAME_LEN

Baseline duration, in ms, of one frame of animation. Accepts values 0-255. The sketch resets frame_len to this value every time it sees a vibration.

MAX_FRAME_LEN

The longest acceptable frame (and thus the slowest animation). The cooldown check will gradually increase frame_len until it hits this value, and then reset to MIN_FRAME_LEN.

MIN_FRAME_LEN

Shortest allowable value for frame_len (and thus the fastest animation). Values 0-255, should be lower than MAX_FRAME_LEN.

COOLDOWN_AT

Number of ms to wait after a vibration event before increasing frame_len, thus slowing the animation over time.

DIM_AT

Number of ms to wait before dimming the LEDs to BRIGHTNESS_LOW.

BRIGHTNESS_HIGH

Brightness for LEDs when in high-brightness mode - a value 0-255. Keep in mind this will impact battery life.

BRIGHTNESS_LOW

Brightness for LEDs when in low-brightness mode. A value 0-255.

Once you're confident that the hardware is working and programmed, it's time to attach it to the disc. You'll need:

  • A tube of E6000 adhesive
  • 1-2 non-conductive washers or spacers, rubber or nylon, to raise the Gemma above the level of the NeoPixel ring so that the battery and USB cable can still be connected
  • Gemma + NeoPixel + vibration switch assembly
  • Random small, flat object (a washer may work well) for counterweighting battery
  • Sturdy self-adhesive velcro patches (or, in a pinch, tape) for battery and counterweight

Disc Prep

Start by levelling the area the ring will occupy in the center of the disc (cutting off any bits of plastic that project too far from the surface), and trimming the ends of the light pipes and the channels they snap into (if using a Flashflight) to line up with the LEDs on the NeoPixel ring.

I found it easiest to eyeball the alignment of the ring and score the pipes/channels with a knife, then use a pair of scissors to snip the pipes. Be a little careful with this part - the light pipes are more brittle than the plastic that makes up the rest of the disc, but they should bend far enough to be easily cut and go back into place without much trouble.

This step isn't strictly necessary, but the final disc will look a lot cooler if the LEDs are in proximity to the ends of the light pipes.

Attaching Gemma & NeoPixel Ring with E6000

You'll want to do this part in a well-ventilated, easy-to-clean area.

First, apply E6000 to the washers and Gemma, centering it in the disc. Once that's in place, lay down a thick bead in the footprint of the NeoPixel ring, and press it firmly into place. This photo, minus the vibration switch, shows the positioning you're looking for:

Think of the E6000 as a structural element - it should hold the ring firmly into place, and partly function to diffuse the light from the LEDs.

Once this is done, set the disc aside on a flat surface and do not touch it for 24 hours. Seriously, come back in a day.

Attach Battery and Counterweight

Now you need power. To attach the battery, I used self-adhesive velcro squares - one side on the battery, one side on the disc, positioned like so:

I was worried about the battery wires snagging on something, so I added an extra square with some plastic over the adhesive on the top piece to hold them in place.

As soon as I tested this in flight, I realized the off-center weight was introducing a predictable wobble, so I added some more velcro with a small piece of wood on the opposite side to counterbalance the battery. (You may need to experiment with placement.)

Slather Some Hot Glue on There

Once I'd flight-tested the disc and was sure I was happy with the hardware layout, I added a healthy dose of hot glue to hold wires and the vibration sensor in place.

So now we have a disc with programmable LEDs which responds, at least in a rudimentary fashion, to its environment.

This is all well and good, but as anyone with much experience throwing a disc can probably attest, the physical end result leaves something to be desired. The electronics will likely survive a casual game of catch, as long as the participants have a light touch and don't pancake the disc too hard, and it looks good in the dark, but we're a long ways from a finished piece of serious athletic gear. The rigors of a real Ultimate game would probably destroy the blinkendisc in a couple of plays.

So where to from here? Well, I have an entire research agenda:

  1. Design and 3D print an enclosure for the electronics that will stand up to mud puddles, random concrete obstacles, actual Ultimate games, and the typical antics of Ultimate players.
  2. Incorporate additional sensor input and some form of wireless communication.
  3. Add more blinkenlights, because life is too short to blink in moderation.
  4. Design an interesting game mechanic that relies on accumulating state on the disc itself.

Stay tuned!

This guide was first published on May 22, 2015. It was last updated on May 22, 2015.