Home-built shooting game with Nerf targets and a Raspberry Pi

Ready, Aim, Fire

© Lead Image © Tithi Luadthong, 123rf.com

© Lead Image © Tithi Luadthong, 123rf.com

Article from Issue 242/2021

A cool Nerf gun game for a neighborhood party provides a lesson in Python coding with multiple processors.

Last Halloween, I was asked to put together a Nerf game for a local neighborhood party. Not wanting to do just the same old thing, I got together with a couple of local makers, and we built a set of electronic targets to create a real-life tower defense game! Although COVID put a damper on this year's plans, we still managed to get most of the hardware together to expand the experience for a second round.

The original game had three targets: an Arduino Uno [1] brain, an audio amplifier, and a loudspeaker. Each target was placed inside a wooden structure that we called a "tower" (Figure 1). The object of the game was to shoot at the targets with a gun firing Nerf darts. A confetti canon was also built into the tower to announce when the tower "fell," which means that the target on the tower had sustained a predefined number of hits from the Nerf gun. We built two identical tower sets, one for each end of the field. Each system operated independently. The game monitors were responsible for powering down the system when the other team won.

Figure 1: The original Nerf targets: The blue disks are worth one point; the red disk in the center tower is worth three points.

The larger targets are worth one point each, and the smaller center target is worth three points. Games can be selected to run between 10 and 100 hits. A "traffic light" health gauge on the center tower gave an approximate value of how many hits remained before the game ended (see Figure 1).

When we deployed the system a second time, we arranged the hardware a little differently. Each target now has a single piezo sensor and its own NodeMCU-based (open source firmware) Arduino. Targets are powered by 3.7V LiPo batteries, so they are self-contained. Hits are transmitted to a server running on a Raspberry Pi on the edge of the playing field. Score announcements are still made by audio because outdoor video displays are unwieldy and expensive.

Arduino Code

The schematic for the second version of the Nerf tower is shown in Figure 2. Each node has only a single piezo, and all of the sound production now comes from the Raspberry Pi, which greatly simplifies the nodes compared to the original design. The nodes themselves are massively simplified. The main processor in this version is an ESP8266 on a WiFi Kit 8 [2]. (See the box entitled "More Inputs.")

Figure 2: Schematic for the second version of the Nerf tower.

More Inputs

The ESP8266 only has a single analog input. Although it possible to wire additional piezos in parallel, it is also possible to add a little bit of external hardware and expand the analog capability as well. Consider the LM339 quad comparator chip. It has four comparators that operate independently. Each comparator has a positive and a negative input. When the voltage on the positive input is higher than the voltage on the negative input, the output is active. It should be noted that the output of each comparator switches between ground and high impedance rather than V+. So if you want to use this with your Arduino, you'll need to turn on the pull-up resistor on whatever pin you connect it to.

This circuit connects the piezo to the positive input. The negative input will have a potentiometer with the lower end grounded, the top end connected to V+, and the wiper connected to the positive input of the comparator. The potentiometer then is the trigger level, or the voltage that needs to be exceeded by the piezo (how hard a hit) to enable the output.

With this one chip, I can add up to four piezo sensors to my target. The downside to this approach is that each piezo requires an associated variable resistor to tune its sensitivity. The variable resistor can't be adjusted remotely like the analog input can.

Each target is a self contained wireless node. A NodeMCU-based processor connects to WiFi and transmits hits to a central game server. The Arduino code is simpler, because all it does is report hits. All of the game logic lives in the game server itself. Each node also accepts a few commands to control game play or calibrate the node.

The code for the Arduino is shown in Listing 1. There are several different things needed to set up a WiFi connection so that's what most of these includes are for (lines 1-7). Arduino.h provides info about the hardware this sketch is currently running on (line 8), and U8g2lib.h is a library to draw on the attached OLED screen on the back of the board.

Listing 1

Arduino Code


Lines 11-16 are a unique kind of if statement. ifdef is an if statement that is interpreted by the compiler itself rather than the C code in other parts of the sketch. In this case, if U8x8_HAVE_HW_SPI is defined (line 11) then include <SPI.h> (line 12). endif on line 13 closes the block. Lines 14-16 work the same way, but I'm checking for I2C and including Wire.h instead.

Any variables that are defined outside of functions are global – they can be accessed by any function in the program. On lines 18-20, I'm defining constants. These constants can't be changed once they are set up. In this case, it is the network SSID (line 18), network password (line 19), and the IP of the game server (line 20).

On line 22, iTrigger is the value that the analog input must go above to trigger a hit. I default to 333, but this can be changed by the server once it connects. Line 23 sets up iHits, which is how many hits this particular node has registered. The game server tracks hits for scoring purposes, but this value is used to update the display, so it is easy to confirm that the sensor is working. Line 24 sets up ulNextHit, which generally is assigned from the millis() function plus a delay. Once millis() is greater than this value, then the timer has expired.

Line 26 creates client as an instance of WiFiClient. This client's purpose is to interact with all of the network commands. Finally, line 28 sets up all of the connections for the OLED display attached to the board.

Just like in any other Arduino program, setup runs once. The setup function (lines 30-69) defines some variables, connects to the WiFi, sets up the display, and then connects to the game server.

The main loop appears in lines 71-110. Each cycle through the main loop updates the display with some internal readings, checks to see if the sensor has been hit, and, finally, sees if there are any characters waiting to receive and process.

Line 72 initializes iSensor; then I do an analogRead on A0 to get the voltage coming off the piezo disk [3]. On lines 76-85, I update the display with three values: the most recent analog reading from A0, the current value of iTrigger, and iHits. This hit counter is only for this node, not game wide. Then, if ulNextHit is greater than millis(), I display the remaining time out value – how long until this node will respond to a hit again.

If iSensor is greater than iTrigger, then this target has been hit. I also check that millis() is greater than ulNextHit. If it's not, then this target is currently disabled. If all that checks out, I increment the hit counter with iHits += 1 (line 89) and disable the target for one second with (line 90):

<C>ulNextHit = millis() + 1000;<C>

Line 92 checks to make sure that the client is still connected to the game server, and if not, line 94 re-establishes the connection. Finally, I send the string H: and the number of hits this node has recorded.

Line 100 checks client.available() to see if there are any characters waiting. If the value is greater than zero, the client has received a message and needs to process it. Line 102 reads incoming data into received until it finds a semicolon.

The switch statement on lines 103-108 checks the first character of the string received [ 0 ] to see if it's a command. If it's an R, then I should reset the hit counter with iHits = 0. If it's a T, I use received.substring to get the rest of the string, use toInt to make it an integer, and set iTrigger with this value. That changes how sensitive the piezo disk is. Finally, if it's a D then I set ulNextHit to millis() plus the integer value of whatever the rest of the received string equals. This sets the delay before any new hits will be registered. A delay of zero will re-enable the target immediately.

The Server

The Raspberry Pi server receives information from the Arduino clients and manages the game. The server software is written in Python and shows a text user interface in a terminal (Figure 3). All of the game logic lives in the server software. Whenever a hit is received from the wireless nodes, the server announces "Red target hit!" or "Blue target hit!" Targets are assigned to colors in the server interface. Similarly, the length of the game (number of hits) is also adjusted there. The server can also adjust each node's input sensitivity and enable a global disable on all the node's targets. Disabling the targets is handy for pausing the game for additional instructions.

Figure 3: The user interface presented by the server is text only, so it will run in any terminal. The connections are simulated with netcat, as noted by the address.

I've utilized multiple threads for this server; each task is split off to its own process and deposits input into a queue as it is received. Then the main process handles input from the threads as it arrives and takes care of the human interface. Each thread can block or wait as long as it needs to, because it is running independently of the main program.

Listing 2 shows the server code. The external libraries imported for the game appear in lines 1-7. socket accesses the computer's networking hardware and allows for network communication. thread allows programs to separate into sub-processes. curses controls character cell displays (like terminal windows). time lets you access the system clock and other timing functions. os allows you to talk to the operating system that the program is running on. Queue creates first-in/first-out thread-safe channels to communicate between different threads.

Listing 2

Server Code


send (lines 25-31) is the "transmitter" of the socket. It accepts a single parameter, msg, which is the data to be sent. Line 26 sets up totalsent, which is the number of bytes actually transmitted. Sockets don't always send all the data at once. Each time you call self.sock.send, it will return how many bytes it actually sent. It is up to the program to keep calling self.sock.send until the entire message has been transmitted. Here I accomplish this with

<C>while totalsend < len ( msg )<C>

(line 27). If I try to send and zero bytes get transmitted, there's a problem with the socket connection. This is caught on lines 29-30. Finally

<C>totalsent = totalsent + sent<C>

updates the transmitted byte count.

In lines 33-45, receive is the counterpart to send. Line 36 asks the socket for a data chunk with self.sock.recv(64). (64 asks for no more than 64 bytes.) If the data returned is empty, then there's a problem with the socket connection. Lines 37-38 check for that. Then strip chunk removes any whitespace characters and adds the result to self.receivedData (line 39).

Line 40 checks for a semicolon in self.receivedData – the end of an incoming message. There's nothing special about the semicolon as end of message; its just the character I picked to signify it. If a semicolon is found, I create the string output, which is all of self.receivedData up to the location of the semicolon (line 41):

<C>self.receivedData.index ( ";" )<C>

nodeClass (lines 47-81) is the server representation of the hardware node I described earlier. The gameServer class (lines 83-252), which is initialized when the program starts and sets everything else in motion, is the main entry point in the code.

I initialize the curses library [4] to provide character-cell management of the terminal window and then set up variables that I use to manage instances of nodeClass and the screen itself. self.allNodes stores each instance of the class. self.nodeIndex is a count of the number of nodes that have connected. self.cursorIndex and self.oldCursor track the cursor movement in the user interface. Other variables track each team's score and the number of points to win the game.

The main loop appears in lines 116-178. Line 116 is an infinite while to continually process events from both the nodes and user input. Line 117 checks if self.q.empty() is False. If it is (the queue is not empty), then line 118 gets the next entry from the queue. If the first character of the received data is an "H" (a target has been hit), then I set up a loop to walk down self.allNodes. Once I find the matching IP address and port, I increase the hit counter, set the delay to one second from now, increment the appropriate team's score, and announce the hit.

The next instance of thread.start_new_thread.self.speak executes an external program, so it will block until the program finishes running. In this case, the external program is the speech program that makes an announcement. By launching the speech program in its own thread, the main program will continue to run even while speech is happening. Finally, I call self.checkScore, since scores have been updated. This will redraw the score on the interface. If a milestone has been hit, it will also announce a percentage of health remaining.

Lines 133 and 134 loop through all of the nodes again and call statusLine. The argument is the reference to self.screen, the curses screen buffer.

Line 137 uses self.screen.getch() for the most recent key pressed (if any). If no key has been pressed, then it will return -1. If a key has been pressed, then I move on to the blocks of if/elif to process each key. Lines 139-149 manage responses to a user pressing the arrow keys. The up and down arrows move the cursor up and down in the node list. The left and right arrows change the sensitivity for how hard the dart has to hit the target.

Lines 150-153 let you press r or b for self.allNodes [ self.cursorIndex ].team to refer to either the RED or BLUE team. This value is used for the overall game score to decide which team should be credited with a hit.

Pressing x on the keyboard calls the current node's clearHits method, which resets the node's hit counter to zero.

The checkScore function (lines 180-217) draws the score to the user interface and also plays speech if certain milestones are reached. Line 191 checks to see if self.redScore is greater than or equal to 50 percent of self.scoreGoal and that self.announceRed is less than 5. This signifies that the 50 percent announcement has not yet been spoken. If both conditions are true, then self.announceRed is set to 5 (line 194) and speak is called in a new thread. This process repeats for 70 percent, 90 percent, and 100 percent (game over). The entire block is then repeated for the blue team.

The speak function (lines 220-234) makes a system call to eSpeak [5]. Each type of message is customized within each if/elif. If this is a HIT then say "[team color] target hit!"

If this is a SCORE update and it's the red team, then pause for 1.5 seconds and say "red base " (calculate percent remaining by subtracting self.announceRed from 10 and then multiply by 10) and then say "percent." The message ends up being something like "red base 50 percent". The same thing happens for the blue team.

If the message is WINNER, wait 1.5 seconds and then say "[team color] base has fallen, game over!"

Starting Up

The startListening function (lines 236-242) invokes all of the socket commands to open a socket and listen for incoming connections from nodes.

socket.socket creates a socket; its parameters socket.AF_INET and socket.SOCK_STREAM specify IPv4 addresses and TCP sockets respectively. serversocket.bind tells the socket where to listen for connections. socket.gethostname asks the system what its name is on the network. You can also specify to only accept local connections. The second argument is the port number, 9000 in this case. Then I call serversocket.listen for up to five connections. If connection number 6 arrives before any previous connections have been processed, the sixth connection attempt will fail. Once earlier connections have been established, further connections can proceed normally.

Line 240 enters an infinite loop to accept incoming connections and create a self.node thread for each connection. startListening itself runs in its own thread so the infinite loop waiting for connections won't hold up the main program.

The node function (lines 244-252) accepts three parameters: the client socket, the address its coming from, and q, which is the communications path back to the main thread. node runs in its own thread, so after creating its own socket handler and adding it to the list of allNodes, it sits in an infinite loop. When data is received, it is sent up the queue if it is not equal to None.

Line 254 instantiates an instance of the gameServer class. Without this step, everything I've just talked about is only definitions. This step sets everything in motion.

Buy this article as PDF

Express-Checkout as PDF
Price $2.95
(incl. VAT)

Buy Linux Magazine

Get it on Google Play

US / Canada

Get it on Google Play

UK / Australia

Related content

  • Mesh Networking

    Mesh networking comes to with the IEEE802.11s draft standard. We'll show you how to mix a mesh.


    Iptables gives admins the ability to set up clusters and distribute the load. But what about failover?

  • A backtracking algorithm tries its hand at the bridges of Königsberg

    Pretty much any computer science lecture about graph theory covers the "Seven Bridges of Königsberg" problem. Mike Schilli puts a Python script to work on a solution, but finds that a new bridge must be built.

  • WebRTC Protocol

    The WebRTC protocol converts your web browser into a communications center, supporting video chat over a peer-to-peer connection without the need for helper apps or browser plugins.

  • Nmap Scripting

    Nmap is rolling out a new scripting engine to automatically investigate vulnerabilities that turn up in a security scan. We’ll show you how to protect your network with Nmap and NSE.

comments powered by Disqus

Direct Download

Read full article as PDF:

Price $2.95