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.