Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C#

A Game Lobby System in C#

4.73/5 (31 votes)
21 Oct 2008CPOL8 min read 9   3.6K  
A simple lobby server for hosting multiple small games and allowing players to create and join games of many types.

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.

Image 1

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:

C#
public class MemberInfo {
  public int ID; // the ID assigned to this used (by the server)
  public uint Flags;
  public string Username, DisplayName; // must be provided to enter
  public object Data; // app-specific stuff about this member
  
  public object InternalData;
  // stuff used by lobby classes
 }
 
 public struct MemberFlags {
  // Client flags: low word
  
  // Server flags: high word
  public const uint ServerControlled= 0xFFFF0000;
 }
 
 public class GameInfo {
  public int ID, CreatorID, MaxPlayers;
  public string Name;
  public String GameType, Version;
  // Reserved flags: 1 locked, 2 closed, 4 in progress
  public uint Flags;
  public int[] Players;
  public uint[] PlayerFlags;
  public String Password;
  public object Data;
  public bool Serverside;
  public IServersideGame Game;
 }
 
 public struct GameFlags {
  // Requires a password to enter
  public const uint Locked    = 0x00000001;
  // No-one can enter
  public const uint Closed    = 0x00000002;
  public const uint InProgress= 0x00000004;
 }
 
 public struct PlayerGameFlags {
  public const uint Ready            = 0x00000001; 
       // Player is content to have the game start
}

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 strings, they have to be transferred like that, and it therefore makes sense to me to have flags not as flag-wise enums but as uints.

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:

C#
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.

C#
public bool ClientDoCode(ClientInfo ci, uint code, byte[] bytes, int len){
   // Public so games can fake messages, warnings etc without bouncing off
   // the server
   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:
      // Add the member to the members table
      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){
       // Copy stuff we are keeping attached to this item
       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;
      // No-ops, but we need to set the flag to say we recognise them
      // Useful for the wrapper app to catch for UI updates, mostly
     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:

  1. 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:
    C#
    bool connect(Server server, ClientInfo ci){
       ci.OnReadMessage = new ConnectionReadMessage(ClientReadMessage);
       ci.OnClose = new ConnectionClosed(ClientClosed);
       ci.MessageType = MessageType.CodeAndLength;
       // Send it a version info message and a challenge message
       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;
    }
  2. 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.
    C#
    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;
       }
       // Not actually signed in, so we need to try to get them to join
       // Only valid thing at this stage is SignIn
       // To do: allow request for encrypted connection
       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);
        
        // Make sure they're not already logged in
        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)){
          // Allow infinite retries (just send a Challenge)
          // NB This is a DOS hole, technically,
          // as server can be spammed with
          // infinite requests to join
          ci.SendMessage(ReservedCodes.SignInChallenge,
                  PrepareChallenge(Strings.BadLoginChallenge, 1), 0);
          return;
         }
        }
        // Validated OK (or no user blocks at all) so add the
        // client and send them
        // all member and game information (them first, though!)
        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);
        
        // Now send all other members
        foreach(MemberInfo mi2 in members.Values){
         if(mi2.ID == mi.ID) continue;
         ci.SendMessage(ReservedCodes.MemberUpdate, 
                        PrepareMemberInfo(mi2), 0);
        }
        // Tell them what their ID was
        ci.SendMessage(ReservedCodes.YouAre, 
               ClientInfo.IntToBytes(ci.ID),
               ParameterType.Int);
        
        // Tell them what games are in progress
        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();
       }
    }
  3. 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.
    C#
    void ClientClosed(ClientInfo ci){
       MemberInfo mi = (MemberInfo)members[ci.ID];
       if(mi != null){
        // Check for any games owned by this player,
        // or which this player has joined.
        // Any he owns are passed on
        // Any he is in, he leaves and the game is updated
        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:

    C#
    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:

C#
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:

C#
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 the game is in progress,
   // the new player needs to get started!
   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?

C#
case ReservedCodes.RequestJoinGame:
   // Just pass it on to the game owner
   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;
   }
   // Make sure they're not already in this game!
   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){
    // Automatically send a rejection if the server is full
    caller.SendMessage(ReservedCodes.PlayerResponse,
          PrepareResponse(gi.CreatorID, reqcode, 0,
                          Strings.GameFull), 0);
    break;
   }
   if((gi.Flags & GameFlags.Closed) != 0){
    // Automatically send a rejection if the server is closed
    caller.SendMessage(ReservedCodes.PlayerResponse,
        PrepareResponse(gi.CreatorID, reqcode, 0,
                        Strings.GameClosed), 0);
    break;
   }
   if((gi.Flags & GameFlags.Locked) != 0){
    // Automatically send a rejection
    // if the server is locked and the
    // password was wrong
    if(pwd != gi.Password){
     caller.SendMessage(ReservedCodes.PlayerResponse,
            PrepareResponse(gi.CreatorID, reqcode, 0,
                            Strings.GameLocked), 0);
     break;
    }
   }

   if(gi.Serverside){
    // Server-hosted game. Allow the plugin to decide
    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:

C#
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

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)