Coding into the Void

Coding into the Void

A blog that I’ll probably forget about after making thirty-one posts.

Handling Inputs in Phaser 3: Appendix B: Dead Zones

In the previous appendix, we discussed KHInputScene and the tradeoffs of using it.

This time, we’re entering the dead zone.

Welcome to the Dead Zone

If you’ve ever played a game and seen your character move or turn without you pressing on any inputs, it may be due to a game having an improperly-configured dead zone.

Each thumbstick has two axes: X and Y, and each one ranges from [-1, 1], with a neutral input at (0, 0). All thumbsticks have some amount of give in their natural resting point, which means that once you release the stick, it will bounce back to a position that is not (0, 0). To account for this, dead zones are specified.

Dead zones restrict controller thumbstick axis values to deaden inputs around zero. In other words, for small deviations from (0, 0), like (0.08, 0.04), it will coerce the input to (0, 0).

If you haven’t read about derived inputs in the previous posts in the series, I’d recommend you check it out. This post is going to lean heavily on those concepts.

Additionally, I’d suggest that you read this article on dead zones, which is what clued me in on dead zones and does a better job explaining them than I do (aside from the axial image, which I find confusing).

Built-In Phaser Support

Phaser 3 does come with dead zone support out of the box. It uses an unscaled radial dead zone,1 which is fine for most purposes.

The default threshold is 0.1, and you can change it using gamepad.setAxisThreshold(). Changing the threshold will change it for all axes on the gamepad. If you plan on doing your own dead zone input modification, I’d recommend setting it to zero to prevent it from messing with your input.

Don’t Trust Player Input

This is a bit of an aside, but as you play around with the demos below, you’ll most likely notice that your controller inputs, even the raw ones, go outside of the bounds of the unit circle. This means that the maximum magnitude2 can be greater than one. This is fine for some cases (like checking for a threshold before navigating a menu), but can break other case (like multiplying a player speed by the vector to determine movement).

If you’re working in a scenario in which a larger inputs give the player an advantage, normalize3 the vector if its magnitude is greater than one. This will put players on an even playing field.

How Do Dead Zones Work?

That depends—there are limitless ways to implement a dead zones. Many have a context or two where they work in, and some are just generally bad ideas. I’ll go over a few different ways to implement them, and you can click on the demos to try them out.

No Dead Zone

An axis with no dead zone is just the raw input of the axis with no modifications. This is universally a bad experience.

Attributes

Drift will occur

No matter what controller you have, there will be some give in the thumbsticks. This means that you will essentially never receive a value of zero.

Continuous

Because of the lack of a dead zone, the inputs will increase linearly the further you move the stick in a direction.

One-Dimensional

It doesn’t take the other direction in mind when computing the value.

Matches Raw Axis at Diagonals

Since it is the raw axis, it’ll match itself.

Code

You get this by retrieving the axis directly from the controller. No derived inputs necessary.

let newXAxis = controller.getAxis(KHPadAxis.LeftStickX);

Demo

Curious as to how your controller would behave without dead zones? Click on the canvas below to try it out. Once you’re finished playing with it, you can click on it to hide it again.

Axial Dead Zone

An unscaled axial dead zone is the simplest form of dead zone. If the input of an axis is less than a threshold, it zeroes it out, otherwise it returns its current value.

Attributes

Discontinuous

The axis value will jump from zero to the threshold value once the threshold is reached, and increase linearly from there.

One-Dimensional

It doesn’t take the other direction in mind when computing the value. If the stick is all the way at (1, 0.05), the Y value will still return 0, even though it’s clear that the thumbstick is actively being used. This can result in some strange movement behavior when nearing the dead zone boundaries.

This dead zone has the effect of lessening the chance of both X and Y input at the same time, which can be desirable if it’s unlikely that the player wants to trigger both directions at the same time. For an instance where this could be useful, think of a 2D menu, where they probably will want to move right or down, but not at a diagonal.

Matches Raw Axis at Diagonals

Since the input is not modified once it passes the dead zone, it matches the magnitude of the input vector near the edges.

