Before writing any network code, I needed a way to determine what data in my existing game entities (spaceships, bullets, obstacles, etc.) should be serialized and sent over the network. As mentioned in my previous article, I opted to use runtime Java annotations to mark these "interesting" fields. The network code can scan a class definition at runtime and create a serializer used to serialize and deserialize data to be sent and received over the network.
The annotations are simple, just two were needed to mark a field in different ways:
1. NetData DELTA. These fields are sent over the network whenever the field's value is different from the client's last acknowledged state. Typical DELTA fields are a game entity's position (x,y coordinates), facing, and life.
2. NetData FULL. These fields are only sent when a game entity is known to be new to a given client. Remember as mentioned above, the server tracks all data that is sent and has been sent to a client.
A FULL field is never changes its value during the life of the game entity so is never present in a gamestate update from the server after the initial payload. Typical FULL fields are a game entity's object type (what type of bullet, spaceship, or obstacle this entity represents).
The following code shows the annotated fields of the Robot class in ErnCon, which represents player and AI-controlled vehicles:
Note the “def” field is a FULL field. The extra attributes on the FULL annotation are used to tell the serializer that we are serializing a non-primitive field and which field of that non-primitive field should be sent over the network.
Java reflection allows inspection of a class' field definitions for runtime annotations. Reflection also allows these field definitions to be stored in the serializer to be used to get and set values on instances of the respective game entities. The serializer, in turn, knows how to take two snapshots of a game entity and write out the differences (if any). The output is sent over the network to clients. The serializer also knows how to apply that data received from the network to an arbitrary instance making it match server-side state.
The following code snippet shows the iteration over each game entity in toObjs, finding their serializer stored in DATA_GRAPHS, and writing either a delta object from the last acknowledged game state or a full object if the object is new.
In the upcoming iOS version of ErnCon, I wrote an exporter that dumps all serializer and field annotation information into a JSON file that is built into the project.
With serializers that know how to take individual game entities and write out the differences between two states, deltas of entire game states can be written to the network. Storing game state is very simple -- all active game entities for a given state are cloned and stored in a list managed by a GameState object. These GameState objects are used exactly as described in the Quake III Networking Model:
1. GameStates are saved up to the earliest GameState acknowledged by all clients.
2. For each client, create a packet of data by calculating the delta between the current GameState and the client's last acknowledged GameState as shown by the following code snippet:
Note that one of the disadvantages of the Quake III Networking Model becomes apparent at this point: storing copies of all relevant game entities for an arbitrary number of game states can become expensive. For ErnCon this is not immediately a big deal because of the following:
1. Maximum number of players restricted to eight to reduce the amount of state to save (and bandwidth when communicating with clients).
2. Game servers run on EC2 instances allowing easy provisioning of new servers if needed. Multiple EC2 instances responsible for hosting games spread the load of active games.
3. Recording game states periodically as opposed to every frame. ErnCon only creates a new GameState object once every 50 milliseconds to conserve memory. This counts as a premature optimization and is one of the things I will start adjusting in the future.
Serializing and sending data over the network is important but not the only part of the network code in ErnCon. After implementing the Quake III Networking Model, most of my development efforts were spent on determining what data to send and how to handle it. Although this varies greatly between types of games, there are some general cases ErnCon handles that you should know about:
1. Client runs the simulation locally but uses network data to guide the game. Dead-reckoning in ErnCon simply means allowing the game simulation to run on the client in absence of data from the server. AI logic for computer-controlled enemies is also run client-side since AI behavior is deterministic for any given game-state. Important events like damage are not applied client-side although the explosion animation of a bullet hitting a spaceship is shown.
2. Ping time between client and server. For client-side prediction like dead-reckoning to work, the client must know how much time a packet takes to reach the server. Each ErnCon client sends a ping packet once per second. Upon receiving the ping packet, the server responds immediately. Each ping packet has an ID unique to the sending client, which is used to determine the roundtrip time. A running average of the last 3 pings is used as the basis for all dead-reckoning calculations used. In my experience, tracking more pings than 3 allows occasional network hiccups to negatively affect dead-reckoning.
3. Merging network data with client simulation. When a client receives a game state update from the server, it must go through the last acknowledged state and apply the delta-compressed data to generate a new state. This new state, in turn, must be applied to the actual local simulation. Each game entity in ErnCon knows how to merge data from a new state triggering appropriate animations and nudging the local entity towards the new server position based on ping time. No fancy formulas are used to interpolate between local position and updated server position -- a vector in the direction of the new position is simply applied to the object; if the object deviates from its server position for too long, then the client snaps the object to the latest server position.
4. Creating new game entities from network data. Network updates from the server can contain data on objects not known by the client -- for example, when a bullet is fired. Clients know to create new local instances of these unknown game entities.
Although I've managed to discuss the major points of ErnCon's multiplayer implementation, there is still much more that can be discussed. Rather than boring you with a 100-page dissertation, feel free to continue the discussion by asking questions in the comments below or via e-mail: [email protected]. I'll do my best to provide code/pseudo-code to answer technical questions.