Hackster will be offline on Monday, June 15 from 5pm to 7pm PDT to perform some scheduled maintenance.
Alistair MacDonald
Published © MIT

Ping Pong Drum

This is an interactive game of light and sound for public spaces that allows participants to play by striking a drum.

AdvancedFull instructions providedOver 1 day116

Things used in this project

Hardware components

Raspberry Pi 5
Raspberry Pi 5
×1
Wemos D1 Mini
Espressif Wemos D1 Mini
×4
Piezo Transducer
×3
LM2596 based DC-DC Buck Converter
I did not use this exact module. Any generic module will do.
×3
Generic USB to DMX adapter
Any DMX controller compatible with Q Light Controller Plus will work.
×1
LeLeght 120W Moving Head DMX Light
Any Pan/Tilt DMX light head will work.
×1
USB-A to Micro-USB Cable
USB-A to Micro-USB Cable
A USB-Micro or USB-C cable depending on the version of the D1 Mini used.
×1
Rechargeable Battery, 12 V
Rechargeable Battery, 12 V
×3
Connector Adapter, Alligator Clip
Connector Adapter, Alligator Clip
Used to connect to battery. A proper battery connector may be more appropriate depending on the battery used.
×6
Stranded Wire
A few short length of wire to connect everything up.
×1
WS2812 Addressable LED Strip
Digilent WS2812 Addressable LED Strip
Any WS2812 addressable strip will do. LED "Neon" was used in my build.
×1
Bluetooth Speaker
A JBL Flip 6 was used in this build, but any speaker with a high enough output will suffice.
×1

Software apps and online services

Raspberry Pi OS
Base OS of the central controller.
Fusion
Autodesk Fusion
Used for designing / modifying 3D printed case.
Q Light Controller Plus (QLC+)
Used for bridging to the DMX controller.
Thonny
Used to create and run the central controller Python code.
Arduino IDE
Arduino IDE
Used for programme the drums.

Hand tools and fabrication machines

Soldering iron (generic)
Soldering iron (generic)

Story

Read more

Schematics

Drum Controller Schematic

Code

Central controller sourcecode

Python
You may need to change the GATEWAY_PORT setting if you have other serial devices connected. You will also need to create a dmxvals.csv file and download audio files. The details about how to do this are in the main write-up.
#!/usr/bin/env python3
import serial
import time
import random
import pygame
import socket
import ipaddress
import logging

# Enable the logger for debugging
logger = logging.getLogger()
logger.setLevel(logging.INFO)

# Setting for the ESP-Now gateway
GATEWAY_PORT = '/dev/ttyUSB0'
GATEWAY_BAUD = 115200
GATEWAY_HEADER = 'PINGPONGDRUM0001'

# Open the serial port for communication via the drum gateway 
gateway_serial = serial.Serial(GATEWAY_PORT, GATEWAY_BAUD, timeout=0)

# Settings for the ArtNet/DMX gateway
ARTNET_IP = '::1'
ARTNET_PORT = 6454
ARTNET_SOCKETTYPE = socket.AF_INET6 if ipaddress.ip_address(ARTNET_IP).version == 6 else socket.AF_INET
ATRNET_PACKET_SIZE = 512
DMX_VALUES_FILENAME = "dmxvals.csv"

# Initalise PyGame for sound
pygame.mixer.pre_init(44100, -16, 2, 256)
pygame.init()

# Load in the DMX position settings
with open(DMX_VALUES_FILENAME, newline="",encoding='utf-8') as file:
    dmx_values = file.readlines()

# Load in the sound effects
soundBackground = pygame.mixer.Sound("freesound_community-machine-room-55632.mp3")
soundHit = pygame.mixer.Sound("traian1984-cartoon-drum-bass-01-186984.mp3")
soundSucsess = pygame.mixer.Sound("meldix-success-340660.mp3")
soundFail = pygame.mixer.Sound("lesiakower-error-mistake-sound-effect-incorrect-answer-437420.mp3")