Code

class KHInputAxisDeadZoneAxial extends KHInputAxisDerived {
    private source: KHInputAxis;
    private deadZone: number;

    constructor(inputSet: KHInputSet, deadZone: number, source: KHInputAxis) {
        super(inputSet);
        this.source = source;
        this.deadZone = deadZone;
    }

    updateDerivedInput(now: number, delta: number) {
        let value = this.source.getValue();
        this.update(Math.abs(value) < this.deadZone ? 0 : value);
    }
}

let newXAxis = new KHInputAxisDeadZoneAxial(
    inputSet, deadZone, controller.getAxis(KHPadAxis.LeftStickX));

Demo

Curious as to how your controller would behave with axial dead zones? Click on the canvas below to try it out. LB and RB will decrease and increase the dead zone by 0.05, respectively. Once you’re finished playing with it, you can click on it to hide it again.

Scaled Axial Dead Zone

A scaled axial dead zone is a slightly more sophisticated dead zone than the unscaled one. If the input of an axis is less than a threshold, it zeroes it out, otherwise it returns its current value adjusted to be continuous.

Attributes

Continuous

Once the axis value increases to the threshold, it will linearly increase from 0-1, ensuring no discontinuities in input.

One-Dimensional

It doesn’t take the other direction in mind when computing the value. If the stick is all the way at (1, 0.05), the Y value will still return 0, even though it’s clear that the thumbstick is actively being used. This can result in some strange movement behavior when nearing the dead zone boundaries.

It has the effect of lessening the chance of both X and Y input at the same time, which can be desirable if it’s unlikely that both should be pressed at the same time. For an instance where this could be useful, think of a 2D menu, where they probably will want to move right or down, but not at a diagonal.

Undershoots Raw Axis at Diagonals

The scaling code for this input assumes that it tops out at a value of 1. For the cardinal directions, this is correct, but due to the circular shape of a thumbstick, it won’t be true when there are both X and Y components to the movement.

The higher the deadzone threshold is set, the more the derived axis will undershoot the raw axis in non-cardinal directions. Functionally, the difference is minimal at any reasonable dead zone threshold.

Code

class KHInputAxisDeadZoneScaledAxial extends KHInputAxisDerived {
    private source: KHInputAxis;
    private deadZone: number;

    constructor(inputSet: KHInputSet, deadZone: number, source: KHInputAxis) {
        super(inputSet);
        this.source = source;
        // A value is divided by (1 - this.deadZone) later, so prevent it from
        // triggering a divide by zero.
        this.deadZone = Math.min(deadZone, 0.999);
    }

    updateDerivedInput(now: number, delta: number) {
        let value = this.source.getValue();
        if (Math.abs(value) < this.deadZone) {
            this.update(0);
        } else if (value > 0) {
            this.update((value - this.deadZone) / (1 - this.deadZone));
        } else {
            this.update((value + this.deadZone) / (1 - this.deadZone));
        }
    }
}

let newXAxis = new KHInputAxisDeadZoneScaledAxial(
    inputSet, deadZone, controller.getAxis(KHPadAxis.LeftStickX));

Demo

Curious as to how your controller would behave with scaled axial dead zones? Click on the canvas below to try it out. LB and RB will decrease and increase the dead zone by 0.05, respectively. Once you’re finished playing with it, you can click on it to hide it again.

Radial Dead Zone

An unscaled radial dead zone is a slightly more sophisticated dead zone. If the vector length of the X and Y components of the axis is less than a threshold, it zeroes it out, otherwise it returns its current value.

Attributes

Discontinuous

The axis value will jump from zero to the threshold value once the threshold is reached, and increase linearly from there.

Two-Dimensional

This takes the other directional axis of the thumbstick in mind when computing the value. If the stick is all the way at (1, 0.05), the Y value will return 0.05, since it’s clear that the thumbstick is actively being used. By the same token, it’s more likely to have accidental input in the other axis, since it requires more precision to keep it straight.

