Quantcast
Channel: Wildbunny blog
Viewing all articles
Browse latest Browse all 19

How to make a multi-player game – part 2

$
0
0

Hello and welcome back to my blog! This is part 2 in the series where I talk about making a multi-player game.

Last time we built a TCP socket server in node.js and we’re able to send and receive complex types. Read the first article if you’ve not already done so here.

Here is a live version of the game I’m describing in this series:

Clock synchronisation

It’s important that both the client and server’s clocks are synchronised because if there is any time based interpolation, you want both server and client to agree on what time it is and therefore at what position your interpolated object is.

Asteroids on an interpolation orbit

I use this technique in 2D Space MMO to ensure the orbiting asteroids are in the same position across all clients and on the server. The asteroids are actually interpolating on an orbit around a central location – there are no update messages getting sent to correct their positions, the only thing which keeps them synchronised is having the same time value across all clients and server.

Before we can try to synchronise clocks, we need to be sure we’re using the same concept of time on both server and client. We want to calculate the number of seconds since 1970/01/01, which is the same in both javascript and actionscript:

var time = new Date( ).getTime( )/1000.0;

The way to synchronise clocks is to first work out how long it takes a message to do a round-trip from client->server->client, like a ping, essentially. We do this by sending a message which contains the client’s local time. The server will then reply with the exact data it received and also an additional parameter which is the time on the server.

When the message arrives back on the client, the client can subtract the old client time in the message from the time ‘now’ on the client, thereby arriving at a total round-trip time. Taking half of this round-trip time we arrive at an estimate of the all important latency value. Latency is how long it takes for our messages to arrive on the server.

Using the second part of the reply from the server (the server’s time), we can then compute an offset which will account for any time-zone differences.

In reality we don’t just do this synchronisation once, because it is subject to error based on the current quality of the connection between the client and the server – imagine the client is on a 3G network travelling on a train, the round-trip time might vary considerably from attempt to attempt based on the proximity and line of sight to the nearest cell tower. In order to combat this problem, we continuously synchronise and take an average value.

/**
 * Send clock synchronisation message
 */
private function SyncClocks( ):void
{
	Message.SerialiseAndSend( m_socket, MessageNames.kTime, {m_clientTime:m_LocalTimeSeconds} );
}
 
/**
 * Get the time on this local machine, in seconds
 */
static public function get m_LocalTimeSeconds( ):Number
{
	return new Date( ).getTime( )/1000.0;
}
 
/**
 * Syncronise clocks
 * 
 * @param message
 *
 */
public function TimeMessage( message:MessageContainer ):void
{
	var now:Number = m_LocalTimeSeconds;
	var clientTime:Number = message.m_data.m_clientTime;
	var serverTime:Number = message.m_data.m_serverTime;
 
	// round trip time in seconds
	var roundTripSeconds:Number = now-clientTime;
 
	// latency is how long message took to get to server
	var latency:Number = roundTripSeconds/2;
 
	// difference between server time and client time
	var serverDeltaSeconds:Number = serverTime-now;
 
	// store averages
	if ( m_latency!=Number.MAX_VALUE )
	{
		m_latency = ( m_latency+latency )*0.5;
	}
	else 
	{
		m_latency = latency;
	}
 
	// this is the current compenstation
	var totalDeltaSeconds:Number = serverDeltaSeconds+m_latency;
	m_timeCompensation = totalDeltaSeconds;
 
	// check again in 5 seconds
	setTimeout( SyncClocks, 5*1000 );
}

In this code I’m using a simple moving average. You might want to do something more advanced like store more data points instead of just the last value and then filter out any outliers by comparing against the median of the dataset.

The latest sample was identified as an outlier and can be discarded

Flash and the socket policy file

Before we go any further it’s worth mentioning how Flash works when connecting to a socket server. The Flash client will send a request for a policy file when it first connects to a socket server; this policy file tells Flash which domains and ports the socket server accepts Flash connections on. You can read more about socket policy files here.

Flash will send a request which looks like this:

<policy-file-request/>

It will do this first on port 843 and then on the port you chose to connect with the server.

