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.
— Lewis Carroll, Alice in Wonderland
“Can’t you?” the Queen said in a pitying tone.
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.
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!
Leave a Reply