This article was written in response to the emails I’ve received over the past few years, on the subject of game design, and more specifically, Zenith. In what follows, I’ll outline the approaches used, but in more detail than my first Developer Blog, and perhaps later I’ll return to show a little code. When I wrote Zenith, it was using an old IDE, Delphi 6 (you can still download a compatible Delphi 7), so I’ll not go into much detail about practices that might be obsolete now.
Every couple of weeks or so, someone writes to me, saying they’d like to make their own version of The Sentinel. They probably saw my project, and thought it was worth asking me if I’d collaborate on a new version, or help them write an infinite universe. While I would never exclude the possibility, I note that I have other commitments that prevent me from reliably spending a lot of time on side-projects. So I wish them well, especially those starting to write their first game, and say I’d be happy to answer questions they might have along the way. I rarely receive a follow-up question, so what happened?
Since I wrote Zenith in 2005, to my knowledge only two similar games have made it to become playable releases. I'm not sure whether this is because The Sentinel is an unexpectedly difficult programming task, or because game writing has a high attrition rate for beginners. You tend only to hear about the successes, while the stalled projects tend never to become public knowledge, or are forgotten... we don't find out why.
Usually, the first thing I do with a game is design the data objects. These are the data structures that are created, changed, and destroyed by the game. These structures hold all the information that the game needs to work. I follow object-oriented principles as much as I can, but without being a slave to it; sometimes it might be simpler to write code ‘procedurally’ and using static structures, rather than ‘object-oriented’ and using dynamically-allocated structures. For a small project that is not part of a larger work, it usually doesn't matter.
I didn’t split my code into many parts, for two reasons: I was developing the project alone and without consideration of writing anything like it again, and I was satisfied to navigate large source files because I organised them fairly sensibly. My files were:
The main objects I used are:
This structure is almost ready for multiple players (but we’re lacking an abstracted client-server command architecture), and could contain multiple landscapes, either for previewing or keeping several concurrent during gameplay (another feature we haven't used).
Most of the logic is in the Landscape object.
Parameters are chosen that are appropriate to the ‘level’ of play, then the generator does its thing.
Games are intended to be repeatable for any given level, much like the original Sentinel, so the landscape generator uses a special random number generator, which is seeded with a number based on the level of play. For example, level 6 will be converted into a seed, say 48572896, and this seed starts a sequence for the random number generator.
Care must be taken to make the level repeatable. This means not taking random numbers from outside this seeding process. If we suddenly took a random number from elsewhere, then the generated level ceases to be repeatable, especially if decisions are made based on this number: a small transgression works like a ‘butterfly effect’, and can result in wide divergences in the finished landscape.
There are just a few parameters that define the landscape, which change the behaviour of the generator. First, I should explain how the landscape is made:
So if the ‘repeat deformation’ variable is 0.6 instead of 0.5, a more mountainous landscape is generated. Had this number been derived outside the repeatable random number stream, then repeat plays of the level would be very different.
There should be a number of trees available for the player to absorb. Too many trees makes an unchallenging game, and too few makes it impossible or frustrating, so there’s a playable balance to be found. There needs to be a function that assesses some statistics of the landscape, and arrives at a number of trees to provide, and influence the vertical distribution of trees.
You can change this part of the game balance, using the Resources slider in the Config tab:
Scanners take a bit of work to place. First, determine the number of scanners there will be on the landscape. This will be a function that semi-randomly conforms to providing just one scanner at level 1, and more scanners in later levels. The highest point is always occupied by the Sentinel. After that, the ideal placement is not straightforwardly ‘the next-highest point’. An additional constraint is used: the next-highest point that is not closer than ‘a distance’ from any other scanners. This is fairly easy to do when you have an ordered list of available map squares (as per SquareHeights, mentioned above), so the top half of the list is ‘thinned’ to exclude those close to a new Scanner. The list is also re-ordered, randomizing the order of any squares having the same height, which prevents any positional bias in future square selections having height criteria.
All objects are registered with the landscape using a simple stack per map square. This list can be walked, top-down or bottom-up, and is used by the ray-tracers, and by the game logic to determine what actions can be performed on the square.
Animations and game logic are intermixed. If we want an object to do something, we create an animation entry for it. These animation entries are serviced in the game loop, at any arbitrary time between simulated events, so we can put logic in there that happens when the animation finishes.
The 'scanner' objects can use several animations, which don't nececssarily have movement associated with them; “timer” would be a more accurate description. We chain several of these together to create the behaviour: wait, rotate, scan, wait...
We do something similar to make objects appear and disappear: create an animation of type ‘appear’ or ‘dissappear’. For example, when creating ‘appear’, we create an object, and add the ‘appear’ animation to it. The renderer responds to these animations, and in this case, the object fades into view over a set period of time. When the ‘appear’ animation has finished, it triggers more game logic, e.g. the game makes the object available for interaction.
This might seem like a messy combination of creating/destroying, timers, game logic, and graphics, but they work nicely togther.
Each scanner has several parameters that contribute to its aggression and behaviour. Timings are an obvious one (which we mentioned above), but there is another parameter that changes with difficulty level, and it relates to the scanning mechanism we use. First, I’ll describe a problem: that of choppy gameplay. This happens if you try to do too much in a single iteration of the game loop. In the early prototypes of Zenith, we found that scanners could take up a lot of processing time, especially when four or more did their scanning at the same time. The result was almost a lock-up of the game, so we decided on a different, less thorough approach.
The initial scanning operation (locating a target to drain) is split up into several identical events, each event containing a limited number of attempts to find a target. This is tuned using a probability, or a straightforward percentage of relevant squares sampled, which becomes a number of trials. For each trial, we choose a square randomly on the map, until it falls within the sector that the scanner is currently using. Then we perform a trial on that square: test if any degradable objects are on it, and if there are, we attempt an (expensive) ray-trace to discover how visible the base square is. We then ‘roll the dice’ to perform a trial, based on how visible the square is, and if it is successful, we initiate the drain.
The first drain then triggers a wait, which in turn triggers another drain. This continues until there is nothing left to drain on that square. The scanner then completes its remaining scans, before moving on, starting with the ‘rotate’ animation. It’s worth noting that all steps in the above process are timed animations.
We just move the camera to a good vantage point, add some markers, and reduce the fog. Mouse movements are interpreted differently in the map view. When returning back to the synthoid view, the settings are restored.
OpenAL provides the sound API. This let us create sound sources when we created game objects. This too is animation-driven. First, we load up the sounds into buffers, so they're ready to be played. Then we trigger them using sound animations, additional to the visual animations. Some of the sounds change as they are played, such as the 'creak' of the Scanner rotation, which is again controlled by the animation routines.
All the sounds were made by FMC, a synthesizer I wrote. For example, the 'create boulder' sound was built like you would a bass drum sound: start with noise, make it very loud to begin with, apply filters to let different frequencies die away at different rates. Another sound is the raspy 'drain energy' sound. This was created using an FM technique, using waves that have lots of harmonics, so one 'rough' sound is modulating another rough sound, to create a very complex and dynamic timbre. It results in a busy alarm, intense and unnatural enough to make the player feel uncomfortable. The heartbeat was designed using a variation on this technique, using a shorter sound, and adding filters and feedback loops that give it a 'weak robotic' feel. I think it apt that the heartbeat sound has the same origins as the sound of a scanner taking your life away.
A lot of attention was given to creating mapping functions that gave a certain distribution for any given input 0..1. Most of them use the difficulty level as reference, and time was spent fine-tuning many of these variables and their interactions. So, at level 1, scanners are few and slow, trees are plentiful, and landscapes are shallow. As the difficult level increases, we see more and faster scanners, a smaller comfort margin for trees, higher landscapes with more ragged features, and a larger step-up increment for the land elevation. At the highest levels, there is perhaps an unexpected turn: the landscapes become simpler! If you've played the game to the mid-level, you’ll know that the landscape can be your friend, providing places to hide, so when there are more scanners, it becomes more difficult to hide in a flatter landscape.