Introduction
While it is quite common to integrate some sort of multiplayer capacity into computer games these days, one often has to know the IP of the player who is hosting a game in order to be able to play. This is fine for playing on a local network, as you can just talk between yourselves to determine where the server will be, but to organize games over the Internet requires one to use a separate system – email or instant messenger.
This library allows you to host a lobby server, which will stay at a fixed known address, so that players of your game (or games) can join it, chat with each other to arrange a game, and then begin and control it without anyone having to know the address of the host player.
Prerequisites
This code makes use of my Sockets
library, which is also up on CodeProject here. The UI also uses my LineEditor
custom component. I haven't had time to write a proper article for it, so you have to get it from here.
Design
What is this code designed to do? What is a lobby, and what do we need to write to create one?
Well, first, of course, there will be a network library, to handle communication between the server and the clients. I use my own with the messaged mode of operation that includes a type code with each message.
What Is a Lobby?
A lobby is in essence a collection of players and a collection of games. Each player may be in one or more games, and each game may contain one or more players. Players should be able to create, join, leave and start games, and they should be able to see which games are already available and which players are in the 'room', although this is simply a case of allowing the UI to see that data. A lobby should also fire events when players join or leave the server, or the state of a game changes (i.e., a new player joins, or it moves from setup to 'in play'), so that a UI can know when to update.
I chose to make the data-handling parts of the lobby a base class, from which the Client
- and ServerLobby
classes (which communicate via network functions to synchronise state) derive. In addition to its data-handling, the ServerLobby
has to send update messages to all clients when updating events happen, such as a new player arriving or a new game being created. It clearly also has to keep track of the players that are connected, and manage authorisation of new clients who are trying to connect and sign in. It also has to run the games and process client commands – requests to start a game, for example. This is all done by receiving messages from the network layer, processing them and then sending responses and broadcasts, again via the network layer.
The ClientLobby
is simply a 'thin client' view of the server, kept in sync via network messages and sending request messages when the end user attempts to do anything.
UI
The Lobby
class and its subclasses intentionally contain no UI. They are designed to be used as programmatic components, called from your own UI and linked in to it via the event callbacks. There is a sample client shell (the LobbyClient folder) which contains most of the things one would need for a general purpose client.
Screenshot of the client
Games
The lobby should be game-type neutral, i.e., it shouldn't care what type of game is being run on it. This is essential for a general purpose engine. To this end, both client and server work on a plug-in system, with plug-ins implementing the interfaces which are defined in Lobby.dll. These plug-ins should be where the actual game processing takes place. For convenience, it is usually best if the game is 'owned' by its creator, so all the processing takes place on one of the clients who then instructs the server to broadcast messages to everyone else in the game, or maybe just to particular players. The server should also be able to store and retrieve data associated with a particular game, so if the owner leaves, the game can be handed over to a new owner with minimal disruption.
This handling of the games on the client side means that the server doesn't need any knowledge of the games being played at all, which is good as updating servers is a lot harder than providing a new client download. However, it means that a hacked client could cheat at will, completely changing the game mechanics. For this reason, I also provide a mechanism for hosting games on the server and having the processing done there (again, through plug-ins and interfaces). The ServerLobby
then hands off messages relating to that game to the plug-in.
Implementation
Well, the simplest way to find out how I've done it is to look at the source ;). That is a pretty hardcore way, however, and I'll explain some of the more generally useful code here.
Data Structures
Perhaps the most important part of any design is the classes and structures used to store data. For this problem, those are for player and game information:
public class MemberInfo {
public int ID;
public uint Flags;
public string Username, DisplayName;
public object Data;
public object InternalData;
}
public struct MemberFlags {
public const uint ServerControlled= 0xFFFF0000;
}
public class GameInfo {
public int ID, CreatorID, MaxPlayers;
public string Name;
public String GameType, Version;
public uint Flags;
public int[] Players;
public uint[] PlayerFlags;
public String Password;
public object Data;
public bool Serverside;
public IServersideGame Game;
}
public struct GameFlags {
public const uint Locked = 0x00000001;
public const uint Closed = 0x00000002;
public const uint InProgress= 0x00000004;
}
public struct PlayerGameFlags {
public const uint Ready = 0x00000001;
}
This is all fairly self-explanatory. Note that all the transferable data is simple types; because my network library only deals efficiently with numbers and string
s, they have to be transferred like that, and it therefore makes sense to me to have flags not as flag-wise enum
s but as uint
s.
The main Info 'structures' are in fact classes, so they can be stored efficiently in a Hashtable
and modified in place.
I also define a collection of event handlers for the main application to connect to:
public delegate void MemberEvent(BaseLobby lobby, MemberInfo mem);
public delegate void GameEvent(BaseLobby lobby, GameInfo game);
public delegate bool ProcessCodeEvent(BaseLobby lobby, MemberInfo mem,
uint code, byte[] bytes, int len);
public delegate void LogEvent(object sender, string text);
public delegate void UnloadDataEvent(object sender, object container,
object data);
public delegate bool UserJoinedEvent(ServerLobby sl, MemberInfo member,
String password);
Most of these are 'update events', allowing the UI to react when something happens. The ProcessCodeEvent
allows the application to override or extend the default handling of messages, and is usually hooked from plug-ins.
Communication
The network communication, as mentioned above, is handled by messages that contain a message type code. This is handled in a large switch
statement that decides whether to process the message immediately, or whether to pass it to the UI or a plugin.
public bool ClientDoCode(ClientInfo ci, uint code, byte[] bytes, int len){
MemberInfo mem = (MemberInfo)members[MyID];
ByteBuilder b = new ByteBuilder(bytes), b_out = new ByteBuilder();
int pi = 0;
bool handled = true;
try {
switch(code){
case ReservedCodes.YouAre:
myid = ClientInfo.GetInt(b.GetParameter(ref pi).content, 0, 4);
mem = (MemberInfo)members[MyID];
break;
case ReservedCodes.SignInChallenge:
string msg = Encoding.UTF8.GetString(b.GetParameter(ref pi).content);
int failures = ClientInfo.GetInt(b.GetParameter(ref pi).content, 0, 4);
if(failures == 0){
b_out.AddParameter(Encoding.UTF8.GetBytes(Username),
ParameterType.String);
b_out.AddParameter(Encoding.UTF8.GetBytes(Password),
ParameterType.String);
ci.SendMessage(ReservedCodes.SignIn, b_out.Read(0, b_out.Length), 0);
} else ci.Close();
break;
case ReservedCodes.MemberUpdate:
MemberInfo mi = new MemberInfo();
mi.ID = ClientInfo.GetInt(b.GetParameter(ref pi).content, 0, 4);
MemberInfo miold = (MemberInfo)members[mi.ID];
mi.Flags = (uint)ClientInfo.GetInt(
b.GetParameter(ref pi).content, 0, 4);
mi.Username =
Encoding.UTF8.GetString(b.GetParameter(ref pi).content);
mi.DisplayName =
Encoding.UTF8.GetString(b.GetParameter(ref pi).content);
if(miold != null){
mi.Data = miold.Data;
mi.InternalData = miold.InternalData;
}
members[mi.ID] = mi;
break;
case ReservedCodes.MemberLeft:
int ID = ClientInfo.GetInt(b.GetParameter(ref pi).content, 0, 4);
if(UnloadData != null){
mi = (MemberInfo)members[ID];
if(mi.Data != null) UnloadData(this, mi, mi.Data);
if(mi.InternalData != null)
UnloadData(this, mi, mi.InternalData);
}
members.Remove(ID);
break;
case ReservedCodes.GameUpdate:
ID = ClientInfo.GetInt(b.GetParameter(ref pi).content, 0, 4);
GameInfo gi = (GameInfo)games[ID];
if(gi == null) gi = new GameInfo();
gi.ID = ID;
gi.Flags = (uint)ClientInfo.GetInt(b.GetParameter(ref pi).content, 0, 4);
gi.CreatorID = ClientInfo.GetInt(b.GetParameter(ref pi).content, 0, 4);
gi.Serverside = (gi.CreatorID < 0);
gi.MaxPlayers = ClientInfo.GetInt(b.GetParameter(ref pi).content, 0, 4);
gi.GameType = Encoding.UTF8.GetString(b.GetParameter(ref pi).content);
gi.Version = Encoding.UTF8.GetString(b.GetParameter(ref pi).content);
gi.Name = Encoding.UTF8.GetString(b.GetParameter(ref pi).content);
gi.Players = ClientInfo.GetIntArray(b.GetParameter(ref pi).content);
gi.PlayerFlags = ClientInfo.GetUintArray(b.GetParameter(ref pi).content);
games[gi.ID] = gi;
break;
case ReservedCodes.GameClosed:
ID = ClientInfo.GetInt(b.GetParameter(ref pi).content, 0, 4);
if(UnloadData != null){
gi = (GameInfo)games[ID];
if(gi.Data != null) UnloadData(this, gi, gi.Data);
}
games.Remove(ID);
break;
case ReservedCodes.MemberJoined:
break;
default: handled = false; break;
}
if(ProcessCode != null)
handled |= ProcessCode(this, mem, code, bytes, len);
} catch(ArgumentException ae) {
Console.WriteLine("Internal error (invalid message sent from server +
"or error in code handler)."+
" Code was "+code.ToString("X8")+". Error was "+ae);
}
return handled;
}
The ByteBuilder
is a utility class (in the Sockets
project) that can build up or deconstruct an array of bytes from or to 'parameters' – length-checked blocks of bytes of known type. b.GetParameter(ref pi)
gets the 'next
' parameter. The format of each message is defined in the comments on the ReservedCode
struct.
Note how the ProcessCode
event is called once the resync processing is done, to allow the application to do extra things in response to the message.
The server's DoCode
function is similar in structure, and too long to post all of it here.
Managing Players
Players are quite simple to keep track of under a TCP system: one connection maps to one player, and if the connection drops, the players are considered to have left. There are three things a player can do:
- Connect to the server. At this point, they are not 'logged in', but we need to add the new socket to our internal list of clients so we know who they are when they do log in. That is done for us by the network layer (the
Server
class stores all the clients that are connected to it), so all we have to do in the ServerLobby
is request that the client log in, and attach event handlers to the new connection:
bool connect(Server server, ClientInfo ci){
ci.OnReadMessage = new ConnectionReadMessage(ClientReadMessage);
ci.OnClose = new ConnectionClosed(ClientClosed);
ci.MessageType = MessageType.CodeAndLength;
ci.SendMessage(ReservedCodes.Version,
Encoding.UTF8.GetBytes(Strings.Version),
ParameterType.String);
ci.SendMessage(ReservedCodes.SignInChallenge,
PrepareChallenge(Strings.InitialChallenge, 0), 0);
DoLog("New connection "+ci.ID+" was accepted from "+
ci.Socket.RemoteEndPoint);
return true;
}
- Log in to the server. This is handled by the
ClientReadMessage
function, which most of the time just passes its information on to the ServerDoCode
function but if a connection is not linked to a player yet will process an attempt to log in. This function is quite long but simple; it just checks if the player is already logged in, whether the attempted sign-in information is valid, and if so adds the new member to the list of players and sends them information about who else and what games are present on the server.
void ClientReadMessage(ClientInfo ci, uint code, byte[] bytes, int len){
MemberInfo mi = (MemberInfo)members[ci.ID];
if(mi != null){
ServerDoCode(ci, mi, code, bytes, len);
return;
}
if(code != ReservedCodes.SignIn){
ci.SendMessage(ReservedCodes.Error,
Encoding.UTF8.GetBytes(Strings.MustSignIn),
ParameterType.String);
return;
}
ByteBuilder b = new ByteBuilder(bytes);
int pi = 0;
try {
String username =
Encoding.UTF8.GetString(b.GetParameter(ref pi).content);
String password =
Encoding.UTF8.GetString(b.GetParameter(ref pi).content);
foreach(MemberInfo mi2 in members.Values){
if(mi2.Username == username){
ci.SendMessage(ReservedCodes.SignInChallenge, PrepareChallenge(
String.Format(Strings.AlreadyLoggedIn, username), 1
), 0);
ci.Close();
return;
}
}
mi = new MemberInfo();
mi.ID = ci.ID;
mi.Flags = DefaultMemberFlags;
mi.Username = mi.DisplayName = username;
if(UserJoined != null){
if(!UserJoined(this, mi, password)){
ci.SendMessage(ReservedCodes.SignInChallenge,
PrepareChallenge(Strings.BadLoginChallenge, 1), 0);
return;
}
}
DoLog("Member "+mi.Username+" ("+mi.ID+") signed in");
members.Add(ci.ID, mi);
server.BroadcastMessage(ReservedCodes.MemberUpdate,
PrepareMemberInfo(mi), 0);
server.BroadcastMessage(ReservedCodes.MemberJoined,
Encoding.UTF8.GetBytes(mi.DisplayName),
ParameterType.String);
foreach(MemberInfo mi2 in members.Values){
if(mi2.ID == mi.ID) continue;
ci.SendMessage(ReservedCodes.MemberUpdate,
PrepareMemberInfo(mi2), 0);
}
ci.SendMessage(ReservedCodes.YouAre,
ClientInfo.IntToBytes(ci.ID),
ParameterType.Int);
foreach(GameInfo gi in games.Values){
ci.SendMessage(ReservedCodes.GameUpdate,
PrepareGameInfo(gi), 0);
}
} catch(ArgumentException) {
ci.SendMessage(ReservedCodes.Error,
Encoding.UTF8.GetBytes(
String.Format(Strings.InvalidFormat, code)
),
ParameterType.String);
ci.Close();
}
}
- Disconnect from the server. While the player who leaves doesn't need any information sending, they may leave a good deal of cleaning up to do: everyone should be told they've left, and any games which they were in need to be updated and potentially transferred to a new owner.
void ClientClosed(ClientInfo ci){
MemberInfo mi = (MemberInfo)members[ci.ID];
if(mi != null){
Hashtable games2 = (Hashtable)games.Clone();
foreach(GameInfo gi in games2.Values){
if(MemberInGame(mi.ID, gi.ID)) {
RemovePlayerFromGame(mi.ID, gi);
}
}
server.BroadcastMessage(ReservedCodes.MemberLeft,
PrepareMemberInfo(mi), 0);
members.Remove(mi.ID);
DoLog("Member "+mi.Username+" ("+mi.ID+") left");
} else {
DoLog("Unknown connection "+ci.ID+" was closed");
}
}
public void CloseGame(GameInfo gi){
DoLog("Game "+gi.Name+" ("+gi.ID+") closed");
if(gi.Serverside)
gi.Game.Close();
games.Remove(gi.ID);
server.BroadcastMessage(ReservedCodes.GameClosed,
ClientInfo.IntToBytes(gi.ID), ParameterType.Int);
}
The RemovePlayerFromGame
method deals with most of the awkward part:
public void RemovePlayerFromGame(int mem, GameInfo gi){
if((!gi.Serverside) && (gi.Players.Length <= 1)){
CloseGame(gi);
return;
}
if(gi.Serverside) gi.Game.Left(mem);
int[] newplayers = new int[gi.Players.Length - 1];
int npi = 0;
for(int i = 0; i < gi.Players.Length; i++)
if(gi.Players[i] != mem) newplayers[npi++] = gi.Players[i];
gi.Players = newplayers;
if(mem == gi.CreatorID){
gi.CreatorID = gi.Players[0];
gi.PlayerFlags[0] |= PlayerGameFlags.Ready;
}
games[gi.ID] = gi;
server.BroadcastMessage(ReservedCodes.GameUpdate,
PrepareGameInfo(gi), 0);
}
Managing Games
Games are also easy to deal with, because they are only changed as a result of particular messages being sent from clients (or a client disconnecting, as above). A game can be created with the CreateGame
function:
public GameInfo CreateGame(int cid, int maxplayers, string gametype,
string version, uint flags, string name, string pwd){
GameInfo gi = new GameInfo();
gi.ID = nextGameID++;
gi.CreatorID = cid;
gi.MaxPlayers = maxplayers;
gi.GameType = gametype;
gi.Version = version;
gi.Flags = flags;
gi.Name = name;
gi.Password = pwd;
gi.Serverside = cid < 0;
if(gi.Serverside){
gi.Players = new int[0];
gi.PlayerFlags = new uint[0];
} else {
gi.Players = new int[]{cid};
gi.PlayerFlags = new uint[]{PlayerGameFlags.Ready};
}
gi.Game = null;
games[gi.ID] = gi;
server.BroadcastMessage(ReservedCodes.GameUpdate,
PrepareGameInfo(gi), 0);
return gi;
}
... a player can be added to the game with the AddToGame
function:
public void AddToGame(GameInfo gi, ClientInfo caller, int id, uint flags){
int[] newplayers = new int[gi.Players.Length + 1];
uint[] newpf = new uint[gi.PlayerFlags.Length + 1];
for(int i = 0; i < gi.Players.Length; i++){
if(gi.Players[i] == id){
caller.SendMessage(ReservedCodes.Error,
Encoding.UTF8.GetBytes(Strings.AlreadyJoined),
ParameterType.String);
return;
}
newplayers[i] = gi.Players[i];
newpf[i] = gi.PlayerFlags[i];
}
newplayers[gi.Players.Length] = id;
newpf[gi.Players.Length] = flags;
gi.Players = newplayers;
gi.PlayerFlags = newpf;
server.BroadcastMessage(ReservedCodes.GameUpdate,
PrepareGameInfo(gi), 0);
if((gi.Flags & GameFlags.InProgress) != 0)
caller.SendMessage(ReservedCodes.StartGame,
PrepareTwoIDs(gi.ID, (int)gi.Flags), 0);
}
... and removed with the RemovePlayerFromGame
method (see above). When a player requests to join a game, the request has to pass certain criteria: is the game open? Have they provided the right password? Is there space for another player?
case ReservedCodes.RequestJoinGame:
id = ClientInfo.G
etInt(b.GetParameter(ref pi).content, 0, 4);
int reqcode = ClientInfo.GetInt(
b.GetParameter(ref pi).content, 0, 4);
String pwd =
Encoding.UTF8.GetString(b.GetParameter(ref pi).content);
gi = (GameInfo)games[id];
if(gi == null){
caller.SendMessage(ReservedCodes.Error,
Encoding.UTF8.GetBytes(
String.Format(Strings.UnknownGame, id)),
ParameterType.String);
break;
}
bool found = false;
for(int i = 0; i < gi.Players.Length; i++)
if(gi.Players[i] == mem.ID){
caller.SendMessage(ReservedCodes.Error,
Encoding.UTF8.GetBytes(Strings.AlreadyJoined),
ParameterType.String);
found = true;
break;
}
if(found) break;
if(gi.Players.Length >= gi.MaxPlayers){
caller.SendMessage(ReservedCodes.PlayerResponse,
PrepareResponse(gi.CreatorID, reqcode, 0,
Strings.GameFull), 0);
break;
}
if((gi.Flags & GameFlags.Closed) != 0){
caller.SendMessage(ReservedCodes.PlayerResponse,
PrepareResponse(gi.CreatorID, reqcode, 0,
Strings.GameClosed), 0);
break;
}
if((gi.Flags & GameFlags.Locked) != 0){
if(pwd != gi.Password){
caller.SendMessage(ReservedCodes.PlayerResponse,
PrepareResponse(gi.CreatorID, reqcode, 0,
Strings.GameLocked), 0);
break;
}
}
if(gi.Serverside){
String cjmsg;
if(gi.Game.CanJoin(mem.ID, reqcode, pwd, out cjmsg)){
caller.SendMessage(ReservedCodes.PlayerResponse,
PrepareResponse(gi.CreatorID, reqcode, 1, cjmsg), 0);
AddToGame(gi, caller, mem.ID, PlayerGameFlags.Ready);
gi.Game.Joined(mem.ID);
} else
caller.SendMessage(ReservedCodes.PlayerResponse,
PrepareResponse(gi.CreatorID, reqcode, 0, cjmsg), 0);
break;
}
cito = server[gi.CreatorID];
if(cito != null){
output.AddParameter(ClientInfo.IntToBytes(reqcode),
ParameterType.Int);
output.AddParameter(ClientInfo.IntToBytes(mem.ID),
ParameterType.Int);
output.AddParameter(ClientInfo.IntToBytes(gi.ID),
ParameterType.Int);
cito.SendMessage(ReservedCodes.RequestJoinGame,
output.Read(0, output.Length), 0);
}
break;
If these preliminary tests pass, the request is passed on to the game owner who can choose to allow the new player into the game or not.
When actually running the game, the server simply acts as a conduit for messages, which are sent to the game owner or broadcast to everyone in the game. This latter is done by the GameBroadcast
method:
public void GameBroadcast(int gameid, uint code, byte[] msgbytes,
byte paramType){
GameInfo gito = (GameInfo)games[gameid];
if(gito == null) return;
foreach(int p in gito.Players){
ClientInfo cito = server[p];
if(cito != null)
cito.SendMessage(code, msgbytes, paramType);
}
}
All actual game processing is done by the relevant game-type plug-in (either on the client or the server).
History
- 17th February, 2007 - Updated source download
- 21st October, 2008 - Updated source download