Laser Tag C11 – IR

Love Jesus, Love Code

Hardware

  • The IR transmitter is a TSAL6100 Vishay, 940Nm IR LED, 5mm or SFH4545 OSRAM (has a more focused beam)
  • The IR receiver is a TSOP34840 (40kHz – used to receive Sony IR signal sent from taggers)

IR Modulation & Data transmission

Infrared (IR) is invisible to the human eye but can be used to transmit data. To distinguish it from ambient IR, such as sunlight or indoor lighting, the signal is modulated at a frequency that makes it easier for the receiver to recognise and decode accurately.

Although the 38kHz frequency is not the most common, Sony uses a default modulation frequency of 40kHz, which is also the frequency employed by our laser taggers. However, it is possible to use the 38kHz modulation frequency, provided all taggers conform to the same modulation standard.

The IR transmitter can generate a range of modulated frequencies controlled by software. If using a 38kHz receiver, the code “sendSony38” can be used instead of “sendSony,” which defaults to 40kHz.

The Milestag protocol differs from the Sony infrared protocol in that the Sony signal typically repeats three times, while the Milestag shot signal is sent only once per shot. This distinction is important, as the IRremote8266 library includes the Sony protocol by default.

The protocol requires a 2400µs pulse header with 600µs gaps. A value of 0 is represented by a 600µs pulse, while a value of 1 is indicated by a 1200µs pulse. This timing is used to detect a laser tag signal, and since the IRremoteESP8266 library cannot handle large values, the recieved signal will be processed manually.

Diagram from the MilesTag CORE Operation Manual Firmware Version 5.6X

Shot packets always start with a 0 as the first bit. The following 7 bits represent the Player ID, the next 2 bits indicate the Team ID, the subsequent 4 bits denote the damage, and the final bit signifies the zone. This constitutes a 15-bit signal, which is crucial to maintain.

For instance, if the Player ID is 5, the Team ID is 1, the damage is 50, and the zone is 1, the gun must automatically calculate the corresponding binary values.

The damage is a mapped value as shown below from the MilesTag 2 Protocol by Christopher Malton April 2011.

A damage value of 50 is represented as 0x0D in hexadecimal, which corresponds to 1101 in binary. Searching for “0x0D in binary” reveals the answer as 0b1101. The prefix 0b indicates that the number is in binary format, just as 0x indicates hexadecimal.

The player ID of 5 is represented as 0000101, requiring 7 bits.

The team ID is represented as 01, requiring 2 bits.

The zone is represented as 1.

The final binary code is:

000001010111011

Circuit

New Feature

Save the project as “LaserTag_11_IR”

Libraries

The best library for Infrared and the ESP32 development module is IRremoteESP8266. However, when Espressif Systems updated their software from version 2.x.x to 3.x.x, it broke IRremoteESP8266. As of this writing, the IRremoteESP8266 library (version 2.8.6) has not been updated to address these changes. Nevertheless, some users have posted a solution on GitHub, which can be downloaded below:

Add this to the Arduino IDE by going to Sketch –> Include Library –> Add .ZIP Library.

An alternative solution is to roll back the esp32 boards from the boards manager to version 2.0.17. This is not the recommended solution, as it may cause issues with other libraries.

Libraries and Variables

Add the following libraries and variables

setup() and loop()

Add the following code to setup() and loop()

New Tab – IR

Create a new tab called “IR” and copy all the code below

void setupIR(){
  //Start the IR Receiver
  irrecv.setUnknownThreshold(kMinUnknownSize);
  irrecv.enableIRIn();  
  // Start the IR Sender
  irsend.begin();
}

