Game Diary: Sprite Kit Physics is a Disaster

This is a real shame, because these APIs were the ones that initially motivated me to try this crazy idea, and I just finished ripping it out. While doing this fixed a critical issue I was having, it also leaves me with stuff that's far simpler with a lot less going on, so I guess that's a good thing.

The problem is that Sprite Kit's physics is not deterministic. This is a disaster, because if somebody solves a level, there is an expectation that their solution will always solve that level. It might seem strange that a physics simulation can even be nondeterministic. But we are simulating Physics here, not actually running it. And different circumstances can yield different simulations. The different circumstance is that if the frame rate changes at all, Sprite Kit chooses to maintain the speed of the simulation, rather than the consistency of it. This article discusses how different timesteps can change things, but it boils down to the fact that we are approximating a differential equation, and we get different solutions depending on where we sample.

The solution is fairly easy. I just have to step forwards by 1/60 of a second, rather than whatever the actual time difference is, and then work hard to never drop below 60Hz. But Sprite Kit doesn't grant me this little configuration, so I was left to write it myself. Mercifully, I had very simple physics, so it wasn't hard.

When I say that, I mean that my forces depend solely on the positions of elements, not on anything else. Further, I only have one dynamic element. Everything else is static. Hence my job is reduced to adding up forces, and then integrating twice.

Calculating the Total Force

This part of the game involves a player navigating around the world using only gravity. A player can create massive nodes, which exert a gravitational pull on the player.

The total force is given by Newton's gravitational equation:

\mathbf{F} = \frac{GMm}{r^2}

Here our units are points, which is fine, but we don't need any concept of mass. We have three variables ( \( GMm \) ) doing a job that one variable could do. Further, we don't even really need the concept of force, instead we can deal only with acceleration. (That's another one of Newton's laws, by the way: his second law of motion.)

So when we translate this to code, our equation of motion is

let acceleration = accelerationMultiplier *
    (position / position.magnitude) /
    (position.magnitude * position.magnitude)

(If you're wondering why I divide by position.magnitude three times, but in two different places, the difference is conceptual: position / position.magnitude is the unit vector in the direction of position, and the final term is the \(r^2\) in the above formula.)

I have to calculate this for every gravityWell in my game, and then add these accelerations together to find the total acceleration.

Now, every frame, we need to solve this equation and move the player accordingly. Since it only depends on position, we don't need to break out anything more sophisticated than high school calculus. We need to integrate twice to get the position. Integrating once gives us velocity, so let's start there.

We are using a fixed time step to get a deterministic simulation. Since I want my game to run at 60Hz, my time step will be 1/60 of a second. The problem we are trying to solve is how much faster we are going now compared to 1/60 of a second ago. Then we add that to the previous velocity, and that gives us our answer.

We have a few options available to us to find this mysterious change in velocity. Just for reference, the integration we are trying to approximate is

\int_{t}^{t+\delta t} a(t) \textrm{d}t

But the rub is that we do not have \(a(t)\) in a closed form that we can symbolically integrate over. We only have \(a(t)\) at the beginning and end of the interval. A good way of doing it would be to draw a straight line between them and integrate that, which would be a first order approximation. A better way of doing it would be to interpolate a quadratic curve across two intervals, which would be a second order approximation. But these would take longer to run and longer to write, and the changes in acceleration frame to frame are not going to be dramatic enough to make a meaningful difference. So I went for the zeroth order approximation, which is assuming acceleration is constant across the entire interval. This makes the area underneath it a rectangle, and means our change in velocity is acceleration / 60.

let newVelocity = velocity +
    timeInterval * acceleration

To compute the displacement, we do exactly the same procedure again. We know change in position is the integration of velocity over the small interval, and I make the assumption that velocity is constant over the integral.

The final result is

let newPosition = position +
    timeInterval * newVelocity

The simulation returns both newPosition and newVelocity, since they are both required inputs to the simulation, so we'll need them in the next frame.

As far as the logic and code goes, that's about all there is to it. Simple physics begets simple code.

There is one caveat, where we divide by the magnitude of the distance three times. If that is zero, or very close to it, we're going to have trouble. I'm trying to simulate a spaceship orbiting planets, and for friendliness reasons, I don't want it to be possible to crash into a planet. (Or maybe I do.) So I set a minimum value for this magnitude, and I use that if the actual distance is smaller than that. It reduces the maximum speed a little.

A second thing that surprised me was the value of the acceleration multiplier. I'm currently using a value of 9,500,000, which is larger than I was expecting, but it makes sense when you consider the mass of a planet.

This is a good prototype, but there is a lot more to be done here. This really is the only part of the game mechanics that can never ever be changed. I can't just drop an update that breaks everybody's solutions, so I need to nail this first time. Expect more updates as I consider things like how to pause the simulation without ruining the simulation, and any adjustments I make. Please find the finished code below.

What I love about this is that all the mathematics I wrote above was for the one dimensional case, but the code for the two dimensional case is identical, thanks in part to the vector extensions I added to Swift. This means that we just did vector based integral calculus. Fun afternoon.

struct PhysicsEngine {
    let accelerationMultiplier: CGFloat = 9_500_000
    let timeInterval: CGFloat = 1.0 / 60.0
    let minimumDistance: CGFloat = 15
    func nextPositionOfPlayerWithCurrentPosition(
        position: CGPoint,
        velocity: CGVector,
        andGravityWellPositions wellPositions: [CGPoint]
    ) -> (CGPoint, CGVector) {
        let newAcceleration = self.accelerationOfPlayerWithPosition(
            andGravityWellPositions: wellPositions
        let newVelocity = self.velocityOfPlayerWithVelocity(
            andAcceleration: newAcceleration
        let newPosition = self.positionOfPlayerWithPosition(
            andVelocity: newVelocity
        return (newPosition, newVelocity)
    func accelerationOfPlayerWithPosition(
        position: CGPoint,
        andGravityWellPositions wellPositions: [CGPoint]
    ) -> CGVector {
        // This is each gravityWell expressed as a distance from
        // the player.
        let relativePositions = {
            // We do it this way around so that we don't have to
            // multiply everything by -1, as it is in Newton's law.
        // Acceleration of player caused by each gravity well.
        let accelerations = { (x: CGVector) -> CGVector in
            // To save us from division by zero
            let magnitude = max(x.magnitude, self.minimumDistance)
            // Divide by magnitude three times. Once to make a unit
            // vector, twice because Newton's law.
            return self.accelerationMultiplier *
                (x / magnitude) / (magnitude * magnitude)

        // Resultant acceleration
        return accelerations.reduce(CGVectorMake(0, 0), +)
    func velocityOfPlayerWithVelocity(
        velocity: CGVector,
        andAcceleration acceleration: CGVector
    ) -> CGVector {
        // Approximate integration over self.timeInterval
        return velocity +
            self.timeInterval * acceleration
    func positionOfPlayerWithPosition(
        position: CGPoint,
        andVelocity velocity: CGVector
    ) -> CGPoint {
        return position +
            self.timeInterval * velocity.asPoint