How Aviator does the right thing at the right time
With great power comes great responsibility... but with limited power comes the need for even greater responsibility. This is particularly true in 8-bit games, where overloading the CPU can make a game unplayable, something that is less likely to be a problem with a word processor or spreadsheet or whatever else people used to run on BBC Micros when they weren't playing games. With a complicated simulation like Aviator, which has to do an awful lot of sophisticated maths every time we want to update the screen, remaining responsive is essential.
Aviator employs three different devices that let it spread out the workload, while still managing to retain smooth graphics and an unstuttered flying experience. Let's take a look at them in turn.
The main loop counter
The most obvious way that Aviator schedules its different tasks is via the main loop counter, which is stored in the mainLoopCounter variable. This counter is set to zero at the start of each game by the ResetVariables routine, and is incremented on each iteration of the main game loop, in part 5 of the main loop.
The loop counter is used as follows:
- In part 5 of the main loop, just after the counter is incremented, we update the radar, but only when mainLoopCounter is 4 plus a multiple of 8 (so that's every 8 iterations, when the loop counter is 4, 12, 20 and so on). So that's 32 out of every 256 iterations, and on different iterations to the fuel gauge and alien-feeding updates (see the next two points).
- In the UpdateFuelGauge routine, we only update the dashboard's fuel gauge when mainLoopCounter is a multiple of 16, so that's every 16 iterations of the main loop, or 16 out of every 256 iterations (i.e. iterations 0, 16, 32 and so on).
- In the UpdateAliens routine, we only update the aliens' states when mainLoopCounter is a multiple of 128, so that's on 2 out of every 256 iterations (i.e. iterations 0 and 128). When we do, then in part 1 we move any feeding aliens on to the next feeding state, and then in part 4 we check whether any feeding aliens have progressed to the next feeding stage, and if so, we double their size.
- In the FillUpFuelTank routine, the fuel tank is only topped up when we are on the runway, and mainLoopCounter is a multiple of four, so that's every four iterations around the main loop, or 64 out of every 256 iterations (i.e. iterations 0, 4, 8, 16 and so on). This is the only time the counter is used to control the speed of a feature, rather than to manage the workload.
The main loop counter isn't used anywhere else, but we have two other scheduling systems that complement it.
Aviator takes an interesting approach when deciding which objects to show on screen (or, more, specifically their lines). You can read all about it in the deep dives on 3D objects and visibility checks, but the part we're interested in is how Aviator decides when it should start drawing an object that is not currently visible.
Instead of scanning the set of all objects to see if any of them are close enough to draw, or perhaps looking to see which objects are in the line of sight and deciding which ones to process, Aviator takes a much simpler approach. Every iteration of the main loop, it simply works through a few of the currently hidden lines in the linesToHide list, checking this batch of lines to see if they are visible, and moving them to the linesToShow list if they are. It's a simple way of ensuring that objects that come into view will get spotted, eventually, and drawn.
The logic behind this system is in part 12 of the main loop. As checking lines for visibility can be a time-consuming task, the code first checks that there aren't too many points on the relatedPoints list; if there are, then these points are already feeding into the visibility checking process, so the game skips any processing of the hidden line list, as there's already enough checking to do.
Assuming we are not already being swamped by related point checks, the main loop calls the ProcessLinesToHide routine three times, to process the next three lines in the linesToHide list. It then implements a timeboxing method, where it calls OSWORD to get the current value of the system clock, and then loops back to process three more lines, but only if it has spent less than a total of 9 centiseconds on processing lines in this iteration of the main loop.
In this way, the essential work of checking for objects coming into view is done on every iteration of the main loop, but only for a set amount of time. After all, some line calculations will take longer than others - some lines are easy to discard as "not visible" pretty quickly, such as those that are far away, while others may take a lot more maths. This timeboxing approach stops the line-checking process from slowing things down, even if the queue is suddenly full of lines with difficult visibility calculations.
Prioritising the dashboard indicators
The final scheduling system in Aviator controls the updating of the dashboard. There are lots of different on-screen indicators in the dashboard, and while they are all important, some are more important than others, so Aviator updates them all with varying priorities.
The core logic is in the UpdateDashboard routine. This routine updates two indicators on the dashboard, one from indicators 0 to 6, and one from indicators 7 to 11. It cycles through each group of indicators with each subsequent call, so if it updates indicators 2 and 8 in one call, then it will update indicators 3 and 9 on the next call. The current indicator from the first group is stored in the indicator0To6 variable, while the current indicator from the second group is stored in the indicator7To11 variable ("current" in this context meaning "last updated").
As we iterate round the main loop, we reach part 4 of UpdateFlightModel, which calls UpdateDashboard, and then calls the same routine's entry point UpdateDash7To11, which updates the next indicator from 7 to 11. So on each iteration around the main loop, we do the following:
- Update the next indicator from 0 to 6:
- 0 = Compass
- 1 = Airspeed indicator
- 2 = Altimeter (small "hour" hand)
- 3 = Altimeter (large "minute" hand)
- 4 = Vertical speed indicator
- 5 = Turn indicator
- 6 = Slip indicator
- Update the next two indicators from 7 to 11:
- 7 = Artificial horizon
- 8 = Joystick position display
- 9 = Rudder indicator
- 10 = Joystick position display
- 11 = Thrust indicator
You'll notice that the joystick position display appears twice, at positions 8 and 10, so it gets updated more often than any other indicator, on two out of every five iterations of the main loop. Meanwhile the other indicators in the second group do pretty well too, and are updated on one out of every five iterations. Meanwhile, the first group contains the less vital indicators, and they get updated on one out of every seven iterations.
Putting those in terms of percentages:
- The joystick position display is updated on 40% of iterations around the main loop.
- The artificial horizon, rudder and thrust indicators are updated on 20% of iterations.
- The rest are updated on 14.3% of iterations.
So this is the third way that Aviator makes sure that the important parts of the system run smoothly, by spending more time on the important indicators, and less time on those that can react more slowly.