26 April 2026

Team 4 Buckshot Roulette Controller

 Buckshot Roulette Shotgun Controller

by William Casson and Ryan Lupoli


    My project is a custom controller for the game Buckshot Roulette. We built it using a toy gun with a Circuit Playground Express attached to it. The goal of the design is to make the controller match the game’s main actions and overall theme. Because Buckshot Roulette is centered around a shotgun, we wanted the player to interact with something that physically relates to that weapon instead of relying on a standard mouse. One important aspect of the controller’s gun shape is that it helps place the player more directly in the shoes of the player character. Actions like aiming at the dealer or at yourself feel more tactile, immediate, and immersive when the player is holding a physical toy shotgun. 

    The conceptual model of the design is based on direct physical interaction. The shape of the controller helps communicate how it should be held and used. The trigger suggests shooting or selecting, the side button suggests a secondary control, and the pump introduces another physical action connected to the shotgun mechanic. Because the form of the controller matches the intended functions, the controller is easier to understand and more naturally tied to the game.

    The input to output mapping is simple and intentional. The trigger button is mapped to Mouse 1, or left click. A side button resets the mouse cursor to the center of the screen. The built-in accelerometer in the Circuit Playground Express controls mouse movement, so tilting the toy gun moves the cursor on screen. A light sensor placed on the shotgun pump detects when the pump covers it, which locks the mouse position in place. This allows the player to move the controller elsewhere without affecting the cursor, such as setting it down for a moment or pausing to think. 

    The signifiers are the trigger, side button, pump, and gun shape itself. These features show the player what actions the controller is meant to perform. The feedback comes through cursor movement and clicking on screen. Together, these affordances support the theme of Buckshot Roulette by making the player’s actions feel more physical, immersive, and connected to the game world. Open-ended peer review question: 

    What additional functionality would make this controller feel even more immersive without making it too complicated to use?

 

 

-

#include <Adafruit_CircuitPlayground.h>
#include <Adafruit_Circuit_Playground.h>
#include <Mouse.h>

// Pin Definitions
const int LIGHT_PIN = A1;
const int TRIGGER_PIN = A2;
const int RECENTER_PIN = A3;

// Constraints & Thresholds
const float ACCEL_THRESHOLD = 0.5; // Tilt Sensitivity / deadzone
const int LIGHT_DROP_LIMIT = 25;   // Light "Pump" lock sensitivity
const float SENSITIVITY = 2.0;    // Mouse speed multiplier
const int MAX_SPEED = 15;          // Max mouse movement per loop

float xOffset = 0;
float yOffset = 0;
float zOffset = 0;

// Variables
int lastLightLevel = 0; // The light level of the shotgun on the last tick
bool isLocked = false; // Determines whether or not the shotgun's cursor is locked

bool lastTriggerState = HIGH;
bool lastRecenterState = HIGH;

const int LIGHT_THRESHOLD = 1000;
bool lightReady = true;            // ensures one toggle per pump

int delayedLight = 0;
unsigned long lastLightUpdate = 0;
const unsigned long LIGHT_DELAY_MS = 500;

unsigned long lastLockTime = 0;
const unsigned long LOCK_COOLDOWN_MS = 1000; // 1s before next toggle

unsigned long lastClickTime = 0;
const unsigned long CLICK_COOLDOWN_MS = 1000; // 1s before next click

bool isArmed = false;

void setup() {
  CircuitPlayground.begin();
  Mouse.begin();

  pinMode(TRIGGER_PIN, INPUT_PULLUP);
  pinMode(RECENTER_PIN, INPUT_PULLUP);

  lastRecenterState = digitalRead(RECENTER_PIN);

  lastLightLevel = CircuitPlayground.lightSensor();

  Serial.begin(115200);

  delay(1000); // time to hold still

  // Calibrate accelerometer baseline
  float sumX = 0, sumY = 0, sumZ = 0;
  for (int i = 0; i < 50; i++) {
    sumX += CircuitPlayground.motionX();
    sumY += CircuitPlayground.motionY();
    sumZ += CircuitPlayground.motionZ();
    delay(10);
  }
  xOffset = sumX / 50.0;
  yOffset = sumY / 50.0;
  zOffset = sumZ / 50.0;

  Serial.println("Calibration complete.");
}