Matches Raw Axis at Diagonals

Since the input is not modified once it passes the dead zone, it matches the magnitude of the input vector near the edges.

Code

class KHInputAxisDeadZoneRadial extends KHInputAxisDerived {
    private primaryAxis: KHInputAxis;
    private secondaryAxis: KHInputAxis;
    private deadZone: number;

    constructor(inputSet: KHInputSet, deadZone: number, 
            primaryAxis: KHInputAxis, secondaryAxis: KHInputAxis) {
        super(inputSet);
        this.primaryAxis = primaryAxis;
        this.secondaryAxis = secondaryAxis;
        this.deadZone = deadZone;
    }

    updateDerivedInput(now: number, delta: number) {
        let primary = this.primaryAxis.getValue();
        let secondary = this.secondaryAxis.getValue();
        const vector = new Phaser.Math.Vector2(primary, secondary);
        const magnitude = vector.length();

        this.update(magnitude < this.deadZone ? 0 : primary);
    }
}

let newXAxis = new KHInputAxisDeadZoneRadial(inputSet, deadZone, 
    controller.getAxis(KHPadAxis.LeftStickX), 
    controller.getAxis(KHPadAxis.LeftStickY));

Demo

Curious as to how your controller would behave with a radial dead zone? Click on the canvas below to try it out. LB and RB will decrease and increase the dead zone by 0.05, respectively. Once you’re finished playing with it, you can click on it to hide it again.

Scaled Radial Dead Zone

A scaled radial dead zone is generally the dead zone you want to use for in-game input. If the vector of the X and Y components of the axis is less than a threshold, it zeroes it out, otherwise it returns its current value, adjusted to be continuous.

Attributes

Continuous

Once the axis value increases to the threshold, it will linearly increase from 0-1, ensuring no discontinuities in input.

Two-Dimensional

This takes the other directional axis of the thumbstick in mind when computing the value. If the stick is all the way at (1, 0.05), the Y value will return 0.05, since it’s clear that the thumbstick is actively being used. By the same token, it’s more likely to have accidental input in the other axis, since it requires more precision to keep it straight.

Overshoots Raw Axis at Diagonals

The scaling code for this input assumes that the input vector tops out at 1. For the cardinal directions, this is correct, but because the input vector often goes outside of the unit circle when both inputs are applied, it won’t be true when there are both X and Y components to the movement.

The higher the deadzone threshold is set, the more the derived axis will overshoot the raw axis in non-cardinal directions. Functionally, the difference is minimal at any reasonable dead zone threshold. The code below avoids this property by capping the magnitude to 1, which restricts the input to the unit circle.

Code

class KHInputAxisDeadZoneScaledRadial extends KHInputAxisDerived {
    private primaryAxis: KHInputAxis;
    private secondaryAxis: KHInputAxis;
    private deadZone: number;

    constructor(inputSet: KHInputSet, deadZone: number, 
            primaryAxis: KHInputAxis, secondaryAxis: KHInputAxis) {
        super(inputSet);
        this.primaryAxis = primaryAxis;
        this.secondaryAxis = secondaryAxis;
        // A value is divided by (1 - this.deadZone) later, so prevent it from
        // triggering a divide by zero.
        this.deadZone = Math.min(deadZone, 0.999);
    }

    updateDerivedInput(now: number, delta: number) {
        let primary = this.primaryAxis.getValue();
        let secondary = this.secondaryAxis.getValue();
        const vector = new Phaser.Math.Vector2(primary, secondary);
        // Magnitude is restricted to 1, otherwise inputs beyond the unit
        // circle will be larger than input.
        const magnitude = Math.min(1, vector.length());
        if (magnitude < this.deadZone) {
            this.update(0);
        } else {
            const normalized = vector.normalize();
            this.update(normalized.x 
                * ((magnitude - this.deadZone) / (1 - this.deadZone)));
        }
    }
}

let newXAxis = new KHInputAxisDeadZoneScaledRadial(inputSet, deadZone, 
    controller.getAxis(KHPadAxis.LeftStickX), 
    controller.getAxis(KHPadAxis.LeftStickY));

