Final Project: Geo-locked Puzzle Box

I’m building a box that only opens at a specific location on Earth. When the button is pressed, it displays how far you are from the correct spot.

Idea

The goal is to create a location-aware box that can detect your current position via GPS. It remains locked until you're physically standing at a pre-defined location. The box will contain a button: pressing it will show the distance to the target spot. Once you're close enough (within a few meters), it will unlock and open.

Implementation

First of all I started testing individual components one by one. I wrote many tiny sketches just to make sure every part would light up, beep or print something. From the very beginning I suspected power distribution would be a headache, because I refused to put any built-in battery inside the box.

My first idea was to use an electromagnetic lock (GM202-UP) powered from the single 9 V battery that the person who finds the box has to bring along.

Because the lock needs 24 V I added a step-up converter and a little MOSFET switch.

However, the coil was never powerful enough to actuate and it dragged the rest of the circuit down with it. I tried sprinkling in some electrolytic capacitors which gave the coil a small kick at the start, but still not enough. The lock would happily stay open once I helped it with a finger, yet it could not pull in by itself.

Here is a video showing the problem:

Here is the test wiring again:

After fighting the electromagnet for far too long I switched to a servo. I redesigned the locking mechanism and did a couple of quick tests:

Unfortunately every time the servo moved it still upset the other modules, so I added a second 9 V battery just for the servo (a common trick with SG-90s). Here is the final wiring diagram:

And here is the same circuit in real life:

I measured every component and designed the smallest possible enclosure in Fusion 360:

Next I laser-cut the parts from 3 mm plywood, glued them together, and fixed the battery holder wires by cutting another jumper in half and soldering the ends:

I tested locking and unlocking with this quick sketch to find the two servo angles:

Copied!
#include <Servo.h>

const int servoPin = 9;     // Servo signal connected to D9
const int angle1 = -10;       // Locked
const int angle2 = 20;      // Unlocked
const int delayTime = 1000; // Delay between movements in milliseconds

Servo myServo;

void setup() { myServo.attach(servoPin); }

void loop() {
  myServo.write(angle1);
  delay(delayTime);
  myServo.write(angle2);
  delay(delayTime);
}

I slowly started putting all components into the box and uploaded the code to the Arduino. Here are two pictures of the build in progress:

The final full code is saved in final_project.ino and looks like this:

Copied!
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <SoftwareSerial.h>
#include <TinyGPS++.h>
#include <Servo.h>

// --- LCD + I/O
LiquidCrystal_I2C lcd(0x27, 16, 2);
const int lcdWidth = 16;
const int buttonPin = 2;
const int buzzerPin = 4;

Servo lockServo;
const int servoPin = 6;

// --- GPS Setup
SoftwareSerial gpsSerial(5, 3); // RX, TX, connect RX of GPS to pin 3, TX to pin 5
TinyGPSPlus gps;

// --- Unlock Target
const double targetLat = 40.7110633673; // 40.7110633673
const double targetLon = 13.8544947609; // 13.8544947609
const unsigned long unlockRadius = 50; // meters
const int lockAngle = -10;
const int unlockAngle = 20;

// --- Flags + Timing
bool ignoreButton = false;
bool signalFound = false;
unsigned long signalStartTime = 0;
unsigned long lastAnimTime = 0;
int animFrame = 0;

// ---------- Centering Helper ----------
String centerText(String text, int lineLength) {
  if (text.length() > lineLength) return text.substring(0, lineLength);
  int spaces = (lineLength - text.length()) / 2;
  String padding = "";
  for (int i = 0; i < spaces; i++) padding += " ";
  return padding + text;
}

// ---------- Distance Formatter ----------
String formatDistance(unsigned long dist) {
  if (dist < 10) return "   " + String(dist) + " m";
  else if (dist < 100) return "  " + String(dist) + " m";
  else if (dist < 1000) return " " + String(dist) + " m";
  else if (dist < 10000) return String(dist) + " m";
  else if (dist < 100000) {
    String s = String(dist);
    return s.substring(0, 2) + "." + s.substring(2, 3) + "km";
  } else if (dist < 1000000) {
    return String(dist / 1000) + " km";
  } else if (dist < 10000000) {
    return String(dist / 1000) + "km";
  } else {
    return "9999km";
  }
}

// ---------- Display Functions ----------
void displayWelcome() {
  lcd.clear();
  lcd.setCursor(0, 0);
  lcd.print(centerText("Welcome!", lcdWidth));
  lcd.setCursor(0, 1);
  lcd.print(centerText("Click To Locate.", lcdWidth));
}

void displayAccessGranted() {
  lcd.clear();
  lcd.setCursor(0, 0);
  lcd.print(centerText("Access Granted!", lcdWidth));

  String frames[] = {
    "3", "3.", "3..", "3...",
    "3... 2", "3... 2.", "3... 2..", "3... 2...",
    "3... 2... 1", "3... 2... 1.", "3... 2... 1..", "3... 2... 1..."
  };
  for (int i = 0; i < 12; i++) {
    lcd.setCursor(0, 1);
    lcd.print(frames[i]);
    delay(250);
  }
  delay(500);
}

void displayOpening() {
  lcd.clear();
  lcd.setCursor(0, 0);
  lcd.print(centerText("Box Unlocked.", lcdWidth));
  lcd.setCursor(0, 1);
  lcd.print(centerText("Quick!", lcdWidth));
}

