Contact meContact megiedrius@tamulaitis.lt

Dialrhea - play Doom on rotary phone

Dialrhea is a modified rotary phone that has been repurposed to control the classic first-person shooter game Doom via Bluetooth. We built it in two days during the “Internet Of Shit” hackathon organized by Technarium crew in Vilnius, Lithuania. The theme of this hackathon was to build devices that are completely useless, but fully functional. Here is Dialrhea in action:

Watch Dialrhea in action: https://www.youtube.com/watch?v=YNjw6ZKlyNM

During the same weekend we also made a “slick” promotional video demonstrating capabilities of this revolutionary shitty machine.

Watch Dialrhea promotional video: https://www.youtube.com/watch?v=RVSE1t3wHlI

What’s next?

I would like to host a workshop where we would build at least three more devices, and then play a Doom death match using these devices. Ideal setting would be some hacker camp or similar event. If you have ideas how to make it happen, drop me a line.

The Story

The device was built during “Internet Of Shit” hackathon. It’s an amazing event that was hosted by Technarium crew in Vilnius, Lithuania in 2017 and 2018. Award categories included the likes of “Fire Means It’s Working”, “Least Private On-line Gadget”, “This is Likely Illegal”, and the teams were competing hard to turn these concepts into a working piece of technology.

Our device won the “Least Shitty project” award, and we were also awarded the “Public Prize” for the most popular project. It is worth mentioning that the prize was a rubber drainage cleaning pump sprayed with gold paint.

The ingenious idea was generated after a few beers with Donatas Valiulis and Džiugas Bartkus. The tree of us carried out the whole operation with me and Donatas taking care of technical aspects and Džiugas responsible for video production and promotional materials.

Džiugas also made a nice trippy video about the making of the device and the whole hackathon in general.

Watch Dialrhea making of video: https://www.youtube.com/watch?v=nAaBJcYIFyU

There were lots of other absurd projects built during the event, if you are interested, here is the full one hour long presentation of all projects of “Internet of Shit 2017” including Dialrhea.

Appearances

In addition to the hackathon, the device was also a guest of honor in numerous events including Maker Faire 2017 at The Energy and Technology Museum, No Trolls Allowed hacker camp, Vilnius Tech Park birthday, Braille Satellite music festival and others.

Vilnius Tech Park birthday 2018

I spent quite some time in a co-working space in a newly opened Sapiegos Tech Park, a start-up / scale-up infested complex of old hospital buildings in a middle of a beautiful park in the center of the city. An amazing place where I met a lot of interesting people, attended many events and heard a tons of interesting business ideas being pitched near the kitchen sink. When Sapiegos Tech Park celebrated their second birthday I offered to fire up Dialrhea as an interactive installation.

There were many interesting people from business and tech world there. In a picture above you can see well known Lithuanian entrepreneur Valdas Lašas testing out Dialrhea and enjoying it. Unfortunately, he didn’t end up investing in this revolutionary shitty machine, probably because being old-school he didn’t understand how to use this high tech gadget properly and instinctively tried putting the phone to his ear, which obviously gained no results.

Braille Satellite music festival 2018

On the other side of the spectrum was Dialrhea’s appearance in the Braille Satellite music festival. Taking place in an amazing Mushroom Manor in a middle of Lithuanian countryside it’s a small and cozy festival for the weirder side of electronic music.

Instead of entrepreneurs this time Dialrhea was battle tested with a bunch of ravers, hipsters, musicians and people who haven’t slept for three days.

Technical details

The device is built on Arduino, and uses Bluetooth LE to communicate to the computer wirelessly. It presents itself as bluetooth keyboard and can be paired with any device supporting Bluetooth LE. Below you can see the breakdown of components powering Dialrhea.

Insides of Dialrhea

Although the ultimate use case for the device ended up being playing Doom, device actually has full support for multiple modes of operation:

  • Doom - in this mode the device acts as a game controller, and is configured to control classic Doom game (using Doomsday Engine)
  • Emoji - this mode is best used with mobile phones and allows you to type emoji’s and send them to your friends
  • Boring - in this mode Dialrhea just outputs the dialed numbers (not recommended)

Reading data from rotary dial

The most interesting learning during the process was how the rotary dial actually works from technical point of view. I am from the generation who still remember using rotary phones so it was really interesting to understand both how simple the mechanism actually is and why I was getting electric shock while touching phone wires and playing phone mechanic only sometimes and not all the time. If you are interested here is the video explaining the mechanism .

After understanding how the mechanism works implementing it with Arduino was pretty much straight forward and I don’t remember having any major troubles with it.

Fighting bluetooth

Probably the biggest challenge was to make bluetooth communication work properly. We chose to use Adafruit Bluefruit LE UART Friend module for the job just because I had it lying around and also I already tried to work with it while working on another project. It is a very capable module, but most problems we had were around stability and reliability. Sometimes things worked well, sometimes we were getting some errors running exactly the same code. We read lots of documentation about how to follow proper handshake protocols, do pairing correctly, etc., but then ended up just adding retry loops and timeouts everywhere so the chip has time to “come to it’s senses” after each risky operation. You can see full source code for Dialrhea below.

Click to enable scrolling Tap to enable scrolling
/***************************************************************************
These are the brains of Dialrhea - revolutionary shitty machine, written in
few hours during Internet Of Shit Hackathon 2017 at Technariumas, Vilnius,
Lithuania, so expect the code to be pretty shitty.

Author: Giedrius Tamulaitis, giedrius@tamulaitis.lt
Version: 1.0

This code is for Adafruit nRF51822 based Bluefruit LE modules
https://learn.adafruit.com/introducing-the-adafruit-bluefruit-le-uart-friend
***************************************************************************/

#include <Arduino.h>
#include <SPI.h>
#if not defined (_VARIANT_ARDUINO_DUE_X_) && not defined(ARDUINO_ARCH_SAMD)
  #include <SoftwareSerial.h>
#endif
#include "Adafruit_BLE.h"
#include "Adafruit_BluefruitLE_UART.h"
#include "BluefruitConfig.h"

#define DEVICE_NAME "Dialrhea"

// Rotary dial input PIN
#define ROTARY_PIN 2
// Handset input PIN
#define HANDSET_PIN 3
// Operation mode potentiometer PIN
#define OPERATION_MODE_PIN A5

// How long to wait before sending keyup message for control keys in Gaming mode
#define CONTROL_KEY_HOLD_DURATION 200
// How long to wait before sending keyup message for fire button in Gaming mode
#define FIRE_KEY_HOLD_DURATION 200
// How long to wait before sending keyup message for keys that are supposed
// to be just one clicks
#define INSTANT_KEY_HOLD_DURATION 10

// Pins for status LED RGB legs
#define STATUS_LED_RED_PIN 4
#define STATUS_LED_GREEN_PIN 6
#define STATUS_LED_BLUE_PIN 5

// Constants for colors
#define COLOR_OFF 0
#define COLOR_RED 1
#define COLOR_GREEN 2
#define COLOR_BLUE 3

// Total number of keys that support timed presses
#define KEY_COUNT 14

// Index of handset button data in data arrays (Gaming mode, we need two because
//we are sending keys for both fire and open door)
#define KEY_GAMING_MODE_HANDSET_1_INDEX 10
#define KEY_GAMING_MODE_HANDSET_2_INDEX 11
// Index of handset button data in data arrays (Emoji mode)
#define KEY_EMOJI_MODE_HANDSET_INDEX 12
// Index of handset button data in data arrays (Boring mode)
#define KEY_BORING_MODE_HANDSET_INDEX 13

// Map of values for each type of dialed number and handset click
const int keyValues[KEY_COUNT] = {
  0x42, // Number 0 in gaming mode (Currently quick load)
  0x52, // Number 1 in gaming mode (Currently "up" arrow)
  0x4F, // Number 2 in gaming mode (Currently "right" arrow)
  0x50, // Number 3 in gaming mode (Currently "left" arrow)
  0x51, // Number 4 in gaming mode (Currently "down" arrow)
  0x2A, // Number 5 in gaming mode (Currently ?, next weapon)
  0x00, // Number 6 in gaming mode
  0x00, // Number 7 in gaming mode
  0x00, // Number 8 in gaming mode
  0x00, // Number 9 in gaming mode
  0x10, // Handset click in gaming mode (KEY_GAMING_MODE_HANDSET_1_INDEX) (Currently space)
  0x2C, // Handset click in gaming mode (KEY_GAMING_MODE_HANDSET_2_INDEX) (Currently 'm')
  0x28, // Handset click in emoji mode (KEY_EMOJI_MODE_HANDSET_INDEX) (Currently Enter)
  0x29  // Handset click in boring mode (KEY_BORING_MODE_HANDSET_INDEX) (Currently Esc)
};

// Durations for each type of mey (mapping the same as for keyValues array)
const int keyHoldDurations[KEY_COUNT] = {
  INSTANT_KEY_HOLD_DURATION, 
  CONTROL_KEY_HOLD_DURATION, 
  CONTROL_KEY_HOLD_DURATION,
  CONTROL_KEY_HOLD_DURATION,
  CONTROL_KEY_HOLD_DURATION,
  INSTANT_KEY_HOLD_DURATION,
  INSTANT_KEY_HOLD_DURATION,
  INSTANT_KEY_HOLD_DURATION,
  INSTANT_KEY_HOLD_DURATION,
  INSTANT_KEY_HOLD_DURATION,
  FIRE_KEY_HOLD_DURATION,
  FIRE_KEY_HOLD_DURATION,
  INSTANT_KEY_HOLD_DURATION,
  INSTANT_KEY_HOLD_DURATION
};

// Array for storing times each key was pressed
unsigned long keyPressTimes[KEY_COUNT];
// Array for storing states for each key
bool keyPressStates[KEY_COUNT];

// Variables required for handling input from rotary dial
int rotaryHasFinishedRotatingTimeout = 100;
int rotaryDebounceTimeout = 10;
int rotaryLastValue = LOW;
int rotaryTrueValue = LOW;
unsigned long rotaryLastValueChangeTime = 0;
bool rotaryNeedToEmitEvent = 0;
int rotaryPulseCount;

// Operation modes
#define OPERATION_MODE_GAMING 0 // Gaming controls fine tuned for the best game of all times: "Doom"
#define OPERATION_MODE_EMOJI 1 // Emojis + Enter
#define OPERATION_MODE_BORING 2 // Numbers + Esc

// Current operation mode
int operationMode;

// Emojis for each dialed number
const char* emojis[] = {":-O", ":poop:",  ":-)", ":-(", ":-D", ":-\\", ";-)", ":-*", ":-P", ">:-("};

// Variables for handling handset clicker button
bool isHandsetPressed = false;
unsigned long handsetPressStartTime = 0;
unsigned long handsetPressStartTimeout = 60;

// Variable that determines weather the state of keys changed during processing of the loop (so we
// can send commands just once in the end of the loop if it is needed)
bool keyPressStateChanged;

// Config settings for Bluetooth LE module
#define FACTORYRESET_ENABLE         0
#define VERBOSE_MODE                false  // If set to 'true' enables debug output
#define MINIMUM_FIRMWARE_VERSION    "0.6.6"
#define BLUEFRUIT_HWSERIAL_NAME      Serial1

// Bluetooth LE module object
Adafruit_BluefruitLE_UART ble(BLUEFRUIT_HWSERIAL_NAME, BLUEFRUIT_UART_MODE_PIN);

void setup(void) {
  pinMode(ROTARY_PIN, INPUT);
  pinMode(HANDSET_PIN, INPUT_PULLUP);
  pinMode(STATUS_LED_RED_PIN, OUTPUT);
  pinMode(STATUS_LED_GREEN_PIN, OUTPUT);
  pinMode(STATUS_LED_BLUE_PIN, OUTPUT);

  setStatusLEDColor(COLOR_GREEN);

  // Wait while serial connection is established (required for Flora & Micro or when you want to
  // halt initialization till you open serial monitor)
  // while (!Serial);
  
  // Give some time for chip to warm up or whatever
  delay(1000);

  initializeSerialConnection();
  initializeBLEModule();

  // Delay a bit because good devices always take some time to start
  delay(100);

  setStatusLEDColor(COLOR_BLUE);
}

void loop(void) {
  keyPressStateChanged = false;
  refreshOperationMode();
  handleHandset();
  handleRotary();
  processKeyUps();

  // If state of pressed keys changed - send the new state
  if (keyPressStateChanged)
    sendCurrentlyPressedKeys();
}

// Sets the color of status LED
void setStatusLEDColor(int colorID) {
  digitalWrite(STATUS_LED_RED_PIN, colorID == COLOR_RED ? HIGH : LOW);
  digitalWrite(STATUS_LED_GREEN_PIN, colorID == COLOR_GREEN ? HIGH : LOW);
  digitalWrite(STATUS_LED_BLUE_PIN, colorID == COLOR_BLUE ? HIGH : LOW);
}

// Outputs error message and bricks the revolutionary shitty machine
void error(const __FlashStringHelper*err) {

  setStatusLEDColor(COLOR_RED);
  
  Serial.println(err);
  while (1);
}

// Blinks the status LED (only green supported for now)
void blink() {
  setStatusLEDColor(COLOR_OFF);
  delay(100);
  setStatusLEDColor(COLOR_GREEN);
}

// Opens serial connection for debugging
void initializeSerialConnection() {
  Serial.begin(9600);
  Serial.println(F("Hello, I am the Dialrhea! Ready for some dialing action?"));
  Serial.println(F("8-------------------------------------D"));
}

// Initializes Bluetooth LE module
void initializeBLEModule() {
  // Buffer for holding commands that have to be sent to BLE module
  char commandString[64];

  setStatusLEDColor(COLOR_GREEN);

  Serial.print(F("Initialising the Bluefruit LE module: "));
  if (!ble.begin(VERBOSE_MODE)) error(F("Couldn't find Bluefruit, make sure it's in CoMmanD mode & check wiring?"));
  Serial.println( F("Easy!") );

  blink();

  if (FACTORYRESET_ENABLE)
  {
    Serial.println(F("Performing a factory reset: "));
    if (!ble.factoryReset()) error(F("Couldn't factory reset. Have no idea why..."));
    Serial.println(F("Done, feeling like a virgin again!"));
  }

  blink();

  // Disable command echo from Bluefruit
  ble.echo(false);

  blink();

  Serial.println("Requesting Bluefruit info:");
  ble.info();

  blink();

  // Change the device name so the whole world knows it as Dialrhea
  Serial.print(F("Setting device name to '"));
  Serial.print(DEVICE_NAME);
  Serial.print(F("': "));
  sprintf(commandString, "AT+GAPDEVNAME=%s", DEVICE_NAME);
  if (!ble.sendCommandCheckOK(commandString)) error(F("Could not set device name for some reason. Sad."));
  Serial.println(F("It's beautiful!"));

  blink();

  Serial.print(F("Enable HID Service (including Keyboard): "));
  strcpy(commandString, ble.isVersionAtLeast(MINIMUM_FIRMWARE_VERSION) ? "AT+BleHIDEn=On" : "AT+BleKeyboardEn=On");
  if (!ble.sendCommandCheckOK(commandString)) error(F("Could not enable Keyboard, we're in deep shit..."));
  Serial.println(F("I'm now officially a keyboard!"));

  blink();

  // Make software reset (add or remove service requires a reset)
  Serial.print(F("Performing a SW reset (service changes require a reset): "));
  if (!ble.reset()) error(F("Couldn't reset?? Lame."));
  Serial.println(F("Baby I'm ready to go!"));
  Serial.println();
}

// Reads the position of operation mode select potentiometer and determines current operation mode
void refreshOperationMode() {
  operationMode = floor((float)analogRead(OPERATION_MODE_PIN) / 342.0);
}

// Handles tracking the handset state
void handleHandset() {
  // Ignore input until last action timeout passes (to filter out noise)
  if (millis() - handsetPressStartTime > handsetPressStartTimeout) {
    int ragelisCurrentValue = digitalRead(HANDSET_PIN);
    
    if (!isHandsetPressed && ragelisCurrentValue == HIGH) {
      isHandsetPressed = true;
      handsetPressStartTime = millis();
      onHandsetClicked();
    }

    else if (isHandsetPressed && ragelisCurrentValue == LOW) {
      isHandsetPressed = false;
      handsetPressStartTime = millis(); 
    }
  }
}

// Handles tracking of the rotary dial state
void handleRotary() {
  int rotaryCurrentValue = digitalRead(ROTARY_PIN);

  // If rotary isn't being dialed or it just finished being dialed
  if ((millis() - rotaryLastValueChangeTime) > rotaryHasFinishedRotatingTimeout) {
    // If rotary just finished being dialed - we need to emit the event
    if (rotaryNeedToEmitEvent) {
      // Emit the event (we mod the count by 10 because '0' will send 10 pulses)
      onRotaryNumberDialed(rotaryPulseCount % 10);
      rotaryNeedToEmitEvent = false;
      rotaryPulseCount = 0;
    }
  }

  // If rotary value has changed - register the time when it happened
  if (rotaryCurrentValue != rotaryLastValue) {
    rotaryLastValueChangeTime = millis();
  }

  // Start analyzing data only when signal stabilizes (debounce timeout passes)
  if ((millis() - rotaryLastValueChangeTime) > rotaryDebounceTimeout) {
    // This means that the switch has either just gone from closed to open or vice versa.
    if (rotaryCurrentValue != rotaryTrueValue) {
      // Register actual value change 
      rotaryTrueValue = rotaryCurrentValue;

      // If it went to HIGH - increase pulse count
      if (rotaryTrueValue == HIGH) {
        rotaryPulseCount++; 
        rotaryNeedToEmitEvent = true;
      } 
    }
  }

  // Store current value as last value
  rotaryLastValue = rotaryCurrentValue;
}

// Event handler triggered when click of the handset button is registered
void onHandsetClicked() {
  // Register state changes for handset button keys depending on the mode
  if (operationMode == OPERATION_MODE_GAMING) {
    if (keyPressStates[KEY_GAMING_MODE_HANDSET_1_INDEX] == false || keyPressStates[KEY_GAMING_MODE_HANDSET_2_INDEX] == false)
      keyPressStateChanged = true;
  
    keyPressStates[KEY_GAMING_MODE_HANDSET_1_INDEX] = true;
    keyPressTimes[KEY_GAMING_MODE_HANDSET_1_INDEX] = millis();
  
    keyPressStates[KEY_GAMING_MODE_HANDSET_2_INDEX] = true;
    keyPressTimes[KEY_GAMING_MODE_HANDSET_2_INDEX] = millis();
  } else if (operationMode == OPERATION_MODE_EMOJI) {
    if (keyPressStates[KEY_EMOJI_MODE_HANDSET_INDEX] == false)
      keyPressStateChanged = true;
  
    keyPressStates[KEY_EMOJI_MODE_HANDSET_INDEX] = true;
    keyPressTimes[KEY_EMOJI_MODE_HANDSET_INDEX] = millis();
  } else if (operationMode == OPERATION_MODE_BORING) {
    if (keyPressStates[KEY_BORING_MODE_HANDSET_INDEX] == false)
      keyPressStateChanged = true;
  
    keyPressStates[KEY_BORING_MODE_HANDSET_INDEX] = true;
    keyPressTimes[KEY_BORING_MODE_HANDSET_INDEX] = millis();
  }
}

// Event handler triggered when number was dialed on rotary dial
void onRotaryNumberDialed(int number) {
  if (operationMode == OPERATION_MODE_GAMING) {
    // Set key state for dialed key
    if (keyPressStates[number] == false)
      keyPressStateChanged = true;
  
    keyPressStates[number] = true;
    keyPressTimes[number] = millis();
  } else if (operationMode == OPERATION_MODE_EMOJI) {
    // Send emoji to happy device
    sendCharArray(emojis[number]);
  } else if (operationMode == OPERATION_MODE_BORING) {
    // Form string from number and send it to device
    char numberString[1];
    sprintf(numberString, "%d", number);
    sendCharArray(numberString);
  }
}

// Sends raw command to BLE module and prints debug output
void sendBluetoothCommand(char *commandString) {
  setStatusLEDColor(COLOR_OFF);

  Serial.print(commandString);

  ble.println(commandString);
  if (ble.waitForOK()) {
    Serial.println(F(" <- OK!")); 
    setStatusLEDColor(COLOR_BLUE);
  }
  else {
    Serial.println(F(" <- FAILED!"));
    setStatusLEDColor(COLOR_RED);
  };
}

// Sends char array (string) to BLE module
void sendCharArray(char* charArray) {
  char commandString[64];
  sprintf(commandString, "AT+BleKeyboard=%s", charArray);
  sendBluetoothCommand(commandString);
}

// Checks which keys are currently pressed and sends keycodes to BLE module
void sendCurrentlyPressedKeys() {
  char commandString[64] = "AT+BleKeyboardCode=00-00";

  for (int i=0; i<KEY_COUNT; i++) {
    if (keyPressStates[i] == true && keyValues[i] != 0x00) {
      sprintf (commandString, "%s-%02X", commandString, keyValues[i]);
    }
  }

  sendBluetoothCommand(commandString);
}

// Process timers to detect when keyup messages have to be sent for each key
void processKeyUps() {
  for (int i=0; i<KEY_COUNT; i++) {
    if (keyPressStates[i] == true) {
      if (millis() - keyPressTimes[i] > keyHoldDurations[i]) {
        keyPressStates[i] = false;
        keyPressStateChanged = true;
      }
    }
  }
}

There is actually a very interesting bug in this device that allows player to “leap” forward very quickly. I have seen it happening several times, usually after somebody was frantically bashing the device for a while. I literally have no idea why it happens or how to reproduce it, but I actually find it really cool, so I decided to just leave it in and call it a feature.

Doom

It is no coincidence that classic Doom was chosen as a subject to be controlled by Dialrhea. I absolutely love the game and was obsessed by it while growing up. My father brought it back from his work one day in like 10 floppy disks. I had to learn how to write custom autoexec.bat and config.sys files and boot the system up from special floppy disk containing minimal version of MS-DOS and highly optimized mouse driver so there is just enough memory to run Doom on my Intel 386 33Mhz machine that only had 4Mb of RAM if I remember correctly. "LH A:\MOUSE.COM" was the magic line that told DOS to load mouse driver into otherwise unreachable high memory therefore winning some additional kilobytes of RAM for Doom.

Apparently I was not the only one obsessed by Doom, many years later that I found out that Doom and it’s creators “id Software” actually redefined the PC gaming market and were first to introduce shareware distribution model successfully. At one point more personal computers had Doom than Windows 95 operating system installed. There is a great book “Masters of Doom” by David Kushner that tells the story of “id Software” with lots of incredible details, highly recommended read. This YouTube video also paints quite a nice picture about the innovation and drama inside “id Software”. I still think that Doom is the best game that was ever released.

Doom running on everything

One more reason to choose Doom to be controlled with rotary phone was that Doom is notorious for being able to run on pretty much any hardware. Well, actually crazy Doom fans are notorious for making Doom run on pretty much any hardware. Doom was made to run on calculators, microwaves, treadmills, traffic ticket validators, vapes and even pregnancy tests! If the device has a CPU and the screen, there will be some geek trying to run Doom on that machine. Subreddit r/itrunsdoom has the most impressive collection of devices running Doom that I’ve seen so far.

Doom running on everything

One of the more interesting projects was one guy running Doom on scientific calculator powered by ~800 potatoes ! Yes, potatoes! Compared to this our Dialrhea project doesn’t seem so crazy.

Competition

Apparently we were not the only ones willing to waste our time building ridiculous machine like this. Good five years after our attempt somewhere in Japan the guy named Yoshino built similar device. I have to admit his marketing campaign was way more successful than ours and the video went viral, being covered by major gaming outlets like PC Gamer, IGN and others and even provoking a tweet from John Romero (one of the people who created original Doom) himself.

John Romero's tween about rotary phone

Compared to Dialrhea, Yoshino’s device doesn’t utilize handset at all forcing you to dial 1 to shoot (which is way less fun than frantically mashing the handset). Also you need to connect it to the computer via cable while Dialrhea is fully wireless and supports Bluetooth LE. Just another example how technologically superior solution doesn’t always win the market, marketing does.