Demo

Curious as to how your controller would behave with a scaled radial dead zone? Click on the canvas below to try it out. LB and RB will decrease and increase the dead zone by 0.05, respectively. Once you’re finished playing with it, you can click on it to hide it again.

Bow Tie Dead Zone

A bow tie dead zone is a combination of both a radial and a modified axial dead zone. The radial dead zone is applied as normal, with inputs below the dead zone zeroed out. Additionally, an axial dead zone is applied, with its dead zone being a linear interpolation between zero and the maximum axial dead zone value, using the opposing axis’s value as the percentage.

The two isosceles triangles pointing towards the center formed by the interpolated axial dead zones combined with the circle of the radial dead zone give the appearance of a bow tie, hence the name.

It’s also possible to do a scaled bow tie, but this post already feels like it’s far too long. It’s implemented (radial scaling, at least) in the source code.4

Attributes

Discontinuous

This setup has two discontinuities: one around the radial dead zone, and one around the modified axial dead zone.

Two-Dimensional

This takes the other directional axis of the thumbstick in mind when computing the value. If the stick is all the way at (1, 0.05), the Y value will return 0.05, since it’s clear that the thumbstick is pressed. The axial dead zone makes it less likely to have accidental other axis input.

Matches Raw Axis at Diagonals

Since the input is not modified once it passes the dead zone, it matches the magnitude of the input vector near the edges.

Code

class KHInputAxisDeadZoneBowTie extends KHInputAxisDerived {
    private primaryAxis: KHInputAxis;
    private secondaryAxis: KHInputAxis;
    private radialDeadZone: number;
    private maxAxialDeadZone: number;
    
    constructor(inputSet: KHInputSet, radialDeadZone: number, 
            maxAxialDeadZone: number, primaryAxis: KHInputAxis, 
            secondaryAxis: KHInputAxis) {
        super(inputSet);
        this.primaryAxis = primaryAxis;
        this.secondaryAxis = secondaryAxis;
        this.radialDeadZone = radialDeadZone;
        this.maxAxialDeadZone = maxAxialDeadZone;
    }

    updateDerivedInput(now: number, delta: number) {
        let primary = this.primaryAxis.getValue();
        let secondary = this.secondaryAxis.getValue();

        // The "bow tie" part of this is made up of two components: a center
        // radial dead zone and an axial dead zone that increases linearly 
        // with the opposing axis's size.
        const axialDeadZone = Math.min(1, Math.abs(secondary)) 
            * this.maxAxialDeadZone;

        // Check if the input is within the axial dead zone.
        if (Math.abs(primary) < axialDeadZone) {
            this.update(0);
            return;
        }

        // Check if the input is within the radial dead zone.
        const vector = new Phaser.Math.Vector2(primary, secondary);
        const magnitude = vector.length();
        if (magnitude < this.radialDeadZone) {
            this.update(0);
        } else {
            this.update(primary);
        }
    }
}

let newXAxis = new KHInputAxisDeadZoneBowTie(inputSet, deadZone, 0.15
    controller.getAxis(KHPadAxis.LeftStickX), 
    controller.getAxis(KHPadAxis.LeftStickY));

Demo

Curious as to how your controller would behave with a bow tie dead zone?5 Click on the canvas below to try it out. LB and RB will decrease and increase the dead zone by 0.05, respectively. Once you’re finished playing with it, you can click on it to hide it again.

Other Ways to Transform Your Input

You can build on top of dead zones too. Here’s some simple examples of how you can modify your input. All of these examples are built on top of a scaled radial axis.

4-Way D-Pad

In some case, you may want to guarantee that your axis behaves like a 4-way D-Pad: only trigger input in one of four directions at once. This is different from a D-Pad in that it will return numbers other than one, but it’s possible to build off of this to achieve that goal, if desired.6

