Schrödinger’s bug

Any programmer with sufficient experience has probably encountered a Heisenbug. That is, a bug that goes away when you try to debug it. The name is, of course, a pun on Heisenberg whose uncertainty principle describes a similar situation in the world of quantum physics. But its twin, the Schrödinbug, is even more elusive.

The Schrödinbug is a bug that doesn’t happen until someone checks the code and realises that it shouldn’t ever have worked. And henceforth, earlier and previously tested versions of the code also no longer work. This one is a pun on Schrödinger and his hypothetical cat.

“I can’t believe that!” said Alice.
“Can’t you?” the Queen said in a pitying tone.

— Lewis Carroll, Alice in Wonderland

A student asked how to implement a jump using Unity’s CharacterController component.

The Unity documentation handily provides an example which does that. Not just that. It also applies horizontal movement. So what follows is the example code with the horizontal movement removed. That is, this code performs a jump, but no other movement.

JavaScript
using UnityEngine;

public class Example : MonoBehaviour
{
    private CharacterController controller;
    private Vector3 playerVelocity;
    private bool groundedPlayer;
    private float jumpHeight = 1.0f;
    private float gravityValue = -9.81f;

    private void Start()
    {
        controller = gameObject.AddComponent<CharacterController>();
    }

    void Update()
    {
        groundedPlayer = controller.isGrounded;
        if (groundedPlayer && playerVelocity.y < 0)
        {
            playerVelocity.y = 0f;
        }

        // Makes the player jump
        if (Input.GetButtonDown("Jump") && groundedPlayer)
        {
            playerVelocity.y += Mathf.Sqrt(jumpHeight * -2.0f * gravityValue);
        }

        playerVelocity.y += gravityValue * Time.deltaTime;
        controller.Move(playerVelocity * Time.deltaTime);
    }
}

Except it doesn’t. At least, it doesn’t jump reliably on my machine. Your mileage may vary because (as we will see) this bug depends on the machine that runs it. For me, nine times out of ten, pressing the jump button (which is assigned to the space bar by default) does nothing.

The code looks fairly familiar. In particular, looking at the last two lines, it is using the Symplectic Euler Integration method that I wrote about recently. And like that method, this code also suffers from falling short. However, that inaccuracy is not responsible for this bug.

Adding a debug log line before testing the jump, we can see that the groundedPlayer flag is not consistently set when the player object is sitting on the ground. The ratio of True and False values over multiple frames defines the probability that pressing the jump button will result in a jump. In a sense, the code is making a dynamic measurement of its own runtime environment and changing its behaviour depending on the result.

But why should the groundedPlayer flag ever be False while the player is on the ground? We can see from the code, that this is sampled from the controller.isGrounded property. And looking into the documentation for this property, we get a clue for what might be happening. This clue is phrased in the form of a question.

Was the CharacterController touching the ground during the last move?

— Unity documentation

Changing the code so that the Move call only happens when the CharacterController is falling or the player has pressed the jump key, this particular problem goes away. Although it introduces a new issue, where the CharacterController would effectively no longer react to changes in the environment. For example, if the surface that the CharacterController was standing on were a trap door, and it fell away, the CharacterController would be left hanging in the air. This would be the equivalent of putting an object to sleep in PhysX, but without its accompanying wake up call.

However, the example code does call Move every frame because the CharacterController is always experiencing a downward acceleration due to gravity. And yet still the isGrounded flag is not reliably getting set each frame that the CharacterController is on the ground. To analyse what is going on I got the example code to output both the time and y position of the CharacterController as a CSV file and put that into a graph:

Three things seem odd about this graph. The first is that it doesn’t rest at height 1, but rather 1.08. Secondly, the peak of the jump appears flattened. And finally, and perhaps most importantly, I expected to see it jitter while on the ground showing times when the isGrounded flag wasn’t set. But instead, it is perfectly flat. A look at the values in the CharacterController component generated by the example code explains some of these oddities.

The Skin Width value of 0.08 explains why the CharacterController rests at 1.08 rather than 1. It is adding this value to the collider to push the object above the surface. And the Min Move Distance value explains why the jump is flattened at the top where the movement per frame was reduced to a distance less than 0.001.

A look at the documentation for this latter parameter gives us a second clue:

If the character tries to move less than this distance, it will not move at all.

— Unity documentation

Putting these two clues together: if the CharacterController moves less than the minMoveDistance, then it doesn’t move, and doesn’t collide with the ground, and sets the isGrounded flag to False. Assuming we start in a grounded state, the movement in the first frame should be:

motion = playerVelocity_{y} \times Time.deltaTime

And playerVelocity.y in turn should be:

playerVelocity_{y} = gravityValue \times Time.deltaTime

Therefore:

motion = gravityValue \times (Time.deltaTime)^2

Re-arrange this in terms of Time.deltaTime and we get:

(Time.deltaTime)^2 = \frac{motion}{gravityValue}

And:

Time.deltaTime = \sqrt{\frac{motion}{gravityValue}}

So given:

motion = minMoveDistance = 0.001 \\
gravityValue = 9.81

The critical timestep is:

\sqrt{\frac{0.001}{9.81}} \approx 0.01

Or, 100 frames per second.

So, at an evenly paced frame rate slightly lower than 100 frames per second, the minimum move distance will be reached each frame and the isGrounded flag will always be set. But with higher frame rates, or uneven frame pacing, the move distance will not be reached and the isGrounded flag will not be set.

And now I think I see why this bug has hidden for so long. This bug can be traced back to as early as Unity 2019.

Earlier versions still have the same code issues but the bug doesn’t show for two additional reasons. Firstly, the code uses GetButton rather than GetButtonDown to trigger the jump. This means that there might be a small lag between pressing the button and the jump triggering, but it is unlikely that the user input will be quick enough to miss a grounded frame. And secondly, the Unity Editor doesn’t run at an unlocked frame rate and so is locked to the vsync of your monitor. However, I think with a modern high refresh rate monitor and keyboard it might be possible to see this at least as far back as Unity 2017.

So, have I finally uncovered a Schrödinbug? I certainly discovered this only recently; it should never have worked; and now it can be seen to not work in versions reaching back 7 years.

Five impossible things to go, then I can have breakfast!


Comments

Leave a Reply

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.