void receiveIR() { //  checks for an incoming signal and decodes it if it sees one.
// use the library
  if (irrecv.decode(&results)) {
    debugInfo += "Signal Detected = " + String(results.value, HEX) + " , " + String(results.value, BIN) + " Bits = "+ String(results.bits)+ "<br>";
    Serial.println("Signal Detected = " + String(results.value, HEX) + " , " + String(results.value, BIN) + " Bits = "+ String(results.bits));
    
  //check header and first space
  if ((results.rawbuf[1]* kRawTick)>2300 && (results.rawbuf[1]* kRawTick)<2600 && (results.rawbuf[2]* kRawTick)>500 && (results.rawbuf[2]* kRawTick)<700){
    if (results.bits == 15) {  //shot code
      debugInfo += "Shot Code Detected ";
      Serial.print("Shot Code Detected ");
      shotPackage();
    }
    else if(results.bits == 24){ // Messaage Code
      debugInfo += "Message Code Detected ";
      Serial.println("Message Code Detected ");
      //milesTagMessage(); this will be used later
    }
    else if(results.bits == 344){ // Clone Signal
      debugInfo += "Clone Signal Detected ";
      Serial.println("Clone Signal Detected ");
      toBitArray();
      bitToByteArray();
      printByteArray(milesTagSetting, 43);
      printByteArray(receivedByte, 43);
      for (int i = 0; i < 43; i++) {
      milesTagSetting[i] = receivedByte[i];   // Copy values from sourceArray to destinationArray
      }
      printByteArray(milesTagSetting, 43);
      writeCloningDataFile();
      milesTagSettingsFromArray();
    }
  }
  irrecv.resume();
  }
}

void toBitArray(){  //populate receivedBit array
  std::fill(std::begin(receivedBit), std::end(receivedBit), 0); //fills array with 0 values
  //RAW Buffer allows for longer length than results.value can hold
  int j=0;
  for ( int i=3; i<results.rawlen; i+=2){

    if (results.rawbuf[i]* kRawTick > 900){
      receivedBit[j] = 1;
      debugInfo += "1";
      Serial.print("1");
    }
    else {
      receivedBit[j] = 0;
      debugInfo += "0";
      Serial.print("0");
    }
    j++;
  }
  debugInfo += " RAW Check <br>";
  Serial.println();
}

void bitToByteArray(){ // Populates receivedBytes[] array  
  debugInfo += "bit to Byte<br>";
  int bitIndex= 0;
  for ( int j = 0; j < (43); j++){ //milestag clone signal length is 43
    int currentByte= 0;
    for ( int i = 0; i < 8; i++ ){
      bitIndex = i + (j*8);
      currentByte += receivedBit[bitIndex] << 7-i;
    }
    receivedByte[j] = currentByte;
    debugInfo += String(receivedByte[j], HEX);
    Serial.print(String(receivedByte[j], HEX));
    if (receivedByte[j]<= 0x0F){ // this needs to be two digits
      debugInfo += "0";
      Serial.print("0");
    }
    debugInfo += ",";
    Serial.print(",");
  }
  debugInfo += "<br>";
  Serial.println();
}

void shotPackage(){
if(hitNo == maxHits){hitNo = 0;} // hitNo sorts out where the data should be stored if statement means old data gets overwritten if too much is received
  hitNo++;
  recPlayerHit[hitNo] = 0; recTeamHit[hitNo] = 0; recDamageHit[hitNo] = 0; recZoneHit[hitNo] = 0;// set hit data to 0

  recPlayerHit[hitNo] = (results.value & 0x7F80) >> 7;     // Player - Bits 7-13 (mask = 11111110000000)
  recTeamHit[hitNo] = (results.value & 0x60) >> 5;       // Team - Bits 5-6 (mask = 01100000)
  recDamageHit[hitNo] = (results.value & 0x1E) >> 1;       // Damave - Bits 1-4 (mask = 00011110)
  recZoneHit[hitNo] = (results.value & (1 << 0)) >> 0;   // Zone - Bit 0

  if (gameState == 1 && recPlayerHit[hitNo] != playerID){hitReceived();}  // only process hits if player in game and alive & not shooting self
  if (recPlayerHit[hitNo] == playerID) {
    debugInfo += " Self-shot detected <br>";
    Serial.print(" Self-shot detected"); 
    debugInfo += " HIT by " + playerName(recPlayerHit[hitNo]) + "| TEAM: " + teamName(recTeamHit[hitNo]) + "| DAMAGE:" + String(damageMapped(recDamageHit[hitNo])) + "<br>";
    Serial.println(" HIT by " + playerName(recPlayerHit[hitNo]) + "| TEAM: " + teamName(recTeamHit[hitNo]) + "| DAMAGE:" + String(damageMapped(recDamageHit[hitNo])) + " ");
    if (hitNo>0) {hitNo--;} //take count back and it will be rewritten next shot
  }
}