The server must respond with a valid policy file which must be terminated with a \0 null character. Here is the function I use to form the policy response in node.js:

/**
 * Get the socket policy response for the given port number
 */
function GetPolicyResponse(port)
{
  var xml = '<?xml version="1.0"?>\n<!DOCTYPE cross-domain-policy SYSTEM'
          + ' "http://www.macromedia.com/xml/dtds/cross-domain-policy.dtd">\n<cross-domain-policy>\n';
 
  xml += '<allow-access-from domain="*" to-ports="' + port + '"/>\n';
  xml += '</cross-domain-policy>\n\0';
 
  return xml;
}

I’ve found that although the majority of Flash clients do request a policy file, there are some which wont so you have to be able to deal with both cases to be completely robust.

Synchronising state in a twitch reaction game

At this point it’s important to talk about exactly how we can go about designing the system to be responsive enough to handle a twitch reaction game. There are primarily two different ways of synchronising state in a multi-player game (by state I mean position, orientation, velocity and other attributes of in-game objects):

  • Periodically send updates about all objects which have changed state
  • Send updates only when an event occurs

The first method sends data at a fixed interval about all objects which have changed state. This introduces a lag which is equal to the time between state updates, but ensures a steady flow of state information which is not subject to flooding.

The second only sends state when an event occurs, such as a key being pressed. This has minimal lag but can be subject to flooding; for example if the player starts hammering keys.

I chose to use an event based method because the response times are important in a twitch reaction game, and also because it allows me to handle the problem with bullets.

The problem with bullets

Imagine if every single bullet fired in game caused a message to be broadcast? This would soon overwhelm the server’s available bandwidth and start leading to nasty lagging issues. To solve this problem I simply send key-presses from client to server and give the weapon auto-repeat so that the player is either firing or not firing based on which keys are currently down on the client.

If I detect key hammering in the game (which is quite a natural behaviour for this type of game), I put up a message telling the player they can hold down fire instead.

When the player is firing bullets, bullets are emitted at a constant rate on both client and server and there is a synchronised timer which ensures that both client and server fire bullets at the same moment in time.

Client side prediction and server authority

Does the client send its state to the server, or does the client send inputs only and simply await the new state from the server?

The former approach is lag free and responsive but presents a serious problem with trust – the client could have been altered by cheaters to gain an advantage in game. Imagine if the client had authority over kills, for example; an altered client could simply kill everyone else in game by telling the server it had hit everyone a thousand times, or it could upgrade the player with super-speed etc.

The latter avoids these problems but introduces an unacceptable amount of lag which is equal to the message round-trip time.

As I mentioned in the last article, although you don’t need to worry about cheaters until you actually have a popular game, it’s worth taking preventative steps ahead of time. The traditional method is to simply have the server be in authority over everything in game and to cope with the lag by doing client side prediction.

Here are some reading materials which cover client-side prediction and complete server authority:

http://gafferongames.com/networking-for-game-programmers/what-every-programmer-needs-to-know-about-game-networking/

http://fabiensanglard.net/quakeSource/quakeSourcePrediction.php

https://developer.valvesoftware.com/wiki/Latency_Compensating_Methods_in_Client/Server_In-game_Protocol_Design_and_Optimization

I’m going to talk about a different method in this article. It has a number of advantages:

  • Incredibly simple to understand and quick to get running
  • Player never sees their character getting warped around or stuttering due to lag
  • Enables complex interactions with rigid bodies
  • Enables the game to be played acceptably over a 3G network

This method has server-side authority over everything in game, except the player’s position and angle, which are client sided. This obviously raises a trust concern with cheaters teleporting around the level, so to mitigate this the server (and other clients) simply clamp the maximum speed of each player. Then if a player teleports, it’s equivalent to them moving there at normal speed for all other clients and the server, thereby negating the benefit of the cheat.

In order to implement this technique I send the client’s current position and angle along with key-presses.

//
// handle player input message sending
//
 