# A fuction to send an UDP ArtNet packet from binary DMX data
def sendDMXPacket(dmx_data):
    # Create ther Art-Net Header
    header = (b'Art-Net\x00' + # ID
        b'\x00\x50' +          # OpCode
        b'\x00\x0e' +          # Protocol Version
        b'\x00' +              # Sequence
        b'\x00' +              # Physical
        b'\x00\x00' +          # Universe
        b'\x02\x00')           # Data Length
    # Create the UDP packet from the header and DMX data
    packet = header + dmx_data
    # Send via UDP datagram
    with socket.socket(ARTNET_SOCKETTYPE, socket.SOCK_DGRAM) as sock:
        sock.sendto(packet, (ARTNET_IP, ARTNET_PORT))

# A fuction to send an UDP ArtNet packet from CSV DMX data
def sendDMX(values):
    # Create a [binary] byte array to send
    ba = bytearray(ATRNET_PACKET_SIZE)
    # Loop thought the given values and populate the byte array
    for index, value in enumerate(values.split(',')):
        if index < ATRNET_PACKET_SIZE:
            ba[index] = int(value)
    # Send the now binary values
    sendDMXPacket( ba )

# A function to randomly select a new drum to play at the given speed
def startRandomGame(speed):
    # Randomly selet the new (or the same) drum
    choice = random.randrange(3)
    # Send the DMX packet to change the light(s)
    dmx_index = 1 + choice
    sendDMX( dmx_values[dmx_index] )
    # Send the dum command to the gateway
    color = ['r', 'g', 'b'][choice]
    command = (GATEWAY_HEADER + 'c' + color + str(speed)[0] + 'x')
    gateway_serial.write(command.encode('utf-8'))
    # Log what we did for debugging
    logging.info("Gateway message " + command + " sent")

# Wait for a responce for the active drum (with timeout calculated from speed) and return the new speed
def readGameResponce(speed):
    # Calculate the timeout for the given speed
    start_time = time.time()
    # Keep looping until we timeout (or recieve a valud responce)
    duration = 1 + ( 3 * (9-speed) / 10 ) + 1
    endtime = time.time() + duration
    while time.time() < endtime:
        # Check if we have data from the gateway, and read it if we do
        if gateway_serial.in_waiting > 0:
            responce = gateway_serial.readline().decode('utf-8', errors='replace').strip()
            logging.info("Gateway message " + responce + " received")
            # Check if the responce is valid
            if len(responce) >= 20 and responce.startswith(GATEWAY_HEADER):
                # Check of the respoce is sucsessful, a falilourm or a hit from another drum
                match responce[19]:
                    case 'h':
                        soundHit.play()
                    case 's':
                        soundSucsess.play()
                        return int(responce[18])
                    case 'f':
                        soundFail.play()
                        return 0
    # We times out to return witht the default slowest speed
    return 0

def main():
    try:
        # Start the backgrond sound
        soundBackground.play(-1)
        # Se the inital speed
        speed = 0
        while True:
            # Start a new game
            startRandomGame(speed)
            # Wait for the game to complete
            speed = readGameResponce(speed)
    except KeyboardInterrupt:
        # We stopped the programm so stop the sound and turn off the DMX lights
        pygame.mixer.stop()
        sendDMX( "0" )


if __name__ == '__main__':
    main()

Central Controller Gateway Firmware

C/C++
This firmware needs flashing to an ESP8266 based module that is plugged in the the central controller for wireless communication with the drums.
/*
 * 
 * Ping Pong Drum - Central controller bridge firmware
 * 
 * Alisatir MacDonald 2026
 * 
 */

#include <ESP8266WiFi.h>
#include <espnow.h>

// Broadcast destination address so we can communicaate with all at once
uint8_t broadcastAddress[] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};

// Called when data is receaved from the drums
void onDataReceived(uint8_t *mac, uint8_t *incomingData, uint8_t len) {
  Serial.write(incomingData, len);
}