void hitReceived() { // Make some noise and flash some lights when you get shot
hitFlash();
health = health - damageMapped(recDamageHit[hitNo]);
scoreUpdate();
mp3.playFileByIndexNumber(6); // damage sound
if(health <= 0){canFire = false; dead();}
hitFlash();
}

void scoreUpdate(){
  damageID [recPlayerHit[hitNo]] += damageMapped(recDamageHit[hitNo]);
  if (health <=0){ killID [recPlayerHit[hitNo]] += 1; deathCount +=1;}
  enemyTeamID [recPlayerHit[hitNo]] = recTeamHit[hitNo];
}

Code Explanation:
receiveIR() relies on the remoteIR library to decode the received signal. It first checks for a header of approximately 2400µs, followed by a 600µs gap; any other signals can be ignored. The function also verifies the signal length to determine whether it is a shot signal (15 bits), a message code (24 bits), or a clone signal (334 bits). Once the signal is received, it is crucial to resume the IR sensor to allow it to receive further data by using the command irrecv.resume();
toBitArray(int, int) converts the raw signal received from the gun into a binary array, enabling easy manipulation of the data. It is crucial to include any leading 0’s in the array; this is why the receivedLength variable is necessary. The “Binary Shift Left” command is used to help fill the array. For more information, see this tutorial.
if (results.rawbuf[i] * kRawTick > 900) – this statement evaluates the timing of each pulse. The theoretical value for a 0 pulse is 600, while for a 1 pulse, it is 1200. The statement identifies the midpoint between these two values and assigns a binary value based on which it is closest to.
void shotPackage() processes the shot package and adds it to a multi-dimensional array that stores all shot data. More information about manipulating binary numbers using C++ can be found here.
void hitReceived() – note that if the health is less than or equal to zero, the player is considered dead, and the dead() function is called.

Code – Edit code in Triggers Tab

Code Explanation
irsend.sendSony(data, length, repeats)– The first input for this function is the data, the second specifies the length of the signal, and the third indicates whether the signal should be repeated. By default, a Sony IR signal is sent three times; however, for milestag, only one signal is transmitted.
shotDelayTime = millis();– This line records the time when the last shot was taken.

Code – Replace code in OLED tab

Go to the OLED tab, and replace the displayGameOver() function with:

void setupOLED(){ // Start the OLED display
  if(!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { 
    Serial.println(F("SSD1306 allocation failed"));
    debugInfo += "SSD1306 allocation failed for OLED display </br>";
    }
  Serial.println("OLED display is now operational");
  debugInfo += "OLED display is now operational <br>";
  displayUpdate();
}

void displayUpdate(){
  // Clear the buffer.
  display.setRotation(0);
  display.clearDisplay();
  //display data to screen
  display.setTextColor(WHITE);
  display.setCursor(0,0);
  display.setTextSize(2);
  display.print(String(health));
  display.write(3);
  display.print(String(clips));
  display.write(15);
  display.print(String(shotsLeft));
  display.setCursor(0,16);
  display.setTextSize(1);
  if (hitNo>0){
    display.setTextSize(2);
    display.write(1);
    display.println(" " + playerName(recPlayerHit[hitNo]));
    display.write(3);
    display.print(" -" + String(damageMapped(recDamageHit[hitNo])) + " " + teamName(recTeamHit[hitNo]).substring(0,3));  
  }
  else{
    display.setTextSize(1);
    display.println(WiFi.localIP());
  }
  //Code to out zero in for seconds less than ten on display
  display.setTextSize(2);
  display.setCursor(12,48);
  if ((timeLeft % 60) < 10) display.println(""+ String(timeLeft / 60) +":0"+ timeLeft % 60);
  else display.println(""+ String(timeLeft / 60) +":"+ timeLeft % 60);
    
  display.drawTriangle(0, 48, 10, 48, 5, 55, WHITE);
  display.drawTriangle(0, 62, 10, 62, 5, 55, WHITE);
  display.setTextSize(1);
  display.fillRect(114,48,128,56,BLACK);
  display.setCursor(80,48);
  display.println(weaponName(fireSelect));
  display.setCursor(80,56);
  display.println(damageMapped(myGunDamage));
  display.setCursor(16,0);
  display.setRotation(1);
  display.println(playerName(playerID));
  display.display();
}

void displayGameOver(){
  int topKiller[6] = {100,100,100,100,100,100}; // based on damage dealt - allow for overflow when shuffling. PlayerID set to 100 
  display.setRotation(0);
  display.clearDisplay();
  display.setCursor(0,0);
  display.setTextSize(1);
  display.println(playerName(playerID) + " killed " + String(deathCount) + " times");
  debugInfo += playerName(playerID) + " killed " + String(deathCount) + " times" + "</br>";
  display.setTextSize(1);
  display.println("DAMAGE REPORT:");
  debugInfo += "DAMAGE REPORT: </br>";
  //Find top 5 killers based on damage
  for (int i=0; i<99; i++){ // shuffle through all player IDs i= playerID
      for (int j=0; j<5; j++) {
        int pos = 0;
        if (damageID [i] > damageID[topKiller[j]]){ // check if the damage is greater than someone else in the list
            pos = j;
          while (j<5){ //shuffle all killers down list by 1 from where they are greater
            topKiller[5-j] = topKiller[4-j];
            j++;
          }
          topKiller[pos] = i; // put the killer into the list at the correct position.
        }
        exit; // exit out of for loop once player inserted into list
      }  
  }
    
   for (int i=0; i<5; i++){
    if (topKiller[i] != 100){ // only display players in the game. PlayerID 100 means not used 
      display.println (playerName(topKiller[i]) + " " + String(teamName(enemyTeamID [topKiller[i]])) + " " + String(damageID[topKiller[i]]) + "D " + String(killID[topKiller[i]]) + "K");
      debugInfo += playerName(topKiller[i]) + " " + String(teamName(enemyTeamID [topKiller[i]])) + " " + String(damageID[topKiller[i]]) + "D " + String(killID[topKiller[i]]) + "K </br>";
    }
  } 
  display.display();
}

Code Explanation:
displayUpdate()– Observe the function of the “if” statements. In the first if statement, once the tagger is hit, the IP address will disappear, and in its place, the name of the person who made a hit will be displayed. In the second if statement, when the timer reaches 0, the scoring screen will be shown.
substring(0,3)returns the first three characters of the string. This is utilised in the display to prevent colours like green and yellow from appearing on the following line.

Christian Content

In the game of Lasertag, every hit against you is meticulously recorded—who shot you, how many times, and the damage inflicted with each hit. This detailed accounting system makes perfect sense in a competitive game setting.

But imagine if we applied this same mentality to our daily lives—keeping track of every slight, hurt, or offense committed against us. What a heavy burden that would be! Our hearts would likely grow bitter and our relationships would suffer under the weight of such detailed record-keeping.

The Bible offers us a beautiful alternative in 1 Corinthians 13:4-5:

“Love is patient, love is kind. Love does not envy, is not boastful, is not conceited, does not act improperly, is not selfish, is not provoked, and does not keep a record of wrongs.”

True love—the kind of love we’re called to embody—doesn’t maintain a scoreboard of offenses. Instead, it chooses forgiveness and releases the debt owed.

This truth becomes even more powerful when we consider how God relates to us. There is profound joy in knowing that our Creator loves us so completely that He chooses not to hold our sins against us:

“How joyful is the one whose transgression is forgiven, whose sin is covered! How joyful is the man the Lord does not charge with sin and in whose spirit is no deceit!” (Psalm 32:1-2)

This divine forgiveness isn’t automatic, though. It comes through accepting God’s gift of grace by believing in Jesus Christ’s sacrifice on the cross for our sins and committing to follow Him as Lord of our lives.

What freedom there is in both receiving this forgiveness and extending it to others—putting down our scorecards and embracing the liberating practice of grace!

Leave a Reply