Although the controller is now operational, it has a few bugs that need to be ironed out and a couple of controls that need to be tweaked to make the game more playable. I learned a lot from the playtests so now it’s time to make some changes.

There are three problems with the controller that need to be fixed: the flashbang, the aiming accuracy, and the aiming speed.

This post is part of a series on creating a custom controller for Overwatch using a Nerf revolver.  Check out the project page here.

Flashbang Fixes

The flashbang is McCree’s go-to utility for getting quick kills on squishy characters. As the first playtest showed, I was having issues with both triggering the flashbang and having my aim fly all over the place while using it.

Use the Force

The flashbang ability is set to trigger when I flick my wrist to the right. During my tests while writing the code this seemed to work perfectly. But when I was in the middle of a deathmatch I frequently had trouble activating it.

It turns out that when I had tracked my aim on an enemy and they were close enough to flash, the controller often wasn’t directly in front of me. Rather than twisting my arm from the elbow, I was only able to turn the gun from my wrist. Couple that with being a little flustered while playing and I would regularly not be able to activate the flashbang while needed.

I lowered the twist threshold from 250 °/s to ~168 °/s, which allows me to trigger the ability more easily without creating too many false-positives. I think the ability would be better if I could trigger it with even less rotation, but if I did that I can’t think of a way to distinguish a “flash” from a sideways aim flick. In the future I’ll likely also need to be more mindful of the orientation of the controller in space.

This is somewhat related to the aiming issue, as having the controller directly in front of me fixes the problem.

Accidental Reloads

Although I often had problems triggering the flashbang, I also had an issue where after triggering the flashbang the mouse would wildly swing up and to the right even though I was holding the capacitive button.

The detents for the rotary encoder attached to the cylinder aren’t very strong, so when I would twist the gun to flash it would turn the cylinder and trigger a reload. I thought this wouldn’t be a problem because the flashbang animation would override the reload button press, but it turns out that there’s a tangential problem: the capacitive sensor.

If the cylinder rotated far enough, it “reloaded” the gun and reset the capacitive sensor calibration. This would happen in the middle of the flash movement, causing the last bit of my animation to be read by the aiming function and swing my mouse cursor out of the way. I didn’t notice this during testing because I never twisted the controller hard enough. In the middle of a tense deathmatch, I would occasionally get a bit flustered and flick the controller a bit too hard or at an odd angle.

I initially added the capacitive calibration reset to the reload function because there seems to be crosstalk from the rotary encoder interfering with the capacitive baseline. When I spin the cylinder (encoder) the capacitive sensor instantly jumps up to a higher baseline value, so I can’t reasonably remove it and have the controller function normally.

void cylinderReload(){
  const long flashTimeout = 150;
  
  if(timestamp <= lastFlash + flashTimeout ){
    cylinder.write(0);
    return;
  }
 ...
}

My solution was to add a software debounce to the reloading when the flash is triggered. I’m disabling the cylinder (and the recalibration) for a short period after every flash, and resetting the encoder position during this period. This should prevent the cylinder’s accidental spinning when flashing from resetting the calibration, but it won’t prevent the inductive effects. Hopefully I’ll be firing after every flash however, which will reset the calibration then.

Aiming Accuracy

One of the things I noticed through the playtest was that my aiming was a little… off.

It wasn’t bad by any means, but it didn’t quite feel right. Whenever I was holding the controller “steady” the cursor still wobbled, and it almost seemed like it was vibrating. I had chalked this up to shaky hands, but it seemed more severe than that.

Demonstrating the controller’s range. Note the shaking at the beginning and end of the movements.

Now, there are two deadzone checks in the code to nullify tiny inputs. The first is in the MPU calibration code that nullifies movements that are smaller than ~0.008°. This is to try to filter out noise. The second is in the aiming code that nullifies mouse tick remainders smaller than 0.1 (arbitrarily set). This is to avoid building up floating point inaccuracy, although it’s probably a bit heavy-handed.

