Skill Level: Beginner

I am sharing this because I am sure someone will find a great use for the things I am learning along the way: how do you maneuver a submarine? How do you simulate a torpedo? How do you play multiple sounds to accompany your simulation?


Recommended Hardware


Knowledge pre-requisites:

  • Knowledge of the Arduino IDE and how to invoke the Serial monitor
  • Customer library installation 
  • If you are using a Teensy, knowledge of the Teensy programmer installation
  • Imagination & Creativity



  1. Overview

    I was inspired by the Nasa Mission Control desk that Jeff Highsmith shared on Make: to create a submarine simulator for my 9-year-old son. We started by building out a cardboard Submarine in our basement and what is missing are the gizmos, sound effects, lighting, etc. My son watched a video on YouTube of his favorite YouTuber (Papa Jake) who built a comprehensive submarine from boxes in his backyard. The goal in this project is to marry the two projects: get the mission control center into the cardboard submarine structure emerging in our basement…

    So – if the goal is to mimic the behavior of a submarine, how does one go about it? And where do you get all the audio files?

    First – I bought (for $10) a submarine simulation game called “Silent Hunter 5: Battle for the Atlantic”. I was able to find where the sound tracks are stored and copied the files (hundreds of them) to my working folder. Many of them are .ogg files so I will be transcoding them as I figure out the ones I want to work with. It is great – there are sonar sounds, ambient sounds and much more. If you want to find tracks the hard way, try freesound which has a vast free library of sound effects you can use (search for sonar, submarine, etc).

    Last but not least, for the handling of the submarine – I found an open source project called OpenSSN. This project offers the entire source code for a Unix-X based submarine simulation. I was able to extract the most important aspects of behavior for a submarine from this library.


    Screenshot taken from OpenSSN

    So there you have it – these are the components I pulled together and now I will share with you what I am building and you can accompany me. So come back to see my progress.

  2. About my hardware choices

    For the final build, I will be using a Teensy 3.6 (and Arduino-compatible platform) which features a 32 bit 180 MHz ARM Cortex-M4 processor with floating point unit. Full specs here. All digital and analog pins are 3.3 volts. I ordered a few boards from PJRC so in the meanwhile, I am developing on an older Teensy(the Teensy ++ 2.0).
    Teensy 3.6 by PJRC

    I am also going to use the Teensy Audio Adaptor which has a cool IBM relationship: they build a Node-Red based configurator to set up and connect features for the board. Try out the online configurator or watch the tutorial. When you download the free Teensyduino library they include the entire node red configurator for use locally on your machine!

    The reason I choose the Teensy is that of the form factor and the advanced libraries the folks at PJRC published, as well as this awesome audio board of theirs. I will be connecting hundreds of wires, switches, 7-segment LEDs and controllers so having such a nice powerful controlled for $30 is needed…



  3. The prototype

    I took all the knowledge imparted from the OpenSSN source code and built an initial prototype. Here’s a video of the code at work. Now that I have the navigation and engine simulation working I am ready to add superfluous LEDs, knobs and buttons…


  4. The Code

    The main loop is simple: I initialize my OLED display and attach the pushbutton lines to Bounce, a common Arduino library to manage the bounce-back of pushbuttons. It allows you to act on the “fall” event of a button push rather than relying on the status of the button read – that allows you to easily act just once on a button press.

    My main loop processes commands: identifying button presses or the status of the potentiometer and determining changes to the target speed, heading or depth. The SubHandeling is where the magic happens

    void setup() {
      Serial.print ("Started...");
      setupInput (BTNFASTER_PIN, btnFaster);
      setupInput (BTNSLOWER_PIN, btnSlower);
      setupInput (BTNUP_PIN, btnUp);
      setupInput (BTNDOWN_PIN, btnDown);
    void loop() {
    void setupInput (int InputPin, Bounce &bounceObject) {
      pinMode(InputPin, INPUT_PULLUP);


  5. Sub Handling

    Here is what the main navigation and controls for the sub do:


    void SubHandeling() {
      if (SubHandelingTimer.check() == 1) { // check if the metro has passed it's interval .
            // ----------------------------------------------
            // Change Depth
            // ----------------------------------------------
            float temp_speed = Speed;
            float delta_depth = 0.0;
            if (temp_speed < 0)   temp_speed = -temp_speed;
            if (DesiredDepth < 0)
                DesiredDepth = 0;
            else if (DesiredDepth > MaxDepth)
                DesiredDepth = MaxDepth;
            delta_depth = temp_speed * PLANES_CHANGE;
            if (delta_depth < 1.0)      delta_depth = 1.0;
            else if (delta_depth > 5.0) delta_depth = 5.0;
            if (DesiredDepth > Depth){  //Do we need to go up?
                Depth += delta_depth;
                if (Depth > DesiredDepth) Depth = DesiredDepth;     //Flatten us out
            if (DesiredDepth < Depth){  //Do we need to go down?
                Depth -= delta_depth;
                if (Depth < DesiredDepth) Depth = DesiredDepth;
            // ----------------------------------------------
            //Change Heading
            // ----------------------------------------------
            float AmountOfChange; //How much to turn the boat.
            AmountOfChange = (Rudder * temp_speed) * RUDDER_CHANGE;
            #if DEBUGMODE == 1
            //if (AmountOfChange>0.0) Myprintf ("Rudder change %f", AmountOfChange);
            if (Heading > DesiredHeading){
              if ((Heading - DesiredHeading) < 180){
                Heading = Heading - AmountOfChange;
                if ((Heading < DesiredHeading) &&
                ((DesiredHeading - Heading) < AmountOfChange)){
                  Heading = (float)DesiredHeading;
                Heading = Heading + AmountOfChange;
                if ((Heading > DesiredHeading) &&
                ((Heading - DesiredHeading) < AmountOfChange)){
                  Heading = (float)DesiredHeading;
              if (Heading < DesiredHeading){
                if ((DesiredHeading - Heading) < 180){
                  Heading += AmountOfChange;
                  if ((Heading > DesiredHeading) &&
                  ((Heading - DesiredHeading) < AmountOfChange)){
                    Heading = (float)DesiredHeading;
                  Heading = Heading - AmountOfChange;
                  if ((Heading < DesiredHeading) &&
                  ((DesiredHeading - Heading) < AmountOfChange)){
                    Heading = (float)DesiredHeading;
            if (Heading > 360)      Heading = Heading - 360.0;
            else if (Heading < 0)   Heading = Heading + 360.0;
            //Myprintf ("Heading: %f / %f", Heading, DesiredHeading);
            // ----------------------------------------------
            // Change Speed
            // ----------------------------------------------
            if (DesiredSpeed > Speed){ //Speed Up
              Speed += 0.45; //a little less than 1/2 a knot per second..
              if (Speed > DesiredSpeed) Speed = DesiredSpeed; // Did we go past target speed?
            if (Speed > DesiredSpeed){ //Slow Down
              Speed -= 0.45;
              if (Speed < DesiredSpeed) Speed=DesiredSpeed; // Did we slow too much?
            SubHandelingTimer = Metro(SubHandlingRate); // Reset the clock       
            EngineSpeed = abs(Speed) / MaxSpeed * 10;
        if (Speed>0 && WhatsPlayingNow == 1) {
          if (musicTrack.isPlaying()!= false ) musicTrack.stop(); //stop the main track
          WhatsPlayingNow = 2;
      if (ScreenRefreshTimer.check() == 1) {
              ScreenRefreshTimer = Metro(OLEDrefreshRate);
      if (fourDigitRefreshTimer.check() == 1) {
  6. Source Code

    Here is a link to the GitHub repository for the source code. Warning – this is still in development!


    Teensy Submarine Simulator

  7. Level Shifting (12v to 3.3)


    How do you handle an LED illuminated switch when it introduces a high voltage to your circuit?

    Here’s the problem: I did not want to use bear metal power supplies or anything which may introduce a fire risk since this circuit is housed inside a cardboard “submarine”. That means that to power the 12v LEDs around the panel I am using the same “brick” power supply as I am using to power my 5v or 3.3v applications (e.g. the microcontroller) using step-down converters. I didn’t think that would be a problem until I started wiring these really cool illuminated SPST switches I bought on Amazon.

    Unfortunately, the internal wiring of the LED connects the switch pole to the “+” side of the external power used to drive the LED. Give I am using a common ground for all circuitry this will create a conflict between the microcontroller inputs and the output from the switch and will send 12v directly to the microcontroller, thus frying it… So I need to convert the “ON/OFF” signal to a 3.3v TTL level acceptable to the microcontroller. Here is a circuit illustrating how to do this with BJT NPN transistors:


    You can see I opted to power the LED in the open side of the switch so the LED is always on. If I connected it to the other pole then I would get a 12v signal at all times. If you want a schematic of this switch check out this link. Each set of BJTs uses the 12v drive the second Transistor shut (off position) to generate 3.3v when the input is 12v high. This yields a 3.3V or 0 (short to ground) which is essentially a down-level shifter.

    Want to run an electronics simulator and test out the voltages on the input/output terminals? Check out my LIVE circuit view here.

    Try it out on a breadboard:


    I have made a limited amount of PCBs for this circuit so let me know if you would like to order one from me:



  8. Building the panels

    Similar to the above referenced NASA control center, I used Masonite to create my individual panels and a simple wood frame to connect them to each other. I used paper printouts as placeholder to make sure everything fits the way I want it. Also – you need to account for the size of your components (on the under side of the panel) because wiring them may place you too close to the frame or be difficult to space out when lots of switches and LEDs are finally hooked up.


    Here are a few photos of the work in progress



    The square arcade buttons are customized with labels I created by printing in color on a inkjet transparency. The fire buttons are visible on the right!



    This is a “work in progress” view of the various panels for the overall build


    Here is a closeup view of the wiring for the illuminated push buttons.

  9. Infrared Remote Control Lighting

    For interior ambiance, I am using an 8ft LED string. Because of the fire hazard (operating within a cardboard structure) I do not want to introduce any 110v or high power electronics. I also do not want to directly tie high power LED strips to the microcontroller (via MOSFET or Transistors). So a workaround is to use an off-the-shelf LED strip like the one I purchased at Home Depot (you can use any of the easily available ones). I then couple the IR receiver with a small Infrared LED controlled by the Microcontroller. The IR receiver for this particular model is on a 5″ cable mounted directly on the power supply. This allows me to mount the IR received outside the cardboard “submarine” and thread the received into the box. I will adhere the LED strip to the middle of the “ceiling” to light up the interior. 

    I can then change the ambiance programmatically: turn the light to red when in dive mode and choose one of the light effects when in “danger” or under attack, etc. And turn it on toe bright white when entering or exiting the “simulator”.

    I picked up the LED Ribbon kit from my local Home Depot store:


    To add this to your Arduino (or Teensy) project you need the following:

    1. An IR Receiver and LED (transmitter). You can buy them cheaply on Amazon.

    3. Install the IR library for your Arduino (or Teensy): Follow the instructions to install the Arduino IR library. If you are using the Teensy library then visit the PJRC IR page for more information. NOTE: there is a conflit between the IR library and the default arduino library RobotIRRemote. You might need to delete the RobotIRRemote library to let the IR library operate properly. Read more here.

    4. Arduino Code for a decoder and encoder


       IRremote: IRrecvDump - modified version, by Tiran Dagan
       An IR detector/demodulator must be connected to the input RECV_PIN.
       Original Copyright 2009 Ken Shirriff
       JVC and Panasonic protocol added by Kristian Lauszus (Thanks to zenwheel and other people at the original blog post)
       LG added by Darryl Smith (based on the JVC protocol)

    #include <IRremote.h>

       You can change this to another available Arduino Pin.
       Your IR receiver should be connected to the pin defined here

    int RECV_PIN = 32;

    #define ANY_IR_PROTOCOL  true  // Set this to false if you want to decode any IR protocol,
                                   // otherwise this code will only decode NEC codes

    #define IR_CODE_COUNT 24 // Number of IRcodes in the arrays below

    String cmdStr[] = {"OFF", "ON", "RED", "L RED", "ORANGE", "ORANGE/YELLOW", "YELLOW", "GREEN",
                       "GREEN/CYAN", "CYAN", "D CYAN", "CYAN BLUE", "BLUE", "L BLUE", "PURPLE", "L PURPLE",
                       "PLUM", "WHITE", "FLASH", "STROBE", "FADE", "SMOOTH", "BRIGHT", "DIM"

    unsigned int  IRcodes[] =
      0x2FF807F,  // OFF
      0x2FF00FF,  // ON
      0x2FFE01F,  // RED
      0x2FFD02F,  // L RED
      0x2FFF00F,  // ORANGE
      0x2FFC837,  // ORANGE/YELLOW
      0x2FFE817,  // YELLOW
      0x2FF609F,  // GREEN
      0x2FF50AF,  // GREEN/CYAN
      0x2FF708F,  // CYAN
      0x2FF48B7,  // D CYAN
      0x2FF6897,  // CYAN BLUE
      0x2FFA05F,  // BLUE
      0x2FF906F,  // L BLUE
      0x2FFB04F,  // PURPLE
      0x2FF8877,  // L PURPLE
      0x2FFA857,  // PLUM
      0x2FF20DF,  // WHITE
      0x2FF10EF,  // FLASH (changes between red/green/blue)
      0x2FF30CF,  // STROBE (quickly flashes between all colors)
      0x2FF08F7,  // FADE (smoothly fades across red/green/blue)
      0x2FF28D7,  // SMOOTH (smoothly fades across all colors)
      0x2FFC03F,  // BRIGHT
      0x2FF40BF  // DIM
    IRrecv irrecv(RECV_PIN);
    decode_results results;
    void setup()
      irrecv.enableIRIn(); // Start the receiver
    void dump(decode_results *results) {
      // Dumps out the decode_results structure.
      // Call this after IRrecv::decode()
      int count = results->rawlen;
      if (results->decode_type == UNKNOWN) {
        Serial.print("Unknown encoding: ");
      else if (results->decode_type == NEC) {
        Serial.print("Decoded NEC: ");
      else if (results->decode_type == SONY) {
        Serial.print("Decoded SONY: ");
      else if (results->decode_type == RC5) {
        Serial.print("Decoded RC5: ");
      else if (results->decode_type == RC6) {
        Serial.print("Decoded RC6: ");
      else if (results->decode_type == PANASONIC) {
        Serial.print("Decoded PANASONIC - Address: ");
        Serial.print(results->address, HEX);
        Serial.print(" Value: ");
      else if (results->decode_type == LG) {
        Serial.print("Decoded LG: ");
      else if (results->decode_type == JVC) {
        Serial.print("Decoded JVC: ");
      else if (results->decode_type == AIWA_RC_T501) {
        Serial.print("Decoded AIWA RC T501: ");
      else if (results->decode_type == WHYNTER) {
        Serial.print("Decoded Whynter: ");
      Serial.print(results->value, HEX);
      Serial.print(" (");
      Serial.print(results->bits, DEC);
      Serial.println(" bits)");
      Serial.print("Raw (");
      Serial.print(count, DEC);
      Serial.print("): ");
      for (int i = 1; i < count; i++) {
        if (i & 1) {
          Serial.print(results->rawbuf[i]*USECPERTICK, DEC);
        else {
          Serial.print((unsigned long) results->rawbuf[i]*USECPERTICK, DEC);
        Serial.print(" ");
    void loop() {
      bool foundIR = false;
      if (irrecv.decode(&results)) {
        if (ANY_IR_PROTOCOL || results.decode_type == NEC) {
          foundIR = false;
          for (short int i = 0; i < (IR_CODE_COUNT); i++) {
            if (IRcodes[i] == results.value) {
              foundIR = true;
              Serial.println (cmdStr[i]);
          if (!foundIR && results.value != 0xFFFFFFFF ) dump (&results);
        irrecv.resume(); // Receive the next value

    After running this code and pressing a few buttons on the remote I realized pretty quickly that my remote uses NEC codes (the image below explains how I got to this conclusion).

    You may also notice that I decoded all 24 keys of my remote and added them into the array initialization. If you use an identical Infrared LED strip remote then this code will identify the key you press. The way I used it is as follows: As I press the keys on the remote – any new keys (which generate a IRcode not already in the IRcodes array) are displayed in the Arduino Serial Monitor with full debugging information, thus helping me identify that code. I then add it to the arrays (cmdStr is a user friendly name and IRcodes is the actual code. Here’s an example:

    IR-Decode-Output-ExampleI was pressing the SAME button on the remote multiple times. You can see that most times it was detected as a NEC protocol keypress. I am ignoring the shorter bits and “unknown encoding” and attribute them to noise or interference.

    Armed with this I added an “if” statement to my code to allow me only to observe NEC protocol messages (you can use the ANY_IR_PROTOCOL flag to let the program “dump” any protocol it detects until you know which protocol to use).  Once I add the codes to the array IRcodes, the program can use the “friendly” text to give you feedback about the key detected. 

    Now I was ready to test out my setup. I placed the LED strip’s power supply near my IR LED and ran the following code (which turns on the LED strip and then sends a few commands that are logged to the Serial console):


    * IRremote: IRsendDemo - demonstrates sending IR codes with IRsend
    * An IR LED must be connected to Arduino PWM pin 3.
    * Version 0.1 July, 2009
    * Copyright 2009 Ken Shirriff
    * http://arcfn.com

    /* Arduino is usually Pin 3 for IR output
    * For Teensy users:
    * Board Transmit Timer Used PWM Pins Disabled
    * ---------------- --------- ---------- -----------------
    * Teensy 3.6 / 3.5 5 CMT None
    * Teensy 3.2 / 3.1 5 CMT None
    * Teensy 3.0 5 CMT None
    * Teensy LC 16 FTM1 17
    * Teensy 2.0 10 4 12
    * Teensy 1.0 17 1 15, 18
    * Teensy++ 2.0 1 2 0
    * Teensy++ 1.0 1 2 0

    #define IR_CODE_COUNT 24

    String cmdStr[] = {"OFF","ON","RED","L RED","ORANGE","ORANGE/YELLOW","YELLOW","GREEN",
    unsigned int IRcodes[] =
    0x2FF807F, // OFF
    0x2FF00FF, // ON
    0x2FFE01F, // RED
    0x2FFD02F, // L RED
    0x2FFF00F, // ORANGE
    0x2FFC837, // ORANGE/YELLOW
    0x2FFE817, // YELLOW
    0x2FF609F, // GREEN
    0x2FF50AF, // GREEN/CYAN
    0x2FF708F, // CYAN
    0x2FF48B7, // D CYAN
    0x2FF6897, // CYAN BLUE
    0x2FFA05F, // BLUE
    0x2FF906F, // L BLUE
    0x2FFB04F, // PURPLE
    0x2FF8877, // L PURPLE
    0x2FFA857, // PLUM
    0x2FF20DF, // WHITE
    0x2FF10EF, // FLASH (changes between red/green/blue)
    0x2FF30CF, // STROBE (quickly flashes between all colors)
    0x2FF08F7, // FADE (smoothly fades across red/green/blue)
    0x2FF28D7, // SMOOTH (smoothly fades across all colors)
    0x2FFC03F, // BRIGHT
    0x2FF40BF // DIM

    #include <IRremote.h>

    IRsend irsend;

    void setup()
    delay (1000);
    sendIR ("ON");

    unsigned int getNECcode(String setting) {
    bool foundIR = false;
    short int i;
    for (i=0; i<(IR_CODE_COUNT); i++) {
    if (cmdStr[i] == setting) {
    foundIR = true;
    Serial.println (cmdStr[i]);
    if (foundIR)
    return IRcodes[i];
    return 0;

    void dim(int dim_steps) {
    for (int i=0; i<dim_steps;i++) {
    irsend.sendNEC(getNECcode("DIM"), 32 ); //on
    delay (10);

    void brighten(int dim_steps) {
    for (int i=0; i<dim_steps;i++) {
    irsend.sendNEC(getNECcode("BRIGHT"), 32 ); //on
    delay (10);

    void sendIR (String setting) {
    unsigned int NECcode;
    NECcode = getNECcode(setting);
    if (NECcode != 0) {
    irsend.sendNEC(NECcode, 32 );
    delay (100);

    void loop() {
    sendIR ("WHITE");
    sendIR ("RED");
    delay (5000);
    brighten (30);
    delay (5000);

     This is what that looks like when running the code:

Join The Discussion