void loop() {
  // Do nothing if the CPE is switched off
  if (!CircuitPlayground.slideSwitch()) {
    delay(20);
    return;
  }

  // Do nothing until recenter button is pressed once
  if(!isArmed) {
    // Still monitor recenter button so we can arm
    bool currentRecenterState = digitalRead(RECENTER_PIN);
    if (lastRecenterState == HIGH && currentRecenterState == LOW) {
        isArmed = true;
        Serial.println("System Armed");
        delay(300); // small debounce
    }
    lastRecenterState = currentRecenterState;
    return;
  }

  // Read current light level
  int currentLight = analogRead(LIGHT_PIN);

  //Serial.print("Current Light: "); Serial.println(currentLight);
  //Serial.print(" | Delayed Light: "); Serial.println(delayedLight);

  // Pump lock using threshold + hysteresis
  if (currentLight > LIGHT_THRESHOLD && lightReady &&
    millis() - lastLockTime >= LOCK_COOLDOWN_MS) {

    isLocked = !isLocked;
    lastLockTime = millis();
    lightReady = false;

    Serial.println("Pump Lock toggled!");
  }

  // Reset trigger once light goes back up
  if (currentLight < LIGHT_THRESHOLD) {
      lightReady = true;
  }

  // Update delayed light every 0.5 seconds
  if (millis() - lastLightUpdate >= LIGHT_DELAY_MS) {
      delayedLight = currentLight;
      lastLightUpdate = millis();
  }

  lastLightLevel = currentLight;


  // Handle Movement (Accelerometer)
  if (!isLocked) {
    float rawX = CircuitPlayground.motionX(); // left/right tilt
    float rawY = CircuitPlayground.motionY(); // forward/back tilt
    float rawZ = CircuitPlayground.motionZ(); // vertical (gravity)

    // Remove gravity baseline
    float xAccel = (rawZ - zOffset);  // left/right
    float yAccel = -(rawX - xOffset);  // forward/back

    int moveX = 0;
    int moveY = 0;

    // Left / Right
    const float X_CENTER = 10.0;

    float xRelative = xAccel;

    if (abs(xAccel) > ACCEL_THRESHOLD) {
      moveX = -xRelative * SENSITIVITY;
    }
    else {
      moveX = 0;
    }

    // Up / Down
    if (abs(yAccel) > ACCEL_THRESHOLD) {
      moveY = yAccel * SENSITIVITY;
    }
    else {
      moveY = 0;
    }

    moveX = constrain(moveX, -MAX_SPEED, MAX_SPEED);
    moveY = constrain(moveY, -MAX_SPEED, MAX_SPEED);

    Mouse.move(moveX, moveY);;

    //Serial.print("xAccel: "); Serial.print(xAccel);
    //Serial.print(" moveX: "); Serial.print(moveX);
    //Serial.print(" xRelative: "); Serial.println(xRelative);
    //Serial.print(" yAccel: "); Serial.print(yAccel);
    //Serial.print(" moveY: "); Serial.println(moveY);
  }

  // Handle Shooting (Trigger Button)
  bool currentTriggerState = digitalRead(TRIGGER_PIN);
  if (lastTriggerState == HIGH && currentTriggerState == LOW) {
    if (millis() - lastClickTime >= CLICK_COOLDOWN_MS) {
      // Button just pressed
      Mouse.click(MOUSE_LEFT);
      Serial.println("Mouse Click!");
      lastClickTime = millis();
      isLocked = false; // unlock
    }
  }
  lastTriggerState = currentTriggerState;

  // Handle Recentering
  const int CORNER_PUSH = 200;   // Movement Per step
  const int CORNER_STEPS = 20;   // Number of steps, keep high to ensure you hit the edge
  // Assumes a 1920 x 1080 screen resolution
  const int CENTER_X = 960;      // half of screen size, 1920
  const int CENTER_Y = 540;      // half of screen size, 1080
  // Step size for moving toward center
  const int STEP_SIZE = 10;

  bool currentRecenterState = digitalRead(RECENTER_PIN);
  if (lastRecenterState == HIGH && currentRecenterState == LOW) {

      Serial.println("Recalibrating... Hold still");

      // Small delay so you can stabilize
      delay(500);

      float sumX = 0, sumY = 0, sumZ = 0;
      for (int i = 0; i < 50; i++) {
          sumX += CircuitPlayground.motionX();
          sumY += CircuitPlayground.motionY();
          sumZ += CircuitPlayground.motionZ();
          delay(5);
      }

      xOffset = sumX / 50.0;
      yOffset = sumY / 50.0;
      zOffset = sumZ / 50.0;

      // FORCE cursor to top-left corner
      for (int i = 0; i < CORNER_STEPS; i++) {
          Mouse.move(CORNER_PUSH, CORNER_PUSH);
          delay(2);
      }

      delay(20); // small pause to settle

      // Move to center more precisely
      for (int x = 0; x < CENTER_X; x += STEP_SIZE) {
          Mouse.move(STEP_SIZE, 0);
          delay(1);
      }

      for (int y = 0; y < CENTER_Y; y += STEP_SIZE) {
          Mouse.move(0, STEP_SIZE);
          delay(1);
      }

      Serial.println("Recentered + Recalibrated");
  }
  lastRecenterState = currentRecenterState;

  lastLightLevel = currentLight;
  //delay(20);
}
 
 

 

  






 

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.