Cross-platform network programming made easy.
Creating a multiplayer game can be a lot of fun, but navigating the complexities of IP network programming can be a headache. That's kind of a strange statement, but the two go hand in hand. You can't write a multiplayer game without some sort of network-based communications, and game-related network programming introduces difficulties not often found with more simple applications. For example, most game developers are concerned with bandwidth utilization and throttling. There's also player session management to contend with. Then, there's the problem of message fragmentation, acknowledgement and sequencing. Oh, and you'd really like to be able to make your game run on both Linux and Windows. That's a tall order for developers who probably are more concerned with writing their games than they are in becoming experts in cross-platform network programming. Fortunately, the ENet library (enet.bespin.org) takes care of these details and presents developers with a simple, flexible and consistent API.
ENet's event-driven programming model makes client session management very simple. The library dispatches an event when a peer connects or disconnects, and when a message is received from a peer. The developer simply writes event handlers that take care of initializing and deallocating resources, and acting upon incoming messages. This means you don't have to worry about the complexities of forking, preforking, threading or nonblocking calls to connect() and accept() in order to handle multiple connections. With ENet, all you do is make periodic calls to its event dispatcher and handle the events as they come in.
ENet provides for both reliable and unreliable transmission. The networking industry really needs to find a better term than “unreliable”, however. Unreliable means that a packet will be sent out, but the receiving end won't be expected to acknowledge receiving the packet. In a “reliable” protocol, every packet must be acknowledged upon receipt. If a peer sends out a packet and requests acknowledgement and doesn't receive it in a timely fashion, the packet will be resent automatically until it is acknowledged, or the peer is deemed to be unreachable. The nice thing about ENet is that the same API provides both reliable and unreliable semantics.
ENet also makes it easy to write real-time client-server applications by taking care of packet fragmentation and sequencing chores for you. Put simply, fragmentation and reassembly is done automatically and is transparent to the developer. Sequencing also is handled transparently and can be turned on and off at will. ENet's concept of a communications channel is related to sequencing. ENet buffers each channel separately and empties each buffer in numerical sequence. The end result is that you can transmit multiple data streams and that lower-numbered channels have higher priority. For example, you could put all real-time game update packets into channel 1, while system status packets could be in a lower-priority channel.
For the sake of demonstration, I discuss both the client and server for a simple chat program. The code I'm using is based on a 3-D video game I'm writing in my limited free time. However, while stripping the code down to its basics, I left something out and couldn't get it to work. So, I posted a description of my problem and code snippets to the ENet e-mail list. Within an hour, I had a reply that showed me how to fix my problem. Thanks, Nuno.
In this example, a user starts the client and provides a user name as a command parameter. Once a client session has been created, the client is expected to tell the server the name of the user, as the very first message sent from the client. Then, anything the user types will be sent to the server. Any messages that come from the server will be displayed on the client's screen. Because all user input is taken in a blocking fashion, the user won't actually see any incoming messages until pressing the Enter key. This isn't ideal, but the point of the code is to demonstrate the ENet library, not nonblocking I/O on STDIN and the necessary cursor control. (In a real-world situation, your programs would be generating messages, such as player movement and projectile impact, in real time anyway.) If the user simply presses the Enter key, no message is sent to the server, but any queued-up messages will be displayed. If the user types the letter q and presses Enter, the client disconnects and terminates.
The server also is very simple. When a client connects, the server waits for the client to identify the user. Then, the server sends a broadcast message announcing the new user's connections. When a client disconnects, that fact also is broadcast to all connected users. When a client sends a message, that message is sent to every connected client, except the one who sent it. Like I said, it's a very simple chat system.
Let's look at some code. First, let's get a few #defines out of the way. Take a look at config.h shown in Listing 1.
Lines 1–17 are boilerplate. Note that on line 3, I include enet/enet.h and not simply enet.h. The ENet documentation indicates that enet.h may conflict on some systems, so the enet directory must be used. The global buffer defined on line 6 is where I will put user input. Lines 7–11 simply define some variables that the ENet library requires.
The real ENet code begins on line 18 with the call to enet_initialize(). Once the library is initialized, I create the client host on line 22. As you'll see, clients and servers are both created with a call to enet_host_create(). The only difference is that for a client, you send NULL as the first argument. For a server, this argument tells ENet what address to bind to. Because a client doesn't have to bind, you pass in NULL. The second argument sets the limit on how many connections to make room for. This example client will connect only to one server, so I pass in 1. These two arguments are the only differences between creating a client and a server!
The third argument indicates how many channels I expect to use, 0-indexed by the way. Finally, the last two arguments indicate bandwidth limitations in bits per second for upload and download, respectively. Of course, I check to see if the call to enet_host_create() was successful and continue.
Lines 27–33 tell the client what address and port the server is on and to try to connect to it. If the client can't connect, it will terminate.
If the program gets to line 34, it has connected to the server, and it's time to identify the user. The enet_host_service() function is ENet's event dispatcher, but this will be made more clear in the server code. For now, understand that all I'm doing is waiting for the server to confirm the connection so you can identify yourselves. If you don't see the ENET_EVENT_TYPE_CONNECT, you know you didn't really get connected and should terminate. On lines 38–41, I create and send a packet to the server that simply contains the user's name. (I'll have more to say about packets when I examine the server code.)
The rest of the program, from line 46, is the main event loop. (I'll discuss enet_host_service() and the switch statement that follows it in more detail when I discuss the server.) The code starting from line 58 is fairly simple. Here I get input from the user and check if it's an empty line or if it's a line with just a q on it. Otherwise, I create a packet and send it. Obviously, I never really get to line 71, but I included it for instructional purposes.
The client really isn't very difficult to write and understand. As you're about to see, the server code is almost identical. Let's take a look at the server code in Listing 3.
As you can see, the first 24 lines of code are almost identical to those found in the client code, with two notable exceptions. On lines 17–19, I tell the server to bind to the default IP address, 0.0.0.0, and allocate space for up to 100 client connections. In this case, I don't set any limits on bandwidth utilization.
On line 25, I call enet_host_service() until it returns 0. Each time enet_host_service() returns a nonzero value, I know that something has happened, and the switch statement that follows is used to determine what happened. Note the third argument indicates how many milliseconds to wait for something to happen. If I had passed a 0 in this argument, the call to enet_host_service() would be completely nonblocking.
The ENET_EVENT_TYPE_CONNECT event indicates that a client has connected to the server. Normally, you'd want to initialize resources for the client. But in this case, there is nothing to do until the client has identified itself. I left this case intact for instructional purposes.
The ENET_EVENT_TYPE_RECEIVE event is dispatched when the server receives a message from a client. For this event, there are two possible scenarios:
The client hasn't identified itself yet, and this is the first message I've received from them.
The client has been identified, and this is a normal chat message.
I check to see which is the case in the conditional on line 30. This line also points out an issue that comes up in the forums from time to time, so I'll explain it in a bit more detail.
Most server applications have to store at least some information about each client. Typically, they use an array of structures to store this information. Intuition tells you that one of the things you should store in a given client's structure is a pointer to whatever data type allows you to communicate with it. But with ENet, that intuition is wrong. Instead, ENet's peer data type provides a field, data, that you can use to store a pointer. This pointer presumably would point to the client's information structure. So, it's almost backward from what you expect. But, this is kind of an elegant solution; ENet manages its data, and you manage yours, separately.
The only client data that you care about is the name of the user associated with a given client. If you don't already have the user's name, and you receive a message from his or her client, you can assume that the client is identifying itself to you and you should store the user's name. I do this in lines 31 and 32. Then, in lines 33–36, I announce the new user to the rest of the clients. Note that I create a packet on line 34, but the call to enet_host_broadcast() deallocates it. It is a major error to deallocate that data structure yourself.
In lines 37–49, you can see the case where the client already is identified. All you have to do is send a message to the other clients indicating the name of the “speaker” and what he or she “said”. To do this, you loop over ENet's list of peers. For each peer, check to see if it's the same peer that sent the message. If it is, you skip it, as people really don't want their own messages echoed back to them. Otherwise, you create a message and send it. This way, each client knows what was said, and by whom.
The ENET_EVENT_TYPE_DISCONNECT event indicates that a client has disconnected. In this case, you announce that the user has disconnected and deallocate the space used to store the user's name. On line 55, I set the data pointer back to NULL just in case ENet decides to re-use this structure when another client connects.
If no event is received, the default case is executed, and the server simply prints “Tick tock” to the console, as a reassurance that it is still running.
And, there you have it—a chat client in 72 lines of code and a multi-user chat server in 65 lines of code, and much of the code was identical. In fact, in the program upon which this code is based, I actually use identical code for both the client and server. Rather than have a block of code in the switch statement, I simply call an event handler, which is implemented in a separate code module, one for the client and one for the server. Then, depending on which module I link against, I can have a client or a server. This has the added benefit of isolating all of the ENet-specific code in one source file.
As you can see, the ENet library is almost trivial to use, but it encapsulates sophisticated network communications capabilities. Of course, what this really means is that it's just fun to use.