Light Racer 2.0 - Days 41-44 - Real-time Multiplayer Protocol Implementation

According to my 2nd revised project plan for Light Racer 2, I was to have a release done on June 5. The core game has been done for a few weeks but the complexity and difficulty of getting the multiplayer system to work well has thrown that date right out the door. The last 4 days of work have been focused specifically on getting the basic multiplayer code to work. This means connecting two phones together, starting a game and playing it. Today I had a breakthrough, after weeks of work I was finally able to play against myself using my phone and emulator. There are tons of bugs and it's not yet complete but it was good to actually see it all working.

Day 41 - What was done:

Talked to a friend - he suggested I try google's protobuffer
Started implementing the world in protobuffer
Protobuf won't work. I'll have GC nightmares and it's too slow.
I need to roll my own protocol, found naga which will make things a little nicer with handling connections but I still need a buncha bytes.

Day 42 - What was done:

Found thorough byte conversion code snippet at http://www.daniweb.com/code/snippet644.html#
Developed basics of a custom protocol
Started work on RealtimeNetworkHost class using Naga NIO

Day 43 - What was done:

Naga NIO didn't work correctly on Android so I reimplemented the data host and client using standard sockets.
Developed Data Protocol, wrote positional byte converter (NetworkDataObject) with header structure.
Wired up the RealtimeNetworkHost and RealtimeNetworkClient completely with the main game thread

Day 44 - What was done:

Reworked state system so that things would work on the network client
Debugged much of the new data networking code
Finally played myself for the first time, though there are tons of little problems

Summary

It took a while to figure out the best approach for the data protocol. I have dedicated one port to control and one port to data. The control port is used to negotiate the multiplayer clients. It's kind of like a "chat room," where people can join and the host can change game settings. Once the game is started, everything moves to the data port.

Unlike the control protocol, which is handled by the multiplayer service, the data protocol is handled by the game thread directly. This means that when the host starts, it opens up a server socket to listen for clients. The clients start and immediately connect to the host. Once all clients are connected, the host begins sending data and the clients begin sending their input.

I tried my best to avoid rolling my own data protocol but as it seems with every game, it's much more efficient to do it yourself. I first tried google's protobuf which is a really nice library and toolset for creating a protocol that can interoperate between languages, but the limitations are that I can not control when it allocates new memory and it's also slower than a custom protocol. I spent a day working on a proof of concept for it but had to throw that out the door.

For connectivity, I almost used Java's NIO but then tried Naga NIO. It didn't work correctly for some reason. I never got callbacks. That was frustrating and cost me almost another day of time as I had to rework all of my socket code to use basic Sockets.

The interesting part - The game's network protocol design

Light racer uses a World class to hold all relevant game information. This was done specifically so that it would be easier to network for multiplayer. The World has GameObjects which are Players, MapObjects and Items. The low level network interface has write(byte[]) and read(byte[]) so I needed to convert the world and its game objects into a flattened-out byte array.

I first came up with a class that I called NetworkDataObject. A NetworkDataObject is a flat, networkable representation of the World or a GameObject. I then added to GameObject that makes the class look like this:

public abstract class GameObject {
/** set if it's possible to collide with this object */
public boolean isCollideable;
/** set if this object is alive */
public boolean isAlive;
/** object's x location */
public int x;
/** object's y location */
public int y;

// Non-Networked fields
/** increment this and use it for assigning IDs out. */
public static int nextId = 0;
/** the id assigned by nextId; */
public int id;
/** set if this object has been initialized (used by netcode) */
public boolean isInitialized;

public abstract void update(LightRacerWorld world);
public abstract void draw(Canvas canvas);
public abstract void restartSound();
public abstract void release();
public abstract boolean checkCollision(int lastX, int lastY, int curX, int curY);
public abstract boolean checkRectCollision(int left, int top, int right, int bottom);
public abstract NetworkObjectData getNetworkObjectData();
public abstract void update(NetworkObjectData data);
}

See the getNetworkObjectData() and update(NetworkObjectData)? Those make it so that on the host, getNetworkObjectData() will encode the object into a NOD (NetworkObjectData) and then when the NOD is received on the client, it will update the existing object.

Here's what the NOD looks like:

public class NetworkObjectData {
public static final int HEADER_DATA_SIZE = 12;

public static final int OBJECT_TYPE_WORLD = 0;
public static final int OBJECT_TYPE_PLAYER = 1;
public static final int OBJECT_TYPE_TRAIL_SEGMENT = 2;
public static final int OBJECT_TYPE_COORDINATE = 3;

public int objectType;
public int objectId;
public byte[] objectData;
private int dataPosition = 0;

public NetworkObjectData(int objectType, int objectId, int dataSize) {
this.objectType = objectType;
this.objectId = objectId;
objectData = new byte[dataSize];
}

public void reset() {
dataPosition = 0;
}

public void putInt(int value) {
int offset = dataPosition;
byte[] data = objectData;
data[offset] = (byte)((value >> 24) & 0xff);
data[offset + 1] = (byte)((value >> 16) & 0xff);
data[offset + 2] = (byte)((value >> 8) & 0xff);
data[offset + 3] = (byte)((value >> 0) & 0xff);
dataPosition += 4;
}

public int getInt() {
int offset = dataPosition;
byte[] data = objectData;
int ret = (int)(
(0xff & data[offset]) << 24 |
(0xff & data[offset + 1]) << 16 |
(0xff & data[offset + 2]) << 8 |
(0xff & data[offset + 3]) << 0
);
dataPosition += 4;
return ret;
}

public static void putInt(int value, byte[] array, int position) {
array[position] = (byte)((value >> 24) & 0xff);
array[position + 1] = (byte)((value >> 16) & 0xff);
array[position + 2] = (byte)((value >> 8) & 0xff);
array[position + 3] = (byte)((value >> 0) & 0xff);
}

//... more types of gets and puts and a few utility methods...
}

The NOD is awesome to use because the interface is so easy. It's just like a parcelable in that you call puts in a certain order on the encode and gets in the same order on the decode. It's about as efficient as you can possibly get while still being easy to read and use.

Here's an example of how it works on Player

@Override
public NetworkObjectData getNetworkObjectData() {
//TODO - Reuse, don't reallocate
NetworkObjectData data = new NetworkObjectData(NetworkObjectData.OBJECT_TYPE_PLAYER, id, MAX_DATA_LENGTH);
data.putInt(x);
data.putInt(y);
//...
}

@Override
public void update(NetworkObjectData data) {
x = data.getInt();
y = data.getInt();
//...
}

That part was easy and elegant. The next difficult part is getting all of the NODs into a single byte array for transmission from the host to the clients. This brings me to the next part, which is the RealtimeNetworkHost and the RealtimeNetworkClient.

I put all of the hard stuff into these two classes. They manage connections and handle all of the tricky parts of the protocol. They are created by the activity and handed down to the thread. The can call back with status updates and drive the client/server updates.

When the host updates the client, it starts by writing a single header int (4 bytes), which is the number of NODs to expect. Each NOD has the exact same structure, 12 bytes for the header (object type, object id and data length) and then the data which matches the data length exactly. This makes it so that the client can read the header, then loop the number of times specified in the header, each time reading another 3 int header which tells it how much data to read for each NOD. It assembles the NODs back into an array and holds them until the main loop is ready to accept the update. The main loop passes in the World and it is updated with the contents of the NODs.

An example of how the protocol looks would be:

// Packet Header
Bytes 0-3 - (int) 3 // number of objects

// NOD 1
Bytes 4-7 - (int) 0 // object type 0 means World
Bytes 8-11 - (int) 0 // object id 0 means World
Bytes 12-15 - (int) 50 // The World's data is 50 bytes long
Bytes 16-65 - (byte[]) ... // World Data

// NOD 2
Bytes 66-69 - (int) 1 // object type 1 means Player
Bytes 70-73 - (int) 1 // object id 1
Bytes 74-77 - (int) 22 // Player data is 22 bytes long
Bytes 78-97 - (byte[]) ... // Player Data

... and so on.

The difficult part is determining when things have changed (AKA when do new objects get created and previous ones deleted?), which will be done using the IDs but is still a tedious task. Also just managing memory is hard. Since you really shouldn't be allocating any new memory during the game loop on an Android game, you will want to reuse the NODs and their byte arrays. This means using fixed-size arrays whenever possible.

I did get everything working today but the client update rate is currently horrible and I'm not yet reusing NODs or arrays. I'm also missing state information and haven't figured out how to manage the trail data. These will be the challenges over the next few days as I inch toward a polished multiplayer game that will really be one of the defining elements of this game.

2 Comments

Post a comment here or discuss this and other topics in the forums

Problems with Naga?

Did you get the Naga code to work correctly outside of Android? If there are any flaws in Naga I'd love to hear about it so I can correct them.

I was having issues where

I was having issues where events were never being called - such as when on an incoming connection.

Comment viewing options

Select your preferred way to display the comments and click "Save settings" to activate your changes.