There are also two checks in the aiming function to only process non-zero inputs. The first is at the start of the function, where if both inputs are zero it immediately exits. The second is during the aiming calculation, where a zero input on either channel (x / y) results in no processing for that channel, which is faster and avoids a reading of -1 that comes with the bitwise inversion.

Yet with all of these checks, the aiming still seemed jittery. I decided to take a closer look, and in doing so found two simple bugs causing the aiming to oscillate.

  int16_t * xyInputs[2] = {&gyX, &gyY};
  static float xyRemainder[2];
  float xyScaled[2];

  for(int i = 0; i < 2; i++){
    if(xyInputs[i] != 0){  
      xyScaled[i] = OverwatchConversion * (float) ~*xyInputs[i];
      
      float remainderTemp = xyScaled[i] - (int32_t) xyScaled[i];
      if(abs(remainderTemp) >= 0.1){
        xyRemainder[i] += remainderTemp;
      }   

      if(xyRemainder[i] >= 1){
        xyScaled[i] += 1;
        xyRemainder[i]--;
      }
      else if(xyRemainder[i] <= 1){
        xyScaled[i] -= 1;
        xyRemainder[i]++;
      }
    }
  }

Here’s the aiming calculation. Can you spot the two mistakes? (If you find more, let me know!)

Bug #1: Negative Signs are Important

This is one of those really stupid typos that causes a lot of problems.

      if(xyRemainder[i] >= 1){
        xyScaled[i] += 1;
        xyRemainder[i]--;
      }
      else if(xyRemainder[i] <= 1){
        xyScaled[i] -= 1;
        xyRemainder[i]++;
      }

I forgot the negative sign! Rather than evaluating whether the remainder was greater than the absolute value of 1, the if statement pair checked whether the remainder was either greater than or less than 1. If the remainder was not greater than or equal to 1, the program would always evaluate the second statement as true.

This would cause the mouse to “vibrate”. The falsely “true” second statement would add 1 to the remainder for any cumulative remainder less than 1. This causes the first statement to be true on the next sample. This subtracts one from the remainder, again making the second statement falsely true. For small inputs the mouse would see {-1, +1, -1, +1, -1, +1…} indefinitely until either both inputs were zero or a gross input was made. Adding the negative sign makes the remainders behave as expected.

Bug #2: So Are Dereference Operators

In my troubleshooting to try and fix the “floating” cursor issue, I significantly increased the threshold for zeroing inputs from the gyroscope and then tested the aim by rotating the controller slowly along one axis. What I found was that for large movements it seemed to work fine, but for very fine movements along one axis the cursor seemed to move diagonally.