if (keysDown != m_lastKeysDown)
{
	m_playerInputDef.Initialise(keysDown, m_gameLogic.m_ThisPlayer.m_Pos, m_gameLogic.m_ThisPlayer.m_Angle);
	Message.SerialiseAndSend(m_socket, MessageNames.kPlayerInput, m_playerInputDef.m_Annon);
 
	m_lastKeysDown = keysDown;
}

When this data arrives at the server (and the other clients) a delta is computed between the current position and this new broadcast position. A fraction of this delta is then applied each frame to the position and angle, and then the delta is depleted by that amount.

Source moves 50% of the remainging way towards target each time

So the correction is applied most at the beginning of its application and least at the end. In fact it never reaches the end, technically.

It should be noted at this point that this is indeed only used for correction purposes; both the client and the server all run the same game code which responds to key-presses. All objects are integrated in the same way across all systems so client and server will be roughly in sync without this correction. The problem is, ‘roughly’ is not good enough and they will start to drift off without it.

The problem with drift

Although the same game-code runs on client and server, which responds to key-presses in the same way, integrates objects forward in time the same way using the same code, you will still get problems with drift if you do nothing to correct it.

Drift occurs due to a number of factors, the most obvious being accidental differences in the code between client->server, or differing mathematical result of calculations due to hardware or language specific implementation details, or a non identical time-step between client->server.

The other one is due to the unpredictable nature of lag. For example, the client holds down the forward key for 30 ticks and then releases it. That’s two different messages getting sent from client to server, one key down and one key up. Due to lag, the server will receive these two messages at some time in the future, the problem isn’t that; the problem is that if the lag changes from key-down to key-up, the number of ticks the server will have the forward key down for could be different than the client!

Difference in key-down duration client->server due to lag variation

In the above example a key is held down for a consistent number of ticks each time, but the lag varies on each attempt resulting in a inconsistent key-down duration on the server. This wouldn’t be a problem if there was a constant amount of lag, but in reality it varies enough to cause problems.

By sending the client’s current position and angle with each key-press this drift can be corrected as described before it gets significant.

Simplicity

The beauty of this technique is that it’s incredibly simple. The player’s position/angle is always slightly ahead of the server because of the nature of this system, so that when the correction deltas are computed they never represent a step backwards in time, like they would with a fully server authoritative technique so always appear smooth and un-jarring.

Of course, this technique does come with its own disadvantages which it is only fair to discuss:

  • If the player is very fast moving, the difference in position on client->server due to lag can be large enough to cause problems with hit-detection
  • A player with a very laggy connection will appear to behave oddly for all other clients observing
  • The maximum speed of the player must be clamped to mitigate cheating

That being said, from a business point-of-view the thing most likely to stop your game being a success is it not being done in time or at all. As long as the compromises are acceptable, any time you can save during development which could be spent on actually making the game better is extremely valuable.

You can read more about my love of simplicity in game development in this article on how to make games.

Integration and the time-step

I’ve already mentioned that the client and server must run the same code. It’s worth talking about the time-step, because although we have a very robust correction system to deal with drift, in reality we want those corrections to be as small as possible.

With a variable time-step, the amount of distance actually moved over a long series of frames will be different each time even with a constant velocity. This is especially noticeable with the jump-height of a character in a platform game; I’ve seen it be pronounced enough that a character won’t be able to make a jump in one part of the level which he has no trouble making in another for the same height.

I’m using the method Glenn Fiedler recommends near the bottom of his article on the subject.

Acting on key-presses

Ok, so lets talk about exactly how I transmit key-presses and what the server does with them.

Firstly, the key-codes from the client’s keyboard are transformed into in-game actions and packed into a bit-field. You can read more about bit-fields in this article I wrote about understanding binary.

var keysDown:int = 0;
 
// translate actual keycodes into logical actions
if (m_keyboard.IsKeyDown(eKeyCodes.kLeftArrow) || m_keyboard.IsKeyDown(eKeyCodes.kA))
{
	keysDown |= Player.kLeftD;
}
if (m_keyboard.IsKeyDown(eKeyCodes.kRightArrow) || m_keyboard.IsKeyDown(eKeyCodes.kD))
{
	keysDown |= Player.kRightD;
}
if (m_keyboard.IsKeyDown(eKeyCodes.kUpArrow) || m_keyboard.IsKeyDown(eKeyCodes.kW))
{
	keysDown |= Player.kUpD;
}
if (m_keyboard.IsKeyDown(eKeyCodes.kDownArrow) || m_keyboard.IsKeyDown(eKeyCodes.kS))
{
	keysDown |= Player.kDownD;
}
if (m_keyboard.IsKeyDown(eKeyCodes.kSpace) || m_keyboard.IsKeyDown(eKeyCodes.kCtrl))
{
	keysDown |= Player.kSpaceD;
}

The advantage of this remapping is that you can easily redefine the controls at a later date with no problems.

On the server, these key-presses are actioned in the same way as they are on the client.

Client:

/**
 * Move the ship forward in time and accept player inputs
 * 
 * @param dt
 */
public override function Integrate( dt:Number ):void
{
	...
 
	// handle player inputs
	if ( IsKeyDown( Player.kLeftD ) )
	{
		m_AngularVel -= Player.kTurnRate*dt;
	}
	if ( IsKeyDown( Player.kRightD ) )
	{
		m_AngularVel += Player.kTurnRate*dt;
	}
	if ( IsKeyDown( Player.kUpD) )
	{
		m_Vel.AddTo( m_Direction.MulScalarTo( Player.kAccelerateRate*dt ) );
	}
	else if ( IsKeyDown( Player.kDownD ) )
	{
		m_Vel.SubFrom( m_Direction.MulScalarTo( Player.kDecelerateRate*dt ) );
	}
}

Server:

/**
 * Move forward in time.
 * 
 * Keep in sync with client!
 */
Integrate:function(dt)
{
	...
 
	// handle player inputs
	if ( this.IsKeyDown( Player.Constants.kLeftD ) )
	{
		this.m_angularVel -= Player.Constants.kTurnRate*dt;
	}
	if ( this.IsKeyDown( Player.Constants.kRightD ) )
	{
		this.m_angularVel += Player.Constants.kTurnRate*dt;
	}
	if ( this.IsKeyDown( Player.Constants.kUpD) )
	{
		this.m_vel.AddTo( this.GetDirection().MulScalarTo( Player.Constants.kAccelerateRate*dt ) );
	}
	else if ( this.IsKeyDown( Player.Constants.kDownD ) )
	{
		this.m_vel.SubFrom( this.GetDirection().MulScalarTo( Player.Constants.kDecelerateRate*dt ) );
	}
}

The reason these inputs are processed inside the Integrate function is due to the integration method I’m using, which compensates for frame-rate variation by calling Integrate multiple times per frame depending on the adjusting time-step.

Other in-game events

As I mentioned before, everything except player position and orientation is server-sided in this article. As such, events like:

  • Time synchronisation ping
  • Player takes damage
  • Player died
  • Player respawned
  • Player joined
  • Player left

…are all handled by the server broadcasting to the clients. Each message has its own message type and the client has a message processing section which deals with the data and actions represented in these messages.

The server has a similar message processing section and deals with messages from the client, which include:

  • Time synchronisation ping
  • New player ready for world state
  • Player pressed a key
  • Player chat message
  • Player entered his real name
  • Player connected
  • Player disconnected

Game state on player join

In this simple example, when a new player joins the game, the entire game state gets serialised and sent to the joining player and all other players get a message that a new player has joined the game.

/**
 * Process all messages received from all clients!
 */
OnMessage:function(client, message)
{
	var thisPlayer = this.m_players[client.m_uid];
 
	switch (message.n)
	{
		...
 
		case MessageNames.kReady:
		{
			if (this.m_numPlayers < GameLogicConstants.kMaxPlayers)
			{
				//
				// client is ready! serialise the world!
				//
 
				// get data for world
				var world = [];
				for (var key in this.m_players)
				{
					var player = this.m_players[key];
					world.push( player.GetData() );
				}
 
				var defaultName = "Guest"+utils.PadNumber(client.m_uid, 4);
				var newPlayer = new Player(client.m_socket, this.FindRandomSpawnLocation(), client.m_uid, defaultName);
 
				this.AddPlayer(newPlayer);
 
				// new player data for serialisation
				var pd = {m_pos:newPlayer.m_pos, m_uid:client.m_uid, m_name:defaultName};
 
				// send this data to the client
				Message.SerialiseAndSend(client.m_socket, MessageNames.kReady, {m_world:world, m_you:pd});
 
				// broadcast new player to all clients, except the one who is ready
				this.m_server.BroadcastExcept( MessageNames.kCreatePlayer, pd, client.m_uid );
			}
		}
		...
	}
}