void displayDenied(unsigned long dist) {
  lcd.clear();
  lcd.setCursor(0, 0);
  lcd.print(centerText("Access Denied!", lcdWidth));
  lcd.setCursor(0, 1);
  lcd.print(centerText("Distance: " + formatDistance(dist), lcdWidth));
}

void displayCongrats() {
  lcd.clear();
  lcd.setCursor(0, 0);
  lcd.print(centerText("Congratulations!", lcdWidth));
  lcd.setCursor(0, 1);
  lcd.print(centerText("Have a Nice Day.", lcdWidth));
}

// ---------- Buzzing ----------
void buzz(int seconds) {
  unsigned long endT = millis() + seconds * 1000UL;
  while (millis() < endT) {
    tone(buzzerPin, 880); delay(100);
    tone(buzzerPin, 988); delay(100);
  }

  noTone(buzzerPin);
}

void openLock() {
  displayOpening();

  lockServo.attach(servoPin);    // Enable servo
  lockServo.write(unlockAngle);  // Unlock
  delay(500);
  lockServo.detach();            // Stop signal

  buzz(3);                       // Play unlocking sound

  lockServo.attach(servoPin);    // Enable servo
  lockServo.write(lockAngle);    // Lock
  delay(500);
  lockServo.detach();            // Stop signal
}

// ---------- Haversine Distance ----------
double haversine(double lat1, double lon1, double lat2, double lon2) {
  const double R = 6371000;
  double dLat = radians(lat2 - lat1);
  double dLon = radians(lon2 - lon1);
  double a = sin(dLat / 2) * sin(dLat / 2) +
             cos(radians(lat1)) * cos(radians(lat2)) *
             sin(dLon / 2) * sin(dLon / 2);
  double c = 2 * atan2(sqrt(a), sqrt(1 - a));
  return R * c;
}

// ---------- Unlock Logic ----------
bool checkUnlockCondition(unsigned long &distanceOut) {
  signalFound = false;
  signalStartTime = millis();
  lastAnimTime = millis();
  animFrame = 0;

  lcd.clear();
  lcd.setCursor(0, 0);
  lcd.print(centerText("No Signal Found.", lcdWidth));
  lcd.setCursor(0, 1);
  lcd.print(centerText("Searching...", lcdWidth));

  String frames[] = { "Searching.  ", "Searching.. ", "Searching..." };

  // Wait for valid GPS fix
  const int sampleCount = 5;
  double latSum = 0;
  double lonSum = 0;
  int collected = 0;

  while (collected < sampleCount) {
    // Animate
    unsigned long now = millis();
    if (now - lastAnimTime >= 250) {
      lcd.setCursor(0, 1);
      lcd.print(centerText(frames[animFrame % 3], lcdWidth));
      animFrame++;
      lastAnimTime = now;
    }

    // Feed GPS from serial
    while (gpsSerial.available() > 0) {
      gps.encode(gpsSerial.read());
    }

    // Try to read a valid location
    if (gps.location.isUpdated() && gps.location.isValid()) {
      latSum += gps.location.lat();
      lonSum += gps.location.lng();
      collected++;
    }

  }

  // Average
  double avgLat = latSum / sampleCount;
  double avgLon = lonSum / sampleCount;

  gpsSerial.end(); // we're done using GPS for now

  distanceOut = haversine(avgLat, avgLon, targetLat, targetLon);
  return (distanceOut <= unlockRadius);
}

// ---------- Setup ----------
void setup() {
  Serial.begin(9600);
  lcd.init();
  lcd.noBacklight(); // Leave on -> lcd.backlight(); Leave off -> lcd.noBacklight(); // save power
  pinMode(buttonPin, INPUT_PULLUP);
  pinMode(buzzerPin, OUTPUT);
  digitalWrite(buzzerPin, LOW);
  displayWelcome();

  lockServo.attach(servoPin);    // Enable servo
  lockServo.write(lockAngle);    // Lock
  delay(500);
  lockServo.detach();            // Stop signal
  
  gpsSerial.begin(9600);
}

// ---------- Loop ----------
void loop() {
  // Always feed GPS
  while (gpsSerial.available()) {
    gps.encode(gpsSerial.read());
  }

  if (!ignoreButton && digitalRead(buttonPin) == LOW) {
    ignoreButton = true;

    unsigned long distance = 0;
    bool unlocked = checkUnlockCondition(distance);

    if (unlocked) {
      displayAccessGranted();
      openLock();
      displayCongrats();
    } else {
      displayDenied(distance);
    }

    delay(5000);
    displayWelcome();
    ignoreButton = false;
    gpsSerial.begin(9600);
  }
}

The result turned out to be pretty good!

I placed the destination far away on purpose, so reaching the box takes time, patience, and care. When you arrive at the lighthouse, there’s a quiet moment waiting, and something inside that might change everything.

List of components

These are all the parts I used to build the final box, including electronics, connectors, and modules.

Component Note / Link
9V Battery Holder BH-9VA
Dupont Wires all genders, all positions
LCD Display 16×2 (I²C) yellow-green backlight
GPS Module NEO-6M
Servo Motor SG-90 (9 g)
Push Button PBS-33B-G
Passive Buzzer simple melody output
Breadboard ZY-170-W
Arduino Nano clone with ATmega328P
Step-Down Converter XL4015 CC/CV

And here is one more picture of the finished box: