Published: 2015-08-05 | Categories: [»] Engineering, [»] Electricity & Electronicsandvideos.

One of my favourite summer activities is to go outside and practice shooting with airguns. The only problem, that you may have experienced by yourself, is that shooting on paper cards targets is not really fun and sometimes you wish you could train with something that is more challenging. Since I’m too considerate to shoot at animals, I came to think a little bit on an electronic target system.

In an electronic target system, each target is equipped with a “hit” sensor connected to a controller logic. In some systems the target is moving through the impulse of a motor, while in others the target are immobile but are activated sequentially in time, forcing you to aim at different point. In this post, we will talk about the latter. You should be able to find several amateur electronic target systems on the Internet but, here, I decided to push the thinking at some higher level: creating a game from them.

Before we proceed to a more detailed discussion on the system developed here, please have a look at the promotional video to get a good grasp on how it performs:

The system is composed of five electronic targets fixed to a rigid frame and connected to two Arduino controllers for the logic. One of the Arduino controller is actually connected to the targets while the other is placed next to the shooter to show its current score. The two controllers talk to each other through Digi Xbee wireless modules. Oh, by the way, if you buy the official Arduino wireless module, you will need to buy the Digi Xbee module separately because they are not included with the Arduino shield. Also, you will be willing to buy the Digi XBee “series 1” model and not the “series 2”. If by any chance you already bought the series 2, you will need to download Digi XCTU software and configure one of the wireless module as a “coordinator” and the other one as a “router”. Failing to do so will make your series 2 wireless module unable to talk with each other. If you have the series 1, no need to worry: just plug them into the Arduino shield and it will work!

Also, the system here uses five targets and is dedicated to airsoft guns. But this is only due to technical limitations: the Arduino Uno does not have enough I/O pin to allow for more than five targets and the targets themselves are not designed to cope with the stronger impacts of paintball guns or power air guns. Nonetheless, you may adapt the system to work with more targets by switching to another controller (such as the Arduino Mega for instance) and it should be possible to make stronger targets by adapting their design. So don’t think of this as a real limitation, it is just the way I decided to implement the solution.

Finally, and before you decide to build such a system, please think twice about your neighbourhood. Living in the countryside will not give you the same constraints as living in a city. Nobody wants a neighbour going berserk with a 400 fps paintball gun aiming at your house while you are having a family dinner outside! By the way, here I am using a 270 fps Tokyo Marui MP5 Hi-cycle with 0.20g BB’s. This should give you an idea of the actual resistance of the targets.

I have tested several technologies for the targets themselves. At first, the idea was to have no moving parts so I tried to build something around a “tilt sensor”. A tilt sensor is a circuit that detects impacts. In its most basic version it is made of a gold casing with a small metallic ball inside that connects two wire. As the sensor is shaken, the ball will move into the casing and open/close the electric circuit. Most of the anti-theft systems used to be built on this concept. Because there is no guarantee that the tilt sensor will be closed or open at rest, you cannot use it as a simple push button to detect an impact. Instead, you should be looking for changes in the signal. By placing a capacitor in series with the sensor, you will get spikes everytime the ball bounce into the casing. The more powerful the impact, the more spikes per second. Using an RC circuit as an integrator, you will get an output voltage that is proportional to the actual impulse received. Comparing this voltage to a fixed constant you can get an ON/OFF I/O state for a target hit. A photography of the tilt sensor used for the test is shown on Figure 1.

Figure 1

This does work but is, in my experience, way too sensitive for shooting, even with airsoft guns. The impulse transferred to the tilt sensor is much too large and the ball takes too much time to lose its energy resulting in a target that stays on for too long. Also, with time, I have noticed that these sensors tend to switch for almost no reasons! Seems like they do not like strong impulses… Nevertheless, they have shown to be working even with high power air guns at close distance.

As the tilt sensors were not reliable enough, I decided to go for a target with mobile elements that would close or open a circuit as the target has been hit. The idea is pretty simple: a metal plate is fixed on a solid frame with a single rotation freedom and held into place using springs. When a bullet hits the metal plate, its kinetic energy is transferred to the plate that is propelled back. As the plate is pushed back, it will open an electric circuit. Finally, the plate is pushed back into place by the springs such that the circuit closes again. In practice, I have implemented this using a pushbutton at the bottom of the plate and a couple of pairs of rubber bands as springs. By default, the rubber bands will hold the pushbutton closed but as a bullet hits the metal plate, the circuit will open for a short period of time. Please refer to the video for a better view.

In our electronic shooting target systems, the controller will pick up a target to shoot at and wait for a hit or miss condition. To clearly identify which target should be aimed at, each of them has a bright LED that turns on when the target is active. It is possible to combine these two behaviours (LED plus hit sensor) using a single circuitry as displayed on Figure 2.

Figure 2

The LED and the pushbutton are connected in series and inserted in a transistor-based logic. Two cases should be distinguished: when current flows through the target and when no current flows. A no current condition will happen when either the target is disabled (no current is then sent from the controller) or when the target was hit because the pushbutton is in the “open” state. At the contrary, the presence of current can only happen when the target is enabled and in the “closed” state.

When no current flows through the circuit, the base-emitter junction will not be biased because the base is put to ground through the Rs resistor. Because no current flows from the base to the emitter, no current will then be drawn by the collector and the output voltage will be +5 Volts. On the other hand, when current flows through the circuit, the base-emitter junction will be biased by the voltage drop across Rs which is the product of the resistance value and the current flowing through it. With Rs on the order of 100Ω and a diode voltage drop of about 3 Volts (typical value for a bright LED), the current in the LED will be about 20 mA and the bias voltage 2 Volts. Because the bias voltage is larger than the minimum bias junction (typically about 0.7 Volts), the NPN transistor will drive current through its collector-emitter junction and the collector will be virtually connected to ground, making the output voltage nearly 0. As a result, the output state will switch from “high” (+5 Volts) to “low” (0 Volts) when current flows or not through the target. The 4.7 kΩ resistor is placed to protect the transistor from excessive current flowing through the base-emitter junction; don’t forget it!

In software, the controller will check frequently if the I/O level input is high or low. To be able to detect the impact, the I/O pin should be looked at about every millisecond because target impacts can be as short as a few tens of milliseconds.

So we have then some targets and a way to check their status with the microcontroller. But this is far from being a game!

The very first step in making a game out of it is to have an incentive for the player. This is usually implemented by having a score-based system. When you get points by shooting at targets, it is a natural thing to try to have the highest score. Most arcade games work that way.

But an issue will quickly pop up if you design the game that way. Imagine you get one point everytime you hit a target and try to compare two players: one with a 30 BB’s magazine and the other with 400 BB’s. It is very likely that the player who has the largest magazine will get the highest score. This makes the game not really challenging because it will be felt that the game is not judging on your shooting skills but more on your magazine size! And it should be already stressed here that what a player wants is to be judged on his competences and not on other external factors.

So a good way to make the game more attractive is to make it reflects the player actual skills. This can be achieved by decreasing the score as you miss a target. Not only is this a good way to make the game less dependent on external factors such as the magazine size, but it will also give some value to the points earned: what can be lost has always more value than what is acquired for sure. Also, some very primitive psychology is involved here: by removing points from the player score you are actually punishing them for some bad actions and by giving them points you are congratulating them for some good actions. The problem is that you cannot spank them very hard for the bad actions because they will feel that the game is unfair to them. Put differently, if you remove one point for a miss, you will have to give n points for a hit.

The question is: what value should this hit-to-miss ratio be? As we have five targets it is intuitive to give 5 points for a hit and to remove 1 point for a miss. This will make the game already much more fun to play but quickly the player will find a work-around for that solution. If you look at some statistics, you can show that, on the average, each target will be lid one fifth of the time. That means if you keep aiming at the same target and shoot only at that particular one, you will get t/5 * 5 = t points per cycle where t is the number of total targets that were lid. But as you keep aiming on the same target, you will miss 4t/5 * 1 points. At the end, you will then have a score of t – 4t/5 = t/5 points. If you have a magazine large enough, the game will last longer and you will be able to get some non-zero score with this “camping” strategy. This is a real issue because the aim of the game is to judge your shooting skills, not your shooting strategy.