// Setup
void setup() {
  // Enable serial port for communication to main controller
  Serial.begin(115200);
  Serial.setTimeout(2);

  // Set device as a Wi-Fi Station
  WiFi.mode(WIFI_STA);

  // Initalise ESP-NOW
  esp_now_init();
  esp_now_set_self_role(ESP_NOW_ROLE_COMBO);
  // Register the callback for when data is recieved from the drums
  esp_now_register_recv_cb(onDataReceived);

}

// Main loop
void loop() {

  // Wain until serial data is recieved
  if (Serial.available()) {
    // Read in the data and send it out
    String commandToSend = Serial.readString();
    esp_now_send(broadcastAddress, (uint8_t*) commandToSend.c_str(), commandToSend.length());
    delay(4);
    esp_now_send(broadcastAddress, (uint8_t*) commandToSend.c_str(), commandToSend.length());
    delay(4);
    esp_now_send(broadcastAddress, (uint8_t*) commandToSend.c_str(), commandToSend.length());
  }

}

Drum controller firmware

C/C++
This is the firmware un on the three drums. Just set the MAD addresses for your three controllers and the rest if the configuration will be done automatically.
/*
 * 
 * Ping Pong Drum - Drum firmware
 * 
 * Alisatir MacDonald 2026
 * 
 */
 
 
#include <float.h>
#include <espnow.h>
#include <ESP8266WiFi.h>
#include <Adafruit_NeoPixel.h>

// Configuration

// Addressable LED settings
#define LED_PIN D4
#define LED_COUNT 25
#define LED_BRIGHTNESS 10

// MAC Addresses of the drums
#define MAC_ADDRESS_RED "BC:DD:C2:00:00:01"
#define MAC_ADDRESS_GREEN "BC:DD:C2:00:00:02"
#define MAC_ADDRESS_BLUE "BC:DD:C2:00:00:03"

// Timing settings
#define GAME_DURATION_MIN 1000
#define GAME_DURATION_MAX 4000

// Hardware timing settings
#define HIT_DEBOUNCE_DURATION 200
#define HIT_THRESHHOLD 200

// Color definitions for the addressable LEDs
#define COLOR_VALUE_RED     strip.Color(255,   0,   0)
#define COLOR_VALUE_GREEN   strip.Color(0,   255,   0)
#define COLOR_VALUE_BLUE    strip.Color(0,     0, 255)
#define COLOR_VALUE_UNKNOWN strip.Color(85,   85,  85)
#define COLOR_VALUE_BOP     strip.Color(255, 255, 255)

// OTA Message neader
// Used at the front of every OTA packet to identify the data intended for us
const char OTA_HEADER[] = { 'P', 'I', 'N', 'G', 'P', 'O', 'N', 'G', 'D', 'R', 'U', 'M', '0', '0', '0', '1' };

// OTA message address constants
// Used to specify where the message came from and who it is intended for
#define COLOR_LETTER_ALL     'a'
#define COLOR_LETTER_RED     'r'
#define COLOR_LETTER_GREEN   'g'
#define COLOR_LETTER_BLUE    'b'
#define COLOR_LETTER_UNKNOWN 'x'
#define COLOR_LETTER_CONTROL 'c'

// OTA message action constants
// Used for the controller to signify sucsess or failiour
#define HIT_LETTER_HIT     'h'
#define HIT_LETTER_SUCSESS 's'
#define HIT_LETTER_FAILURE 'f'

// Globals, constants and abstract types

enum DrumColor { UNKNOWN, RED, GREEN, BLUE };

// OTA Message Structure
typedef struct {
  char packetHeader[sizeof(OTA_HEADER)];  // "PINGPONGDRUM0000"
  char packetSource;      // "r", "g", "b" for drums, and "c" for controller
  char packetTarget;      // "r", "g", "b" for drums, "a" for all drums, and "c" for controller
  char packetSpeed;       // "0" to "9"
  char packetHit;         // "h" generic hit, "s" for sucsess, and "f" for failure
} OTAMessage;

