Zenith
Programming Zenith (devblog)

Contents

Introduction

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.

Are you writing a game?

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.

About Zenith

Zenith is a rewrite of The Sentinel, originally designed and programmed by Geoff Crammond in the mid-1980s.

Project Structure

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.

Units

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:

  • Main, input, views and UI, player camera, out-of-game flow, game loop.
  • Landscape,
  • NotesMemoForm, the console module. It handles logging, preferences, and parses text commands.
  • MathJV, a custom module for maths that off-the-shelf libraries did not provide.
  • NewGame, a UI module for starting a game, and using preferences.
  • NextGame, a UI module for selecting a new game, based on the result of a finished game. It is much simpler than NewGame.
  • Various OpenGL API modules, selected from GLScene.
  • OpenAL API module, to help with positional audio and ambience.
  • A PNG library, for loading the splash image, and saving screenshots.

Objects

The main objects I used are:

  • Game, containing the active data for a game.
    • Landscape
    • Cheat info
    • Audio
  • Landscape, containing states and code for the current landscape.
    • Constants: Colours, random seeds, animation types,
    • GameObjects[]: TGameObject is any item on the landscape, containing its GL object, and properties.
    • Players[]. TPlayer contains position, energy, cheat and active status. The core of the game allows for multitple players, but the interface does not make use of it.
    • Scanners[]. A scanner is any automated object that may sap a player's energy (unifying Sentinel and Sentry). Contains position, type, scanning state and timings, and some audio data.
    • Aminations[]. An animation contains transition information about an object: the type of animation, timings, references to sound sources, positions, and transition values.
    • SquareHeights[] is a list of landscape squares, ordered by height. Useful for quickly selecting the highest and lowest points.
    • Squares[], containing stacks of game object references and their heights, used to keep track of everything on the landscape, and optimise the ray tracing into collision volumes.
    • Properties: LocalPlayer, basic landscape parameters, pointers to OpenGL objects.
    • Methods: Landscape generation, player positioning, enemy and tree distribution, GL Geometry, GameObject management, object and sound animation, acoustic computing, scanner logic, map and player cameras, lighting, ray tracing.

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.

How did we...

...Generate the landscape?

Parameters are chosen that are appropriate to the ‘level’ of play, then the generator does its thing.

Carefully-chosen random numbers

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.

Making a complicated 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:

  1. Start with a flat landscape, of a size determined by level (anywhere from 32 × 32, to around 120 × 120).
  2. Choose a number of deformations, e.g. 40.
  3. For each deformation, lift an area of landscape. This can be a polygon, circle, rectangle, or whatever you choose.
  4. Still with the same deformation, choose randomly whether to do it again, but with a randomly-inset area. One of the parameters determines this, e.g. if it is 0.5, then there’s a 50% chance that a deformation will be repeated: 50% will be elevated once, 25% twice, 13% three times, and so on, to create a variety of ‘hill heights’ where the highest is least common.
  5. If it is decided not to repeat a deformation, we move to the next deformation, in a new area of the landscape.
  6. Most of the deformations will be upward, but a small fraction will be downward. This breaks up some flat areas, and slopes, giving them a more organic feel, and it also provides some lowest points which are good as semi-sheltered starting locations (and hyperspace locations).
  7. So far, these deformations are integer values. When all deformations are done, the landscape is stretched to the appropriate height. This is one of the key parameters of the game, and a slight change has a significant effect on its difficulty. For example, it would be an easy game if you could see the next-highest square without needing to use a boulder (just by standing on the floor). If you need to stand on two boulders to see any higher squares, then that's an extra 4 to 8 units of energy that must be found, to progress comfortably, an extra few seconds per move, and more time absorb what you left behind.

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.

...Populate the landscape?

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.

Tweaking Resources

You can change this part of the game balance, using the Resources slider in the Config tab:

Resources: 0.5×
Resources: 0.5×
Resources: 0.5×
Resources: 2.0×

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.

...Do the animations?

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.

...Scan the Landscape?

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 trails. 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.

...Create a Map View?

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.

...Make sounds?

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.

...Balance the game?

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.