Techniques
Up

Graphics and animation techniques

DIBs and transparency

We used Windows DIBs to implement all of our graphics objects.
Every bitmap displayed or stored internally in the game is encapsulated by the CDIB class.

Since every DIB is rectangular, we had to support transparency when displaying it.
This is achieved using a color-key. A specific palette entry that, when copying a region from one DIB to another, only pixels that do not have this palette value get copied.
Other techniques exist (for example, creating a bit-per-pixel map of transparent areas and using AND and OR BitBlts to filter out the background pixels).
We favored this straightforward technique over the other techniques since it requires no additional memory and is very fast for small bitmaps (90% of our bitmaps are smaller than 40x40 pixels).

The ImageManager

The CImageManager class handles all of the chores of image management and display.
Its main features are:

Holds a list of all game images.
Each image:
Holds a list of one or more sub-images.
Each sub-image:
Holds a list of one or more bitmap structures.
Each bitmap structure:
Has a DIB resource ID.
Holds a pointer to a dynamically allocated CDIB that holds the bitmap pixels (loaded on startup).
Has an optional bitmask (for pixel-level collision detection), created automatically on startup.
Has optional offset markers (for display fine-tuning).
Has a cyclic animation flag. For example: in bonus images, where the bonus keeps rotating, this flag is TRUE. For explosion images, where the explosion animation does not repeat itself, this flag is FALSE.
Has a frame delay - time to wait between frame (animation speed).
Holds an optional pointer to another image. Used for overlays (image on another image). For example: the zombie hourglass cursor is displayed on top a tank image using this technique.

Some examples:

A red tank is an image. It has 24 sub-images (each optional rotation angle). All the sub images do not animate and have a single bitmap.
A shield bonus is an image. It has a single sub-image. The sub-image has 9 bitmap structures (all the 9 frames that compose the animation). The frame delay is 100 ms and the animation is cyclic (keeps rotating).
A shell explosions is an image. It has only one sub-image. This sub-image has 28 bitmaps (all the 28 frames that compose the animation). The frame delay is 100 ms and the animation is not cyclic (ends after the last bitmap is displayed).
Loads all bitmaps (stored internally in the application's resource) during startup - for best performance.
Other classes that use the ImageManager are given a handle.
The handle is a structure that specifies the image, sub-image and bitmap index along with animation properties.

The user of this class hands this handle to the ImageManager which automatically draws the correct bitmap at the right time.
Rotating the image (e.g. a tank) is also handled internally in the ImageManager.

Rendering and game objects techniques

Pixel-level collision detection

All images in the game are stored in rectangular DIBs.
To test if an image (e.g. a shell) collides with another image (e.g. a tank), we must apply pixel-level collision detection instead of the simple rectangular-level collision detection.

Most of the images (bonuses, bullets, bombs etc.) don't require this level of detection.
Only the game objects that require pixel-level detection are supplied with a pixel-lookup table generated automatically (on image load time) and stored along the image's DIB in the ImageManager.

All game objects support a collision detection method (against another object).
We first do a rectangle-level collision detection to rule out the easy cases. Only if the result is a hit and both images hold a pixel-lookup table, a slower but more accurate pixel-level detection (only within the intersection of the two rectangles) is made.

Objects interaction

All objects in the game inherit from a single class - the CGameObject.
Each and every object can perform:

Return its state at a given time (dead or alive).
Tell its type.
Return its location on the map.
Return its size.
Return its current image.
Return its height (z-order).
React to another game object:
Explode with a variable intensity.
Block with a variable level (e.g. a wall blocks by 100% while a water pond reduces speed to 50%).
Give a bonus of some kind (for bonus objects).

During the game, in the GameManager's main loop, all objects are traversed and their status (state, position, image etc.) is gathered. All objects are checked for reaction against all other objects (N x N complexity at the most) in a single rendering pass.

Objects list and z-order

All the objects in the game are stored in a special list (encapsulated in the CObjectsList class).
Internally, this list is implemented as 4 statically allocated arrays (for performance reasons).

Each array corresponds to a specific z-order of the object.
We support 4 different heights (z-orders):

Ground level - only one object has this level: the game map (terrain).
Lower level - has objects that are on the ground (tanks, mines, bonuses etc.).
Higher level - has objects above the ground (bullets, shells, etc.).
Sky level - has the highest objects (bomber planes, game-over animation).

The GameManager traverses the list of objects during rendering. It starts with the ground level and ends in the sky level. This ensures objects are drawn in the correct order for a more realistic experience.

Map terrain generation

Each new game session, the hosting computer creates a new map. The map object is responsible for its own creation (implemented in CGameBoard).

To create a new map all the game board need are two parameters: the map complexity level (1..20) and a random seed (short integer).
The game board object initializes the random mechanism with the seed (srand) which ensures a pre-defined series of semi-random values form the rand() function.

All the obstacles on the map are selected randomly from now on. The walls are erected first, then the various other obstacles are placed (ponds and rocks).

The host computer has to pick the random seed randomly (using the system timer).

When a client joins a game session, all it has to get are the two map creation parameters. Since he uses the same map generation algorithm, it will have the same exact map.

Constant frame rate

To achieve a constant frame rate, the game manager is placed in a dedicated worker thread.

Every frame cycle, it checks the time the entire frame calculation and drawing took. If it took less than a single frame time (50 milliseconds per frame for 20 frames per second), it goes to sleep for the remainder of the time (if it's not too small).

This frees up CPU for other threads (user-interface and the CommManager) while maintaining a constant frame-rate.

The GameManager thread is given THREAD_PRIORITY_HIGHEST for better performance.

Communication and game synchronization techniques

The Judge

Since the game is distributed among more than one machine, various machines can perceive a different game-state at the same time and reach conflicting conclusions. For example, two tanks believe they both picked up a single bonus while only one of them did.

A special entity, called the Judge, had be created to solve such problems and act as a mediator between players.

We chose to implement the Judge as the player on the host machine.
This was chosen for 2 major reasons: the host machine is usually stronger and can take the extra CPU load and the player on the host machine is placed in the middle of the classic server-clients star configuration and has the best view of the game.

Most of the time, the players cannot create conflicting decisions.
Local tanks decide when they die, where they are, what's their ammo status and their shield level. All they do is transmit that information to other tanks periodically for an update.

However, the judge is responsible for the following game session properties:

Existence and identity of participating tanks (informs new joining clients of the tanks in the game).
Mines on the board and their locations.
Current bonus type (if exists).

The host, when receiving this update information from the judge (the local player), stores it locally.
When a game-state snapshot arrives from a player's machine (non-judge), the host checks that information against the one published by the judge and sends corrections accordingly.

Game snapshots and checksums

Each player in the game, sends a game-state snapshot to the host periodically (once a second).

This information includes:

The number of active tanks.
The current bonus type (if exists).
Local tanks' position.
A checksum of the active mines (divided into sectors).
For each tank (local or remote):
It's zombie state (true or false).
It's shield level.
It's ammo count.

The information regarding the local tank of the sending player is stored and broadcasted to the other players.

The information about remote tanks (remote to the sending player) is compared against the stored information (from other reports) and specific update messages are sent upon a mismatch.

Not every mismatch generates an update message. Only mismatches that are critical enough are considered update-message-worthy.

The information in the snapshot message uses checksums to preserve bandwidth while still providing mismatch detection at the minimal required resolution. For example, all the information about the mines in the board (can reach several hundreds mines) is stored in 2 double words (8 bytes).

Zombie state

Every tank (local or remote) can become a zombie during its lifetime.
The zombie state indicates that the machine the player is using has network congestion problems.

The Zombie state is temporal and once the congestion goes away, the tank can resume normal play.

While in zombie mode, the tank cannot move or perform any game actions but it cannot be hurt or destroyed.

The host is responsible for determining which tank becomes zombie or alive again. It does that constantly monitoring the players packet round-trip time.

In another safeguard mechanism the host employs, a client becomes zombie if no message arrives from it for a long time (5 seconds). After about 30 seconds of inactivity, the keep-alive mechanism of DirectPlay kicks in and disconnects the player.

Tank position synchronization

Each player sends a game-state snapshot every second to the host.
Included in this snapshot is the current position of the player's local tank.

The host, upon receiving this location information, broadcasts it to all the other players.

Thus, each player receives up to 3 (there are up to 4 players in a game) position information per second (depending upon network throughput).
Each position information should update its matching remote tank object at the receiving client.
The only problem is that the information takes time to arrive (client A to host, host to client B). By the time the client receives it, it's old news.

To solve this, every tank object keeps a cyclic history buffer. On every game frame (up to 20 frames per second) the tank stores its current position and the current time at the tail of the buffer, pushing the oldest information out of the buffer's head.

Once such a tank (remote tank object) receives a position update (from the network), it finds the matching history location, calculates the drift and applies it to its current location.

For example:

Player A drives the red tank (the red tank is a local tank at player A).
Player B drives another tank but also sees the red tank on his screen (a remote tank object).
Player A sends a position update about the red tank at time 100. The position is (50,50).
Player B receives this update at time 120.
Player B looks up the time of the incoming message (100) and finds the nearest time slot in the history buffer of the red tank object (remote tank) he holds. It finds the slot and reads the position there (at time 100) to be (47, 49).
Player B now knows that, at time 100, the remote red tank he holds was 3 pixels by 1 pixel away from its real location.
Player B updates the current location of the red tank he holds by that offset (from 80,80 to 77,79).

This technique is based on heuristics but it proved to yield good synchronization results even with very difficult network connections.

We keep a history buffer of about 1500 milliseconds for each tank (30 slots). This is adequate since any latency greater than 1500 milliseconds is handled by the zombie mechanism.

Miscellaneous techniques

Maneuver sets

In order to isolate the tank object from the keyboard or the network, we came up with the concept of maneuver sets. A maneuver set is a set of actions telling the tank what to do next (for example: move forward, turn right and fire a bullet).

Maneuver sets cannot contain contradicting actions (turn right AND turn left).

Maneuver sets are encapsulated in the CManeuverSet class.

To make tanks behave the same regardless of their locality (local tank or remote tanks), the maneuver set of each tank can be updated from exactly one of two sources:

The maneuver set of the local tank is updated by the user interface main  thread. When the user presses a key, the key is captured and checked against the defined maneuver keys. If there's a match, the maneuver set of the local tank (there's only one local tank) is updated, removing contradicting actions.
The maneuver sets of remote tanks are received from the network and directly update the corresponding tank object.

Every tank, when it calculates its new state, checks to see if the maneuver set it uses has changed from the last frame. If so, the tank asks the communication manager to send the new maneuver set to the host. The host broadcasts it to all the other players.

 

Back Home Up Next