// Phisical level destination mac address (aka broadcast address)
uint8_t broadcastAddress[] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};

// Create a Neopixel object from accessing the LED strips
Adafruit_NeoPixel strip(LED_COUNT, LED_PIN, NEO_BRG + NEO_KHZ800);

// Local drum settings (unique for each drum and populated at startup)
DrumColor LOCAL_COLOR = UNKNOWN;
uint32_t  LOCAL_COLOR_VALUE = COLOR_VALUE_UNKNOWN;
char      LOCAL_COLOR_LETTER = COLOR_LETTER_UNKNOWN;

// Current game settings (populated when the game starts on this drum)
int currentSpeed = 0;
int gameDuration = 0;

// Time from millis() when the game started
unsigned long lastGo = 0;

// Time from millis() when the drum was last hit
unsigned long lastBop = 0;

// Has a message about the hit been sent to the controller ( false = needs sending )
int messageSent = true;


// Functions

// used at startup to identify the colour of this drum from the MAC address
// The results in the exact same firmware running on all drums and saving setup time
DrumColor macToDrumColor(String inMacAddress) {
  if (inMacAddress.endsWith(MAC_ADDRESS_RED))   return RED;
  if (inMacAddress.endsWith(MAC_ADDRESS_GREEN)) return GREEN;
  if (inMacAddress.endsWith(MAC_ADDRESS_BLUE))  return BLUE;
  return UNKNOWN;
}

// Return a Neopixel compatable color from our abstract color code
int drumColorToNeopixelColor(DrumColor inDrumColor) {
  switch (inDrumColor) {
    case RED :   return COLOR_VALUE_RED;
    case GREEN : return COLOR_VALUE_GREEN;
    case BLUE :  return COLOR_VALUE_BLUE;
    default :    return COLOR_VALUE_UNKNOWN;
  }
}

// Return the letter required for the OTA cammand from our abstract color code
int drumColorToLetter(DrumColor inDrumColor) {
  switch (inDrumColor) {
    case RED :   return COLOR_LETTER_RED;
    case GREEN : return COLOR_LETTER_GREEN;
    case BLUE :  return COLOR_LETTER_BLUE;
    default :    return COLOR_LETTER_UNKNOWN;
  }
}

// The main setup routine
void setup() {

  // Initialize Serial Monitor
  Serial.begin(115200);
  Serial.println("Starting...");

  // Set device as a Wi-Fi Station
  WiFi.mode(WIFI_STA);
  String macAddress = WiFi.macAddress();
  Serial.println("MAC Address : " + macAddress);
  WiFi.disconnect();

  // Use A0 for input and D0 as a GND
  pinMode(A0, INPUT);
  pinMode(D0, OUTPUT);
  digitalWrite(D0, LOW);
  
  // Set the drum specific colours and settings based on the mac
  LOCAL_COLOR = macToDrumColor(macAddress);
  LOCAL_COLOR_VALUE = drumColorToNeopixelColor(LOCAL_COLOR);
  LOCAL_COLOR_LETTER = drumColorToLetter(LOCAL_COLOR);

  // Initalise ESP-NOW
  esp_now_init();
  esp_now_set_self_role(ESP_NOW_ROLE_COMBO);
  // Register the callback for when data is recieved from the drums
  esp_now_register_recv_cb(OnDataRecv);

  // Init the LED strip
  strip.begin();
  strip.setBrightness(LED_BRIGHTNESS);
  strip.clear(); 
  strip.show(); 
  
}

