Fill 'er up!

16 May 2014

Two topics for this post: fuel pod game logic and better replays. I’ll keep it short since I’m starting to feel the ratio of time spent between writing these posts and developing the actual game is getting a little skewed towards the writing thing ;-)

Fuel pickup game logic

With orb lifting in place, I thought it would be a good time to add the fuel pickup logic to the game. The way this works is as follows: when the player activates the tractor beam and there’s a fuel pod directly below the player ship, in range of the tractor beam, fuel will be transferred from the fuel pod to the player, as long as the tractor beam stays active and the fuel pod remains within range. When the fuel pod level hits 0, it ‘dies’ (disappears).

To implement this feature, I first had to add properties to K14Entity and K14Ship to be able to track whether they are being ‘tractored’ by the player. The K14Ship step method is responsible for updating these properties: whenever the tractor beam is activated, it checks whether there’s an entity within range of the tractor beam. If the entity class is of a type that can be tractored (currently only K14Entity) it is marked as tractored by setting a property on it, and a reference to it is stored with the K14Ship entity. If multiple matching entities are in range, the closest one is tractored. In subsequent K14Ship step invocations it is checked that the tractor beam is still active and the tractored entity still in range, if any of these conditions are violated the entity is ‘untractored’.

Here’s a snippet from the K14Ship step method that illustrates the fuel pod tractoring logic. Note that entity classes other than K14FuelPod are completely ignored, as none of them can be tractored at this point except K14Orb, which needs special treatment to allow lifting it:

-(void) step: (NSTimeInterval) dt
{
  // Handle tractor beam game logic
  self.tractorBeamActive = [self.planet.game.inputs isInputActive:K14_INPUT_TRACTOR_BEAM];

  if (self.tractorBeamActive)
  {
    // ...
        
    // Check for fuel pods directly below the ship
    NSArray *fuel_pod_entities = [self.planet peekFromPosition:self.position
                                                         angle:-M_PI_2
                                                           fov:M_PI_4
                                                   maxDistance:TRACTOR_BEAM_RANGE
                                           filterByEntityClass:K14FuelPod.entityClass];
    
    if (fuel_pod_entities.count != 0)
    {
      // If there are multiple fuel pods in range, they will be returned in sorted order
      K14FuelPod *fuel_pod = (K14FuelPod *) fuel_pod_entities[0];
      
      // In case a different fuel pod was previously marked as tractored, unmark it
      if ((self.tractoredEntity != nil) && (self.tractoredEntity.id != fuel_pod.id))
      {
        self.tractoredEntity.tractored = NO;
      }
      
      // Consume fuel, unless the fuel pod is empty
      if (fuel_pod.fuel >= 0.0)
      {
        self.planet.game.player.fuel += MIN(dt * fuel_pod.depletionRate, fuel_pod.fuel);
      }
      
      // Mark fuel pod as being tractored
      self.tractoredEntity = fuel_pod;
      self.tractoredEntity.tractored = YES;
    }
    else
    {
      // No fuel pods visible. In case there one was tractored before, 
      // unmark it as being tractored
      if (self.tractoredEntity != nil)
      {
        self.tractoredEntity.tractored = NO;
        self.tractoredEntity = nil;
      }
    }
  }
  else
  {
    // Tractor beam inactive.
       
    // ...

    // Unmark tractored entity, in case there was one
    if (self.tractoredEntity != nil)
    {
      self.tractoredEntity.tractored = NO;
      self.tractoredEntity = nil;
    }
  }
}

The K14FuelPod class got a few extra properties to track its capacity, fuel level and depletion rate in units per second. As you can see from the snippet, the ‘consumption side’ of the refueling process is implemented as part of the K14Ship logic. I chose to implement the ‘production/depletion side’ logic inside the K14FuelPod step method, which I think makes more sense since it needs to directly modify the K14FuelPod entity, but I might reconsider this later. Here’s the fuel pod step method:

-(void) step: (NSTimeInterval) dt
{
  // Decrease fuel if tractored
  if (self.tractored)
  {
    self.fuel = MAX(self.fuel - dt * self.depletionRate, 0.0f);
  }
  
  // Kill pod if empty
  if (self.fuel == 0.0f)
  {
    self.state = K14_DEAD;
  }
}