As you may have already understood, the game will quickly lose its fun as the player discovers a winning strategy. So 5 points per hit is actually too much. But our statistical approach brings some important value here because we can use it to identify the hit-to-miss ratio that will give a null score for this strategy. That way, the player will neither be congratulated nor punished by using this winning strategy. If you give the player 4 points per hit, you can show that after t targets the mean score should now be zero which is exactly what we would like. Lower hit-to-miss ratio will make this end score negative in the average which is not really a good thing either. So we will stick with this ratio: 4 points for a hit, -1 point for a miss. Obviously, if you build a system with 10 targets, the hit-to-miss ratio should then become 9:1 and so on.

Still, players with larger magazines will likely get higher score than player with smaller magazines. This is normal because the longer the game session last, the more points the player can earn if he is a good shooter. The solution to this problem is to make targets more difficult to shoot at as your score increase. That way, at some point, the player will reach a steady state where he cannot hit any more targets because his reflexes are not sufficiently accurate. As he keeps missing targets, the delay will be a bit longer such that he will then be able to shoot again on a target until he finds himself with not more ammo. The score earned then becomes a direct representation of the player actual shooting skills which allows not only competing with other players but also with himself. This is a crucial part of the game developed here.

Implementing this pattern is not very difficult on itself but the question is more on how to select the ideal score-vs-delay progression. In game design, this is usually referred to as the learning curve. If the learning curve is too steep, the player will find the game too hard to play and will quit. But if the learning curve is to shallow, the player will get bored. Choosing the right learning curve is a difficult task because every player is different and what is boring to one can be hard for another. This is why many games proposes several difficulty levels such as “easy”, “normal” and “hard” with different degree of learning curve steepness. Here, I have decided to include only one learning curve but there is not real difficulty in evolving the device later.

To translate the score into the delay, one will need a mathematical expression for the learning curve. There are different classical shapes such as the linear one (delay directly proportional to the score), power functions, exponentials, sigmoids… From trial and error I have decided to go for an exponential one:

with tmax the slowest target delay (when you begin the game) and tmin the fastest target delay. k is a constant that codes for how fast the delay will drop down as your score increase.

After some tests, I ended up with the learning curve shown on Figure 3. tmax was set to a reasonable 2 seconds which is okay for most entry level shooters and tmin was set to the fastest reaction time of human being plus the travel time of the bullet to the target (100 ms total). There is no need to have a system that can switch faster than you can blink your eyes! The time constant, k, was selected such that it is possible to go down to the fastest delay with a magazine of 80 BB’s which makes the theoretical maximum score of about 400 (assuming that nobody can shoot faster than tmin).

Figure 3

I have done some tests and it works but I am not completely happy with the result. I am not sure that a sigmoid would have performed better but it is possible to build composite models with a linear part at first and an exponential part at the end. The only important thing is that our model should be a pure decreasing function. That is, the delay at score #1 should always be larger than the delay at score #2 if score #1 < score #2. Failing to implement this will result in a game that is not a true description of the player shooting reactions.

Feel free to change the learning curve to test different models and to tune the parameters. Game design is a long task and there is, unfortunately, no shortcuts to make a good balanced game. Testing, testing, testing is the key!

Have fun ;-)

Assembling the controllers

To create the target read-out logic you will need to download this [∞] layout to make a custom shield for your Arduino Uno. Use stacking headers and solder the components according to Figure 4.

Figure 4

You will also need two Arduino Uno Rev3 as well as a digit shield from Nootropic Design, two proto wireless shield (without the SD functionality) and two Digi XBee series 1 (see the Introduction for a discussion on this). They are all available at [∞] Robotshop.

For the target controller, assemble an Arduino Uno Rev3, a wireless shield and the target read-out logic shield of Figure 4. For the remote score display, assemble an Arduino Uno Rev3, a wireless shield and the digit shield. You will need to program the Arduino microcontroller before you assemble the shields. Also, you will want to read the instructions for the various shields (especially the wireless shield and its usb/micro switch) before you start using them.

The programs themselves are not really difficult to understand and are based on a custom packet exchange such as discussed in [»] this post. The score display controller is the easiest and it consists of a simple loop that checks for packets to update the value displayed on the digits:

#include <DigitShield.h> #define VERSION_IDENT 'TRG0' #define MAX_BUFFER 32 struct packet_s { unsigned long ulIdent; unsigned char ucChecksum; int iCurrentScore; int iScoreMax; }; unsigned char g_ucBuffer[MAX_BUFFER]; int g_iBufferLength = 0; int g_iCurrentScore = 0; int g_iScoreMax = 0; unsigned long g_ulLastUpdate = 0; unsigned long g_ulNextBlink = 0; const unsigned long g_ulHighScoreDelay = 10000; const unsigned long g_ulHighScoreBlink = 1000; bool g_bBlinkState = false; unsigned char checksum(unsigned char *pBytes, int n) { unsigned char ret = 0; while(n--) { ret += ~(*pBytes); pBytes ++; } return ~ret; } void setup() { Serial.begin(9600); DigitShield.begin(); DigitShield.setValue(0); } void swapBuffer(int n) { if(n > g_iBufferLength) { g_iBufferLength = 0; return; } for(int i=n;i<MAX_BUFFER;i++) g_ucBuffer[i-n] = g_ucBuffer[i]; g_iBufferLength -= n; } bool processBuffer(void) { if(g_iBufferLength < sizeof(struct packet_s)) return false; struct packet_s *p = (struct packet_s*)g_ucBuffer; if(p->ulIdent != VERSION_IDENT) { swapBuffer(1); return true; } unsigned char oldChecksum = p->ucChecksum; p->ucChecksum = 0; if(checksum((unsigned char*)p, sizeof(struct packet_s)) != oldChecksum) { swapBuffer(sizeof(struct packet_s)); return true; } g_iCurrentScore = p->iCurrentScore; g_iScoreMax = p->iScoreMax; g_ulLastUpdate = millis(); swapBuffer(sizeof(struct packet_s)); return true; } void appendBuffer(void) { unsigned char b = Serial.read(); if(g_iBufferLength < MAX_BUFFER) g_ucBuffer[g_iBufferLength++] = b; } void loop() { bool bUpdateScore = false; bool bBlinkScore = false; unsigned long ulTime = millis(); while(Serial.available()) appendBuffer(); int iOldScore = g_iCurrentScore; while(processBuffer()); if(g_iCurrentScore != iOldScore) bUpdateScore = true; if((g_ulLastUpdate + g_ulHighScoreDelay) < ulTime) bBlinkScore = true; else g_bBlinkState = false; if(bBlinkScore && g_ulNextBlink < ulTime) { g_bBlinkState = !g_bBlinkState; bUpdateScore = true; g_ulNextBlink = ulTime + g_ulHighScoreBlink; } if(bUpdateScore) { if(g_bBlinkState) { DigitShield.setLeadingZeros(true); DigitShield.setValue(g_iScoreMax); } else { DigitShield.setLeadingZeros(false); DigitShield.setValue(g_iCurrentScore); } } }

The target controller is a bit more complex. At first, the system checks which targets are actually connected to the system. This was implemented in order to handle a various number of targets but in practice it should have five targets. In the main loop, the controller waits for the next target to be selected (this event occurs on initialization as well) and randomly select a target from all the connected ones. If the system has more than one target connected, it enforces that the new target is different from the previous one (to force the player to change its aim point between every targets). When a target has been selected, the controller waits for a hit-or-miss condition. When the condition is met, it enters a procedure where it waits for the next target selection event.