class KHInputAxis4Way extends KHInputAxisDerived {
    private primaryAxis: KHInputAxis;
    private secondaryAxis: KHInputAxis;
    // Prefer one axis if the values are equal, otherwise it's possible that
    // both inputs have non-zero outputs.
    private preferred: boolean;
    
    constructor(inputSet: KHInputSet, primaryAxis: KHInputAxis, 
            secondaryAxis: KHInputAxis, preferred: boolean) {
        super(inputSet);
        this.primaryAxis = primaryAxis;
        this.secondaryAxis = secondaryAxis;
    }

    updateDerivedInput(now: number, delta: number) {
        let primary = this.primaryAxis.getValue();
        let secondary = this.secondaryAxis.getValue();
        let absPrimary = Math.abs(primary);
        let absSecondary = Math.abs(secondary);
        if (absPrimary > absSecondary 
                || (this.preferred && absPrimary == absSecondary)) {
            this.update(primary);
        } else {
            this.update(0);
        }
    }
}

return new KHInputAxis4Way(inputSet, 
    new KHInputAxisDeadZoneScaledRadial(
        inputSet, this.deadZone, rawPrimaryAxis, rawSecondaryAxis), 
    new KHInputAxisDeadZoneScaledRadial(
        inputSet, this.deadZone, rawSecondaryAxis, rawPrimaryAxis), 
    true);

Demo

As usual, click where it says below to try it out. LB and RB will decrease and increase the dead zone by 0.05, respectively. Once you’re finished playing with it, you can click on it to hide it again.

8-Way D-Pad

Not many controllers actually have a D-Pad that allows you to hit one direction at once; they usually allow you to press two adjacent directions. So let’s give that a try as well.

class KHInputAxis8Way extends KHInputAxisDerived {
    private primaryAxis: KHInputAxis;
    private secondaryAxis: KHInputAxis;
    
    constructor(inputSet: KHInputSet, primaryAxis: KHInputAxis, 
            secondaryAxis: KHInputAxis) {
        super(inputSet);
        this.primaryAxis = primaryAxis;
        this.secondaryAxis = secondaryAxis;
    }

    updateDerivedInput(now: number, delta: number) {
        let primary = this.primaryAxis.getValue();
        let secondary = this.secondaryAxis.getValue();
        let absPrimary = Math.abs(primary);
        let absSecondary = Math.abs(secondary);

        if (absPrimary > absSecondary * 2) {
            this.update(primary);
        } else if (absSecondary > absPrimary * 2) {
            this.update(0)
        } else {
            this.update(Math.sign(primary) * (absPrimary + absSecondary) / 2);
        }
    }
}

return new KHInputAxis8Way(inputSet, 
    new KHInputAxisDeadZoneScaledRadial(
        inputSet, this.deadZone, rawPrimaryAxis, rawSecondaryAxis), 
    new KHInputAxisDeadZoneScaledRadial(
        inputSet, this.deadZone, rawSecondaryAxis, rawPrimaryAxis));

Demo

As usual, click where it says below to try it out. LB and RB will decrease and increase the dead zone by 0.05, respectively. Once you’re finished playing with it, you can click on it to hide it again.

Final Advice

If you’re taking in analog input, it’s hard to go wrong with scaled radial input. If you’re taking in menu input, or input where you expect one axis at a time, scaled axial is the safest bet.

If you want to see the source code for the demos, as well as for the input system we’ve been discussing, it’s in the repository here.

In the final planned post for this series, we talk about how to set up controller vibrations in a way that won’t annoy people using the keyboard.


  1. See the code here. ↩︎

  2. Length of the input vector, or the distance of the thumbstick from the origin. ↩︎

  3. Divide each component of the vector by its magnitude. Be careful with this; if your vector’s magnitude is less than one, normalizing it will increase the strength of the input. ↩︎

  4. Link at the bottom, which this footnote helpfully took you near to. ↩︎

  5. The maximum axial dead zone is locked at 0.15 for this demo. ↩︎

  6. Imagine KHInputAxisMaximalize (maybe choose a better name), which transforms any input less than 0 to -1 and any input greater than 0 to 1. ↩︎