This technique works fine for this simple game, but if you have a much larger world, or many more players it will start to get bulky and expensive on bandwidth. You might want to look at techniques like Interest Management to handle this problem in larger games. I use Interest Management in 2D Space MMO.

Each object in game has a method called GetData() which returns the complete state required to create the object from scratch on the client. Here is what this looks like for the players:

/**
 * Serialise this player
 */
GetData:function()
{
	return {m_mod:this._super(),
		m_uid:this.m_uid,
		m_keysDown:this.m_keysDown,
		m_lastKeysDown:this.m_lastKeysDown,
		m_bulletTimer:this.m_bulletTimer,
		m_health:this.m_health,
		m_invincibleTimer:this.m_invincibleTimer,
		m_kills:this.m_kills,
		m_died:this.m_died,
		m_name:this.m_name};
},

You can see there are the vital details for the player, including his UID which uniquely identifies this object on the server, what keys are down now and which were down last frame, the bullet timer which synchronises the firing of bullets between client and server, health, number of kills, number of deaths and his name. The position, angle and velocities are actually stored inside the m_mod member which is inherited from the base class which describes all moving objects.

When the joining client receives and has deserialised this array of world data, the game state is synchronised and any further update in state will be handled by the normal event based messaging system. For the other clients present a more simple set of data is serialised which describes the new player:

// new player data for serialisation
var pd = {m_pos:newPlayer.m_pos, m_uid:client.m_uid, m_name:defaultName};

Because the joining state of all players is identical, bar the UID, the name and the position, these are the only things which are necessary to serialise to the existing clients. For example, all new players start un-rotated, all have full health, zero kills and have died zero times so there is no need to serialise that data – new players have defaults for those values.

Chat

No multi-player game would be complete without some way for players to chat with each other. Luckily implementing a chat system is very simple; chat messages are read from a text-box on the client, sent to the server and then broadcast to all clients, where upon the message is displayed at the appropriate location in the chat window.

The only thing to worry about is filtering the text for profanity. This can be done on either client or server, I’ve chosen to implement this on the client. Text must be filtered before being displayed on reception of the message rather than before transmitting it from the source. The reason is simple: imagine a hacked client exists where the hacker has removed the chat filter; he would be able to type unfiltered messages which then get broadcast to all clients!

Here is a simple chat filter implementation:

/**
 * Replace the given character in the string 
 * @param str
 * @param char
 * @param index
 * @return String
 *
 */
private function ReplaceChar(str:String, char:String, index:int):String
{
	return str.substr(0,index) + char + str.substr(index + 1);
}
 
/**
 * Censor the given text string
 */
public function Censor(text:String):String
{
	for each(var swear:String in m_naughtyWords) 
	{
		var done:Boolean = false;
 
		while (false == done)
		{
			// find start location of profanity
			var lowerString:String = text.toLowerCase();
			var startPos:int=lowerString.indexOf( swear );
			if (startPos == -1)
			{
				// no more occurances
				break;
			}
 
			// length is
			var length:int = swear.length;
			var end:int = startPos+length;
 
			// replace
			for (var i:int=startPos; i<end; i++)
			{
				text = ReplaceChar(text, "*", i);
			}
		}
	}
 
	return text;
}

It replaces sub-string occurrences of profanity with the asterisk character and returns the filtered string.

It’s worth pointing out that it makes sense to store any data unfiltered (in a database, for example) because you are then free to change the filter and will still have the original data to work with.

As well as chat, you can display game events in the chat window, as in this example whereby kill events are displayed with the chat.