#define VERSION_IDENT 'TRG0' struct packet_s { unsigned long ulIdent; unsigned char ucChecksum; int iCurrentScore; int iScoreMax; }; const int g_iNumTargets = 5; int g_iTargetPinRead[g_iNumTargets] = {6, 7, 8, 9, 10}; int g_iTargetPinEnable[g_iNumTargets] = {A5, A4, A3, A2, A1}; bool g_bTargetPinConnected[g_iNumTargets] = {false, false, false, false, false}; int g_iNumConnectedTargets = 0; int g_iCurrentTargetIndex = 0; int g_iCurrentScore = 0; int g_iScoreMax = 0; const float g_fTargetDelayMax = 2000.0f; const float g_fTargetDelayMin = 100.0f; const float g_fTargetDelayConst = 0.05f; unsigned long g_ulNextTargetOff = 0; unsigned long g_ulTargetDelay = (unsigned long)g_fTargetDelayMax; unsigned long g_ulNextTargetSelection = 0; unsigned char checksum(unsigned char *pBytes, int n) { unsigned char ret = 0; while(n--) { ret += ~(*pBytes); pBytes ++; } return ~ret; } void remoteUpdate(void) { struct packet_s s; s.ulIdent = VERSION_IDENT; s.ucChecksum = 0; s.iCurrentScore = g_iCurrentScore; s.iScoreMax = g_iScoreMax; s.ucChecksum = checksum((unsigned char*)&s, sizeof(s)); Serial.write((uint8_t*)&s, sizeof(s)); } void checkTargetConnections(void) { for(int i=0;i<3;i++) { for(int j=0;j<g_iNumTargets;j++) { enableTarget(j, true); delay(125); g_bTargetPinConnected[j] |= !isTargetOpen(j); enableTarget(j, false); delay(125); } } g_iNumConnectedTargets = 0; for(int i=0;i<g_iNumTargets;i++) if(g_bTargetPinConnected[i]) g_iNumConnectedTargets ++; delay(1000); } void setup() { Serial.begin(9600); remoteUpdate(); randomSeed(analogRead(0)); for(int i=0;i<g_iNumTargets;i++) { pinMode(g_iTargetPinRead[i], INPUT); pinMode(g_iTargetPinEnable[i], OUTPUT); } checkTargetConnections(); } bool isTargetOpen(int iTargetIndex) { return digitalRead(g_iTargetPinRead[iTargetIndex]) == HIGH; } void enableTarget(int iTargetIndex, bool bEnable) { digitalWrite(g_iTargetPinEnable[iTargetIndex], bEnable ? HIGH : LOW); } void selectNextTarget(void) { if(g_iNumConnectedTargets == 0) return; int iNewTarget; if(g_iNumConnectedTargets == 1) { for(int i=0;i<g_iNumTargets;i++) if(g_bTargetPinConnected[i]) iNewTarget = i; } else { do { iNewTarget = random(g_iNumTargets); } while(iNewTarget == g_iCurrentTargetIndex || !g_bTargetPinConnected[iNewTarget]); } enableTarget(iNewTarget, true); g_iCurrentTargetIndex = iNewTarget; g_ulNextTargetOff = millis() + g_ulTargetDelay; } void recomputeTargetDelay(void) { if(g_iNumConnectedTargets == 0) return; float fScore = (float)(g_iCurrentScore / g_iNumConnectedTargets); float fNewDelay = (g_fTargetDelayMax - g_fTargetDelayMin) * exp(-g_fTargetDelayConst * fScore) + g_fTargetDelayMin; g_ulTargetDelay = (unsigned long)fNewDelay; } void waitForNextTarget(void) { if(g_iNumConnectedTargets == 0) return; g_ulNextTargetSelection = millis() + random(g_ulTargetDelay, g_ulTargetDelay * 2); enableTarget(g_iCurrentTargetIndex, false); g_ulNextTargetOff = 0; } void loop() { static bool bInitialize = true; if(g_iNumConnectedTargets == 0) { checkTargetConnections(); return; } unsigned long ulTime = millis(); if((g_ulNextTargetSelection < ulTime && g_ulNextTargetSelection != 0) || bInitialize) { selectNextTarget(); g_ulNextTargetSelection = 0; bInitialize = false; } else if(g_ulNextTargetOff == 0) return; if(g_ulNextTargetOff < ulTime) { if(g_iCurrentScore > 0 && g_iNumConnectedTargets > 0) { g_iCurrentScore --; remoteUpdate(); recomputeTargetDelay(); } waitForNextTarget(); } else if(isTargetOpen(g_iCurrentTargetIndex)) { if(g_iNumConnectedTargets == 1) g_iCurrentScore ++; else g_iCurrentScore += g_iNumConnectedTargets - 1; g_iScoreMax = max(g_iScoreMax, g_iCurrentScore); remoteUpdate(); recomputeTargetDelay(); if(g_iNumConnectedTargets == 1) { while(isTargetOpen(g_iCurrentTargetIndex)) delay(50); } waitForNextTarget(); } }
[⇈] Top of Page

You may also like:

[»] 1 Amp Power LED driver with Modulation Input

[»] DIY Conductometry

[»] Arduino Controller for our Low-Cost Syringe Pump

[»] Conductivity Shield for Arduino Uno

[»] Working with Pressure Sensors and Load Cells