// A callback fuction called when a message is receaved
void OnDataRecv(uint8_t *mac, uint8_t *incomingData, uint8_t len) {

  OTAMessage* otaMessage = (OTAMessage*)incomingData;

  // Check we have a large enough message, that it has a valid header, that it is for us, and that the speed is valid
  if ( (len>=sizeof(otaMessage)) &&
       ( memcmp(otaMessage->packetHeader, OTA_HEADER, sizeof(OTA_HEADER)) == 0 ) &&
       ( ( otaMessage->packetTarget == LOCAL_COLOR_LETTER ) || ( otaMessage->packetTarget == COLOR_LETTER_ALL ) ) &&
       ( '0' <= otaMessage->packetSpeed ) && ( otaMessage->packetSpeed <= '9' ) ) {
      // Save the requexted speed and calculate the duration
      currentSpeed = otaMessage->packetSpeed - '0';
      gameDuration = GAME_DURATION_MIN + ( (GAME_DURATION_MAX-GAME_DURATION_MIN) * (9-currentSpeed) / 9 );
      // Save the start time of this game (an indirectly start it)
      lastGo = millis();
  }
}

// A function we call to send a message to the central controller
void sendMessage(char inSpeed = '0', char inHit = HIT_LETTER_HIT) {

  // Create the message to send
  OTAMessage otaMessage;
  strncpy(otaMessage.packetHeader, OTA_HEADER, sizeof(otaMessage.packetHeader));
  otaMessage.packetSource = LOCAL_COLOR_LETTER;
  otaMessage.packetTarget = COLOR_LETTER_CONTROL;
  otaMessage.packetSpeed = inSpeed;
  otaMessage.packetHit = inHit;
  
  // Send the message
  esp_now_send(broadcastAddress, (uint8_t *)&otaMessage, sizeof(otaMessage));

}

// The main loop
void loop() {

  unsigned long now = millis();

  // Check if the drum is being hit
  if (analogRead(A0)>=HIT_THRESHHOLD) {
    lastBop = now;
  }

  // Calcualte the durations from the last times saved
  unsigned long sinceLastBop = now-lastBop;
  unsigned long sinceLastGo  = now-lastGo;

  // Update the LEDs
  // If hit is the last 200ms the LEDs white
  if (sinceLastBop<200) {
    strip.fill(COLOR_VALUE_BOP);
  }
  // Light up some of the LEDs based on the percentage of time passed in this game
  else if (sinceLastGo<gameDuration) {
    int dash1Start = ( 4 * LED_COUNT * sinceLastGo / gameDuration ) % LED_COUNT;
    int dash1Length =  LED_COUNT * sinceLastGo / gameDuration;
    int dash2Length = dash1Start + dash1Length - LED_COUNT;
    strip.clear();
    strip.fill(LOCAL_COLOR_VALUE, dash1Start, dash1Length);
    if (dash2Length>0) {
      strip.fill(LOCAL_COLOR_VALUE, 0, dash2Length);
    }
  }
  // Of not doing anthing else then default to the standard local colour
  else {
    strip.fill(LOCAL_COLOR_VALUE);
  }
  strip.show();

  // Send a failure message and reset if time went over
  if ( ( gameDuration > 0 ) && ( sinceLastGo >= gameDuration ) ) {
    sendMessage( '0', HIT_LETTER_FAILURE );
    gameDuration = 0;
  }

  // Send message if drum was hit
  if (sinceLastBop<HIT_DEBOUNCE_DURATION) {
    if (!messageSent) {
      // Work out the new speed (including soem device by zero protection)
      float newSpeed = (gameDuration>0) ? 10 * sinceLastGo / gameDuration : FLT_MAX;
      if ( newSpeed < 10 ) {
        sendMessage( '0' + newSpeed, HIT_LETTER_SUCSESS );
      }
      else {
        sendMessage( '0', HIT_LETTER_HIT );
      }
      messageSent = true;
      gameDuration = 0;
    }
  }
  else {
    messageSent = false;
  }

  // Qucik fix for using ESP Now and analogRead
  delay(6);

}

Example dmxvals.csv configuration file

Plain text
This is an example configuration file for the DMX ingratiation. A full description on how to create this is in the main write-up.
0,0,0,0,0,0,0,0
169,0,69,0,10,0,0,128
179,0,88,0,20,0,0,128
160,0,89,0,30,0,0,128

Credits

Alistair MacDonald
7 projects • 5 followers

Comments