Chat text with game events

Bots

There are two bots in the live demo which accompanies this article, they are server-side bots so are genuinely equivalent to other real players in all things accept their level of skill… in most cases!

They are handled by creating a fake client for each bot which runs server side. Each bot creates a socketed connection to the server in exactly the same way a genuine client would, but the code the bot runs is comparatively tiny compared to the full client.

Indeed, here is the full code of the bot:

require('./Scalar');
require('./Player');
require('./Message');
 
// import node.js net library
var net = require('net');
 
 
/**
 * Class to emulate a client
 */
Bot = Class.extend(
{
	Init:function(host, port, uid)
	{
		this.m_host = host;
		this.m_port = port;
		this.m_player = null;
		this.m_lastKeysDown = 0;
 
		var scope=this;
 
		this.m_socket = net.connect(port, host, function()
		{
			Message.SerialiseAndSend(scope.m_socket, MessageNames.kReady, {uid:uid});
		});
	},
 
	Update:function()
	{
		if (this.m_socket && this.m_player && Scalar.RandInt(50)==0)
		{
			var keysDown = Scalar.RandInt(15);
 
			// always fire
			keysDown |= Player.Constants.kSpaceD;
 
			if (this.m_lastKeysDown != keysDown)
			{
				Message.SerialiseAndSend(this.m_socket, MessageNames.kPlayerInput,	{m_keysDown:keysDown,
																					m_pos:this.m_player.m_pos,
																					m_angle:this.m_player.m_angle});
				this.m_lastKeysDown = keysDown;
			}
		}
	}
});

As you can see, the full extent of their AI involves sending a completely random key-state change at a random point at a probability of 1/51! Given how simple this is, I’m constantly surprised by how realistic they can seem. It goes to show that random events can go a long way toward emulating real behaviour in some cases.

Bots are created when the first player joins the game and are destroyed when there are zero real players left. In a PvP only game, it’s important to have bots because a lot of the time there wont be that many real players playing, which will lead to a bad play experience and bad reviews.

Player personalisation

It’s very important to be able to let players personalise themselves in a multi-player game. The simplest way to do this is to allow them to enter their own nickname. In this example it’s handled the same way as any other message; it gets broadcast to all clients who then update their copy of the changed name. The server also stores this new name so that subsequent players will have it serialised to them when they join. The name is displayed on each player after being run through the profanity filter!

Avatar customisation in 2D Space MMO

Simple stats tracking can also help make the game more fun, like a way for players to compare themselves against others. In the demo accompanying this article the number in brackets after the player’s name is the number of times the player has died subtracted from the kill count.

Version control

Once you’ve made your multi-player game and distributed it onto the portals, it’s very important that you are able to update it and fix any bugs you find without having to manually locate and alter each copy of the game on every portal. You can do this by creating a boot-loader which loads the main part of the game from a fixed location which you control. Of course there are technicalities associated with this, not least of which is browser-caching. You can read more about how to solve this in this article I wrote a while back.

Buy the source code

As ever you can buy the source-code accompanying this article! Your purchases help me to be able to continue writing articles like these.

It will give you the complete prototype as shown in playable form above, with both server and client code. You will need either Flash Develop or Amethyst to build the client-side code and of course you will need node.js installed to run the server-side. You will also need Flex SDK version 4.5.1 or above. If you would like to edit the assets included with this demo, you will need Adobe Flash CS4+. Note that you cannot build the client side code with only the Flash IDE.

It comes in two versions, a personal edition which you are free to use for your own, non commercial purposes and a commercial version which allows you to use the code in any number of different commercial products or games:

Personal use licence – USD 49.99

Commercial use licence – USD 199.99

Subscribers can access the source here

If it seems expensive, bare in mind that it took a couple of solid weeks of programming to produce, which would have been around $3500 if I were contracted… Not to mention the many weeks and days it took to arrive at a solution powerful enough to handle a twitch reaction game over a 3G connection!

That’s all for now! Until next time, have fun!

Cheers, Paul.


Viewing all articles
Browse latest Browse all 19

Trending Articles