I eventually traced the source of these diagonal movements to the negative sign above, but I was still confused: why was that remainder statement being evaluated at all? If an axis input is zero it shouldn’t even do the calculation where the bug above is located.

    if(xyInputs[i] != 0){

I forgot to dereference the pointer to the gyroscope values! The if statement to check if the gyroscope value being evaluated is zero is never true because without the dereference operator (*) it doesn’t evaluate the value but rather the memory address, which is never zero since it’s equal to the address of the gyroscope value. Adding the operator fixes the bug.

Ironically, I think it would have taken me longer to find the first bug if this was working properly, as the zero’d axis wouldn’t have been moving during my tests.


With both of these bugs fixed, the aiming is now steady and buttery smooth. I actually cut the gyroscope zero-ing threshold and the remainder cutoff thresholds in half, now that I can squeeze the most out of fine movements.

Aiming Speed

Although the aiming is now more accurate, it’s far from the controller’s biggest problem. The controller’s biggest problem is in fact the speed of the aiming setup.

For the first two playtests the gyroscope was set to be slightly faster than 1:1. This means that moving the controller 90° in real life moves the character’s view 90° in the game. Although I’m happy that I figured-out how to do this, it does not play well. For an example, take a look at what happens when I meet the world’s worst Genji:

I have to make these large sweeping movements just to be able to keep up with him (and I can’t even do that!). The second flashbang was a false-positive, triggered because I’m moving the controller so aggressively.

McCree is a short-range to mid-range hero, as his weapon has significant damage falloff above 20 meters. This means that to play him well I need to engage at mid-range or close range (the latter usually with his flashbang). But when playing I realized that it’s far too difficult to turn quickly with these current settings. If I’m fighting a hero close-by that walks behind me the engagement is done; I simply can’t turn around fast enough to win the fight. This is especially a problem with fast-moving heroes like Tracer, Genji, and Doomfist – ironically the exact heroes McCree is supposed to be best at countering.

I can think of four different ways to possibly fix this.

Option 1: Increase the Sensitivity

The first option is the most obvious: if the sensitivity is too low, why not increase it?

In short: aim small, miss small. In other words, bumping up the aim speed to be able to turn faster in fights also makes it harder to aim precisely to land hits on enemies. And if you can’t hit your targets, what’s the point in being able to track them better?

Look at how far my crosshair is moving for how little I’m moving the controller, not to mention the shaking from my hands. It’s much more difficult to hit accurate shots. (I’m also getting a little frustrated at being spawned into an ultimate.) This was at 2.5:1.

The sensitivity for the first two tests was at 1.25:1 (game : real world). I tried testing at both 2.5:1 and 1.66:1 and my concerns about accuracy problems were right on the nose. At 2.5:1 I can quite easily whip the controller around in the middle of a fight (72° for 180° of aiming) but I can no longer reliably hit shots as the shaking of my hands is translated into more significant cursor movement. The difference is evident even at 1.66:1 – aiming is noticeably more difficult and turning is better but not significantly so.

Note: In-game sensitivity doesn’t particularly matter for this point, just so long as the sensitivity is low enough to give the controller a fine resolution. In gaming terms, I’m not “skipping pixels”.

This option is a lost cause. Any sensitivity where aiming is slow enough to be accurate will be too low to turn the character around, and any sensitivity where I can turn the character quickly will be too fast for accurate aiming. Back to the drawing board.

Option 2: Use a Sensitivity Switch

The next logical option would be to use both sensitivities. Rather than locking the controller at one speed, add a toggle switch so that I can quickly move between them. This would give me a working setting for aiming and another for turning. The issue with this is that the controller currently has only one switch on it: the capacitive sensor.

At the moment the capacitive sensor is used to entirely disable mouse inputs, which allows me to do things like flash without the aim moving or use my ultimate without staring at the ground. These capabilities would be lost if I repurposed the switch.

I could still use those abilities by doing things like aiming up before I use my ultimate or pre-aiming to the left of my target before flashing. But that quite honestly seems more awkward than dealing with the slow aiming as-is.

I can think of some other workarounds, like requiring both the capacitive switch and another button to be pressed before inputs are disabled. But the real answer is to re-do the hardware and insert another switch or button somewhere (capacitive or not). I don’t know where I would put another easily-manipulable switch and a change like that would also require making another circuit board. So for the time being this option’s out.

Option 3: Use Joystick Aiming

A third option would be to ditch the rotation-based aiming and instead use something like joystick aiming. Rather than move the mouse based on how far the controller rotates, move the mouse continuously based on the distance from a zero-point. I can still use the gyroscope for this, using rotation as my joystick vector.

The downsides to this are the same downsides you have with a traditional joystick: since you’re moving based on a direction and a vector rather than point-to-point, precise movements aren’t possible. This is why basically every console FPS game has some form of auto-aim: you simply cannot make the same precise movements that you can with a mouse.

Nevertheless I programmed an implementation and tried it out. To my surprise, it actually works pretty well. It’s more intuitive than I thought it would be – move the controller in a given direction just like a joystick, move it back to center (or press the capacitive button) to reset. Code below.

float xyTotal[2];

void handleIMU() {
  ...
  // If capacitive button is triggered, don't aim
  if(capRead() == true){
    xyTotal[0] = 0;
    xyTotal[1] = 0;
  }
  ...
}

void joyAim(int16_t gyX, int16_t gyY){
  /* Requires the IMU + Overwatch variables from the aiming function as well */
  ...
  const int16_t MaxQ = 500; // Max speed, in degrees per second
  const int16_t JoyDeadzone = 3; // in degrees
  const int16_t JoyRange = 60; // in degrees

  const float OW_MaxQ = (float) MaxQ * (OverwatchTPD / OverwatchSens) / (1000.0 / (float) IMU_UpdateRate); // max speed, keeping update rate into account
  const float OW_JoyDeadzone = ((float) JoyDeadzone * (OverwatchTPD / OverwatchSens)) / 2.0; // Dead zone in terms of mouse ticks, div/2 for half on each side
  const float OW_JoyRange = (float) JoyRange * (OverwatchTPD / OverwatchSens); // Joy range in mouse ticks
  
  currentLED = runningColor;
  
  int16_t * xyInputs[2] = {&gyX, &gyY};
  float xyScaled[2];

  // Calculate aiming values
  for(int i = 0; i < 2; i++){
    if(*xyInputs[i] != 0){
      xyScaled[i] = OverwatchConversion * (float) ~*xyInputs[i]; // Flip gyro inputs to match mouse axis, then calculate

      xyTotal[i] += xyScaled[i];
    }

    // Joystick calculations
    float sign = 1.0;
    if(xyTotal[i] < 0){
      sign = -1.0;
    }

    if(abs(xyTotal[i]) > OW_JoyDeadzone){
      // If at max or beyond, set to max movement
      if(abs(xyTotal[i]) >= OW_JoyDeadzone + OW_JoyRange){
        xyScaled[i] = OW_MaxQ * sign;
      }
      else{
        // Scale movement
        float newAim = abs(xyTotal[i]) - OW_JoyDeadzone;
        newAim /= OW_JoyRange; // 0-1 value for percentage in range
        xyScaled[i] = newAim * OW_MaxQ * sign;
      }
    }
    else{
      xyScaled[i] = 0;
    }
  }

  if(xyScaled[0] != 0 || xyScaled[1] != 0){
    Mouse.move((int) xyScaled[0], (int) xyScaled[1]);
  }
}

The crosshair will not move within 3 degrees of the zero point about the center line (1.5° each side). Outside of that range it’s scaled to the maximum value according to its position within the joystick’s range. In the code above I’m using a max value of 500 degrees per second over a range of 60 degrees. These calibration values seem to work fairly well – I can play the game keeping the controller in front of me at all times and still aim reasonably well. Note that I also had to remove the initial ‘0’ checks for both inputs so that the joystick will keep moving if the player’s hand is still.

The problems are as expected: turning works well, but not being able to make precise movements quickly is a problem. You can make accurate hits on two still targets but it takes a much longer period of time to get the crosshair in position. I’ll hesitantly call this method “playable”, and for other games it might be the method of choice. Although it’s definitely not ideal for a game like Overwatch.

Option 4: Use Hybrid Joystick Aiming

The fourth option is to combine both rotation-based aiming and joystick aiming: aim 1:1 within the deadzone and then use joystick aiming outside of it. In my mind this was going to be the ideal solution, as I can aim precisely but still turn quickly if need be. Both functions are written, I just need to combine them.

Unfortunately this didn’t work nearly as well as I had hoped, mostly because of the transition between the two settings. The ‘joystick’ doesn’t take into account the controller’s inertia so there is a very noticeable point where the aim speed pauses. It might be possible to work around this by “importing” the current speed when the transition happens, but then that introduces other issues for scaling in the joystick range. It technically works, but it feels awful to use.

There’s also an issue of range. There needs to be enough space in front of the player to aim accurately with the 1:1 aiming and enough space to have a degree of adjustability with the joystick’s range. To keep the controller in front of the player with a joystick range of just 30° the dead zone needs to be small (< ~60°). This means that the aim sensitivity needs to go up in order to be able to make significant-enough movements in that small space – which is counterproductive. If we assume the joystick has no range and goes to full speed the moment the controller leaves the deadzone then the range issue is fixed, but the transition speed difference is absurd.

To make matters worse, there is no yaw position sensor inside of the controller so the “zero point” tends to drift. This means that even if I were to use the hybrid aiming with zero range on the joystick, the point at which it activates would drift constantly. So this idea is a dud.

Option 5: Use Mouse Acceleration

Here’s an idea so basic it took me a full week to think of it: why not add mouse acceleration into the mix?

The downsides are obvious: moving the controller (‘mouse’) the same distance will cause the in-game cursor to move different distances depending on the speed at which it’s moved. This is a strict no-no for mouse movement in most FPS games, as it really messes with your muscle memory. But for a controller like this it might just be the right answer, as it would allow me to do fine movements and gross movements using the same function (so I wouldn’t need to worry about the transition between algorithms).

The finished mixed exponential algorithm on top of the linear algorithm. These are the minimum and maximum values per sample.

Overwatch has no mouse acceleration option, but I can build this into the controller itself. Squaring the mouse function’s output gives me exponential gain, but there are two problems:

  1. It’s FAR too aggressive. The max output for 7.5 sensitivity at 1:1 goes from 50 ticks to 2500 ticks.
  2. Small (slow) inputs are even smaller than they would be with 1:1 aiming.

The first problem can be fixed by either using a smaller exponent or a scalar less than 1. I chose the latter, as it’s more intuitive and likely faster to calculate on the fly. A scalar of 0.05 seems to work well.

The second problem is fixed by combining the exponential function with the linear one. The output is linear up until a given threshold (currently 125 °/s), at which point the exponential output (starting at 0) is added to the linear output. This keeps the mouse movement consistent for precise aiming and smooth when transitioning between the two output types.

This works much better than the straight 1:1 aiming, and I think it’s going to be the definitive solution. I might have to tweak the threshold and scalar values after playtesting, though. Here is the modified aiming code:

const int16_t Aim_ExponentialThreshold = 125; // Threshold before exponential aiming kicks in, in °/s
const float   Aim_ExponentFactor = 0.05; // Factor to multiply the exponent product by, scaled by sensitivity

void aiming(int16_t gyX, int16_t gyY){
  ...
  const float OW_ExponentialThreshold = (Aim_ExponentialThreshold / Gyro_FullScaleRange[FS_Sel]) * OverwatchConversion * 32768.0; // exponential aim threshold, in (overwatch ticks / max overwatch ticks per sample)
  const float Aim_ExponentFactorScale = Aim_ExponentFactor * (OverwatchSens / 7.5); // Set using 7.5 sensitivity   
  ...

  int16_t * xyInputs[2] = {&gyX, &gyY};
  float xyScaled[2];

  for (int i = 0; i < 2; i++) {
    if (*xyInputs[i] != 0) {
      xyScaled[i] = OverwatchConversion * (float) ~*xyInputs[i]; // Flip gyro inputs to match mouse axis, then calculate

      // Exponent function. Linear baseline + (squared baseline * minimizing factor)
      if(abs(xyScaled[i]) >= OW_ExponentialThreshold){    
        float mouseSign = 1; // Faster than x/|x|
        if(xyScaled[i] < 0){
          mouseSign = -1; 
        }
        
        xyScaled[i] +=
          abs(xyScaled[i] - OW_ExponentialThreshold) * abs(xyScaled[i] - OW_ExponentialThreshold) *
          Aim_ExponentFactorScale *
          mouseSign;
      }
    }
  }
  ...
}

There are more options than these five to fix the aim speed issues, but I think the acceleration-based aiming using the gyroscope output is going to be the way to go. It’s fast, smooth, and intuitive.

Aiming Helper Function

The aiming tweaks are almost done, but there’s just one last step. When I started playing around with exponential algorithms my initial equations had huge outputs, and I noticed that the on-screen mouse was not moving as expected. It turns out that the ‘move’ function in the Arduino’s Mouse library can only accept 8-bit signed integers as inputs (-128 to +127). Anything larger will cause an overflow.

Because of this, I created a separate function to handle large numbers and floating point inputs (the ‘remainder’ code previously inside of the aiming function).

void sendMouse(float mX, float mY){
  static float xyRemainder[2];

  float * flt_mouseXY[2] = {&mX, &mY};
  int32_t int_mouseXY[2] = {(int32_t) mX, (int32_t) mY};
    
  // Store floating point remainders and insert them if > 1
  for (int i = 0; i < 2; i++) {
    if (*flt_mouseXY[i] != 0) {
      float remainderTemp = *flt_mouseXY[i] - int_mouseXY[i];
      
      if (abs(remainderTemp) >= 0.05) {
        xyRemainder[i] += remainderTemp;
      }

      if (xyRemainder[i] > 1) {
        int_mouseXY[i] += 1;
        xyRemainder[i]--;
      }
      else if (xyRemainder[i] < -1) {
        int_mouseXY[i] -= 1;
        xyRemainder[i]++;
      }
    }
  }

  // Calculate +/- signs beforehand to save cycles
  int8_t mouseSign[2] = {1, 1};
  for(int i = 0; i < 2; i++){
    // Single if statement faster than x/|x|
    if(int_mouseXY[i] < 0){
      mouseSign[i] = -1;
    }
  }

  // Send mouse outputs in iterations, to avoid the int8_t limit of the Mouse.move function
  while(int_mouseXY[0]!= 0 || int_mouseXY[1] != 0){
    int8_t mouseUpdate[2];
    
    for(int i = 0; i < 2; i++){
      if(abs(int_mouseXY[i]) >= 127){
        mouseUpdate[i] = 127 * mouseSign[i];
        int_mouseXY[i] = (abs(int_mouseXY[i]) - 127) * (int32_t) mouseSign[i];
      }
      else if(abs(int_mouseXY[i]) > 0){
        mouseUpdate[i] = (int8_t) int_mouseXY[i];
        int_mouseXY[i] = 0;
      }
      else{
        mouseUpdate[i] = 0;
      }
    }
    Mouse.move(mouseUpdate[0], mouseUpdate[1]);
  }
}

The function takes floating point parameters, stores the remainders <1 for the next sample, and then loops the Mouse.move function until the values for the sample have been sent. With the current tuning values for the sensitivity (7.5 / 1:1) and exponential output (125 °/s threshold and 0.05 scalar) this loop will never be needed, as the max is |123| including any remainder addition. But it’s good to have it in the pipeline in case any settings are changed.

An Aside: Movement

The last thing to tweak on the controller is the movement setup. As the play tests showed, movement is a bit awkward with the dance pad. The pads are spread out and in a dance game you usually wouldn’t press the pads on a diagonal (e.g. “Up” and “Left”) simultaneously, like you would in Overwatch strafing around a corner. I noticed that I tended to simply hold “forward” and use the aiming to get around rather than using a combination of keys. This would frequently get me into trouble because I would accidentally walk straight past a target I was shooting at (see the second flashbang gif, above).

After playing a few more games, it was clear that much of this is a problem with needing to get used to the dance pad and not necessarily an issue with the pad setup itself. Time will tell if I need to swap it out for a more elegant solution – I ordered a Wii Nunchuk that’s warming up on the launch pad just in-case.

Conclusion

With these tweaks the controller is playing better than ever. I can trigger all of the abilities reliably, the capacitive sensor is working as-intended, and the aiming is now smooth, quick-enough to navigate, and precise-enough to hit shots. One or two more playtests and I think I might be able to declare this project “done”!

Next Post: Nunchuk Movement


Leave a Reply

Avatar placeholder

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Would you like to know more?