Everything considered this is all pretty basic, but it works surprisingly well. I added a unit test that simulates fuel pickup step-by-step, including a few corner cases such as moving away from the fuel pod while refueling until it’s out of range, fully depleting a fuel pod, and switching between 2 non-empty fuel pods located very close to each other by moving the player ship. This all worked first time around, after which I tweaked the replay I’ve been using so far to hover above the fuel pod on the left side of the planet, tractoring it, then moving to the orb to pick it up. Here’s a video of the replay:

I used the K14FuelPod debug color to indicate the fuel pod level, where green means ‘full’ and red means ‘empty’. In this replay, the fuel pod is not depleted completely, but if it were, it would simply disappear, cleaned up by the K14Planet step method that throws out any entity flagged as ‘dead’.

Better replays

Manually setting up the replay input events to get to the above replay required a lot of tweaking and trial-and-error, so I was pretty happy when I found the magic combination that did the trick. Then I tried to capture the replay video, only to find out that replaying the video outside of the XCode debugger produced a markedly different replay. Running the same replay on another computer also produced different results.

Since the only external factor affecting the sequence of states the game goes through is time, I figured the problem was related to frame rate variance when running different builds on different machines. The top-level step method on K14Game, which gets called by the CADisplayLink at 60 frames per second, used to look like somewhat like this:

-(void) step: (NSTimeInterval) dt
{
  self.elapsed += dt;

  if (self.actionReplay)
  {
    [self.actionReplay replayEvents];
  }

  [self.planet step:dt];
}

The actionReplay replayEvents call would replay all input events up to the elapsed property of the game, which it accessed through the K14Game instance passed on construction. Because all time-dependent state changes of the game are integrated over the step time, ie: movements, applied forces, etc are all scaled by the step time to make the engine frame-rate independent, I assumed the above game loop would produce the exact same results even if dt would fluctuate a little between frames (which it does). This assumption turned out to be wrong, and even the slightest frame rate variance would result in small differences in the replay, which would propagate and get amplified by subsequent input events.

The takeaway here is that to ensure pixel-perfect replays, it is necessary to eliminate any external factor that may affect the sequence of game engine states triggered by the replay, including time. There’s two ways to do so: by using fixed timesteps when a replay is active, or by discretizing time before updating any game state. I previously implemented the former by adding a fixedTimestep property to the action replay to override the delta-time passed into the step method, but this had the unwanted side-effect of speeding up or slowing down the replay when changing the fixed timestep. A much nicer way to solve this problem is by discretizing time. The best way to explain is probably by showing the K14Game step method as it is currently implemented:

-(void) step: (NSTimeInterval) dt
{
  self.updateTime += dt;
  
  while (self.updateTime >= self.updateInterval)
  {
    self.updateTime -= self.updateInterval;

    // If an action replay is active, actualize any input events up to the elapsed game time
    if (self.actionReplay)
    {
      NSArray *activated_events = [self.actionReplay popEventsUpToTickCount:self.elapsedTicks updateHz:self.updateHz];
     
      for (id<K14Replayable> event in activated_events)
      {
        [event actuate:self.inputs];
      }
    }
    
    // Update planet state
    [self.planet step:self.updateInterval];
    
    // Update elapsed ticks
    self.elapsedTicks += 1;
  }
}

The idea is that game state updates are now performed on a ‘tick-by-tick’ basis, using a fixed ‘time budget’ per tick. Every time the step method is called, the delta time passed in is added to a running tally that tracks the update time budget. If this number exceeds the update interval (which is equal to 1.0 / update Hz), the game state is advanced by one step and the time budget is reduced by the update interval, carrying over the remaining time. The same steps are repeated until the time budget is below the update interval. Effectively, we are discretizing game updates without breaking frame-rate independence. No matter how much frame time fluctuation occurs, every modification of the game state is always done using the same time delta. By carrying over the ‘leftover time budget’ and catching up by doing multiple updates in the same step call if necessary, frame rate variance is smoothed out. Using the above step method, the replay now produces pixel-perfect results on any run, on any machine ;-)

Development scoreboard

It took me about 5 hours to implement the pickup logic, the unit test to exercise it, plus some additional refactoring. The total development time is now about 32 hours. SLOC count is at 740 lines.