Optimizing Your Online Game Bandwidth

From GPWiki
Jump to: navigation, search

This article discusses basic concepts of networking online games and how to improve the performance of the network. Content in this article is aimed mostly towards those using a TCP messaging system with Visual Basic 6, though the theory applies to any language and socket tool. The focus of this article is primarily towards those using or have a background with the Elysium, Mirage and/or ORE engines for Visual Basic 6.

Using This Tutorial

I have compiled this tutorial through months of research for my own online RPG I am creating. The topics in this tutorial range from basic to intermediate, and is mostly just an explanation of techniques and how to approach them rather than a "copy and paste this for a free MMORPG!!!".

This guide assumes you know how to program decently in the language of your choice and at least the basics of using sockets and sending information. I will not go much into information on specific sockets or languages.

I cannot force you to give me credit if you use this tutorial, but just a mention of my name would be very much appreciated. :)

Choosing Your Tools

TCP VS UDP: What tools you use will be very vital in your process of making an online game. When making a turn-based online game where speed is not an issue, TCP is a better choice because of its reliability. Though, when speed is vital and there are very frequent packet updates like a FPS, it is almost hands-down with UDP. Online RPGs, no doubt the most commonly attempted type of online game, can go with either TCP or UDP. My personal preference would be TCP unless you plan on making a 3D MMORPG with a huge fan-base, though that is more then likely not going to happen with the typical indie developer, no matter how much time you have on your hands.

TCP

TCP is designed for packets getting to the user without being lost, but at the cost of speed. As you can see, the words "cost of speed" is why real-time games like FPS (and REALLY fast paced RPGs) prefer UDP. With TCP, you don't have to design as much around the change of a packet being lost as you would with UDP. Though, if a packet is lost in TCP, it can cause a painful delay in the packet stream since packets are made to arrive in the order they were sent.

UDP

UDP is designed to send packets to the user quickly. The packets are sent in a cluster rather then a stream like TCP. This allows for the packets to arrive in any order from when they were sent. This can be good because you cancel out any delay, though if you send a packet that required a packet from earlier (ex: send a create NPC packet, then send a move NPC packet, and the move arrives first), it can cause some errors. Even worse yet, the packet may not be received at all - so it is often a good idea to not make packets rely on each other in UDP.

TCP/UDP Hybrid

Systems using both TCP and UDP have been done before, and the results have often been quite notable. The idea of a TCP and UDP hybrid is that the data that must get there (position updates, character creation, etc) is sent through TCP, while data that doesn't matter as much (health updates, attack animations, etc) is sent through UDP. This system can be mildly complicated to set up, but many would say that it is easier to work with then pure UDP since you can set packets that must get there to get there.

Socket Tools

There are so many socket tools and wrappers out there (especially for C++) - that is something that should be quite obvious. Now what isn't so obvious is which one to use. For this tutorial, you will want one that supports TCP, binary and multiple connections. A simple Google search should help you figure out what ones there are and which one you should use. I myself use a WinSock API wrapper, and it has worked great. What I do not recommend is the WinSock Control (due to it's very few features, such as inability for binary or setting the Nagle Algorithm) or DirectPlay (Microsoft has announced discontinuation on it, and it has been known to have memory leaks).

Setting Up Your Sockets

How the sockets will be set up will greatly depend on what sockets you are using. First off, make sure you are using Binary packets instead of String packets - this is something we will be getting into later on why you should use it. Secondly, make sure you have created a method of creating an array of the sockets so your server can connect to multiple clients at once. Finally, make sure the socket can send in the method of TCP. You can use UDP, though this guide will not be covering that.

Using Binary Packets

First off, there is a few vocab words we should know:

DataID:

  • In every packet, there should be a DataID that will state what the packet is for, whether it is a character moving, attacking, etc.

Data:

  • This is the actual data in the packet, which can contain ANYTHING that relates to the DataID.

Separator:

  • Often used to separate parts of data in the packet for variable-length data so you know when one piece of data ends and another starts.

EndID:

  • Often used as a single character which states the end of a packet. Used to combine broken packets or separate packets when sent more then once at a time.


If you have ever used ORE, Elysium or anything of that sort, you would see your packets laid our like this:

<DataID>(<Separator><Data><Separator>...)<EndID>

The problem with this is the DataID is usually many characters long, along with that there are separators which separate the data strings, then finally the end character that states that the packet has ended. Why is this so bad? It seems pretty decent, right? Well, lets look at this method:

<DataID(1 Byte)>(<Data><Data>...)

Simple, huh? There are no Separators or EndIDs, just the DataID and the Data. This is why we want to use Binary.

For those who do not know how to use Binary, I suggest looking at some guides on File I/O with Binary - the method for this will be just the same. We will write a predefined series of variables, then read them in the exact same order they were sent. This is vital!! If you fail to read the data in the same order it is sent, your packet will be ruined. Now to make things even more complicated, we will use only a byte array to send data.

Creating A Byte Array Packet

The reason we will want to use a byte array for our packets since it is the easiest to organize, any data can fit in a byte array and some sockets can be optimized to send in byte arrays. Though, it can easily add an extra layer of confusion when using a byte array. To simplify things, I have created a Visual Basic 6.0 Class Module which converts Bytes, Integers, Longs and Strings to a byte array and places them in a temporary array. Though before I show that, let me explain how things will work.

To convert to a byte array from another type of variable, it is easy to use CopyMemory API or a built-in conversion (Visual Basic 6.0 does not have these). In CopyMemory, if you place the Source as a Long (4 bytes), the Destination as a Byte Array with the elements of 0 to 3, and the length as 4 bytes, the long will be copied to the byte array. Simple as that. The same method can be used to reverse engineer the bytes to a long. Strings get a bit more complicated, though. The same method will be used (Destination = Byte Array, Source = String, Length = String Length) though we will have to put in an extra byte at the start of the string to tell the length of the string. Strings are variable length (unless defined elsewise, which can be very inefficient and completely ruin the idea of optimizing your bandwidth since you will send many empty characters) so we have to define how long they are. Before you convert the byte array back to a string, make sure to grab the extra byte at the start of the string to get the length.

Now that we have a very broad idea of how to create the binary data, here is how it will look as far as how much bandwidth is used when comparing Binary VS Strings: Byte = Binary has advantage in double/triple digits Integer (2 bytes) = Binary has advantage of triple or higher digits Long (4 bytes) = Binary has advantage at 5 or more digits String = Binary always takes 1 more byte

As it looks from that, Binary isn't that great, right? Well, remember the separators and end characters? Yeah, say good bye to those. Huge bonus right there.

Byte Array Packet Conversion Code

Here is a little code I wrote for a class module to get and put data into a byte buffer. (Visual Basic 6.0)

Private PutBytePos As Long
Private GetBytePos As Long
Private ByteBuffer() As Byte
Private ByteBufferUbound As Long
 
Private Declare Sub CopyMemory Lib "kernel32" Alias "RtlMoveMemory" (Destination As Any, Source As Any, ByVal Length As Long)
 
Public Sub Overflow()
 
    'Force the buffer to overflow to break the packet reading loop
    GetBytePos = ByteBufferUbound + 10
 
End Sub
 
Public Function HasBuffer() As Byte
 
    'If there is a buffer or not
    If PutBytePos > 0 Then HasBuffer = 1
 
End Function
 
Public Sub Clear()
 
'Clear all the values so we can use the buffer from the start again
 
    PutBytePos = 0
    GetBytePos = 0
    ByteBufferUbound = -1
    Erase ByteBuffer
 
End Sub
 
Public Function Get_Buffer_Remainder() As Byte()
Dim b() As Byte
 
'Return the remainder of the byte array buffer
 
    'Check if we already hit the end of the buffer
    If UBound(ByteBuffer) - GetBytePos + 1 = 0 Then
        Get_Buffer_Remainder = b
    Else
        ReDim b(0 To (UBound(ByteBuffer) - GetBytePos + 1))
        CopyMemory b(0), ByteBuffer(GetBytePos), UBound(ByteBuffer) - GetBytePos + 1
        Get_Buffer_Remainder = ByteBuffer
    End If
 
End Function
 
Public Function Get_Buffer() As Byte()
 
    'Return the byte array buffer
    Get_Buffer = ByteBuffer
 
End Function
 
Public Function Get_Byte() As Byte
 
'Retrieve a byte from the buffer (1 byte)
    If GetBytePos > ByteBufferUbound Then Exit Function
 
    CopyMemory Get_Byte, ByteBuffer(GetBytePos), 1
    GetBytePos = GetBytePos + 1
 
End Function
 
Public Function Get_Integer() As Integer
 
'Retrieve an integer from the buffer (2 bytes)
    If GetBytePos + 1 > ByteBufferUbound Then Exit Function
 
    CopyMemory Get_Integer, ByteBuffer(GetBytePos), 2
    GetBytePos = GetBytePos + 2
 
End Function
 
Public Function Get_Long() As Long
 
'Retrieve a long from the buffer (4 bytes)
    If GetBytePos + 3 > ByteBufferUbound Then Exit Function
 
    CopyMemory Get_Long, ByteBuffer(GetBytePos), 4
    GetBytePos = GetBytePos + 4
 
End Function
 
Private Function Get_PutPos() As Long
 
'Return the put byte position
    Get_PutPos = PutBytePos
 
End Function
 
Public Function Get_ReadPos() As Long
 
'Return the read byte position
    Get_ReadPos = GetBytePos
 
End Function
 
Public Function Get_String() As String
Dim tempB() As Byte
Dim ArraySize As Byte
 
    On Error GoTo ErrOut
 
    'Retrieve a string from the buffer
    ArraySize = Get_Byte    'Get the size of the string
 
    'Check for a valid size before sizing the array
    If ArraySize = 0 Then Exit Function
 
    'Resize the temp byte array to fit the size of the string
    ReDim tempB(ArraySize - 1)
 
    'Copy the bytes for the string in the buffer to the temp byte array
    CopyMemory tempB(0), ByteBuffer(GetBytePos), ArraySize
 
    'Convert the byte array to Unicode
    Get_String = StrConv(tempB, vbUnicode)
    GetBytePos = GetBytePos + ArraySize
 
ErrOut:
 
End Function
 
Public Function Get_StringEX() As String
Dim tempB() As Byte
Dim ArraySize As Integer
 
    On Error GoTo ErrOut
 
    'Retrieve a very long string from the buffer
    ArraySize = Get_Integer 'Get the size of the string
 
    'Check for a valid size before sizing the array
    If ArraySize = 0 Then Exit Function
 
    'Resize the temp byte array to fit the size of the string
    ReDim tempB(ArraySize - 1)
 
    'Copy the bytes for the string in the buffer to the temp byte array
    CopyMemory tempB(0), ByteBuffer(GetBytePos), ArraySize
 
    'Convert the byte array to Unicode
    Get_StringEX = StrConv(tempB, vbUnicode)
    GetBytePos = GetBytePos + ArraySize
 
ErrOut:
 
End Function
 
Public Sub Put_Byte(ByVal Value As Byte)
 
    'Store a byte (1 byte)
    If ByteBufferUbound < PutBytePos Then
        ReDim Preserve ByteBuffer(0 To PutBytePos)
        ByteBufferUbound = PutBytePos
    End If
    CopyMemory ByteBuffer(PutBytePos), Value, 1
    PutBytePos = PutBytePos + 1
 
End Sub
 
Public Sub Put_Integer(ByVal Value As Integer)
 
    'Store an integer (2 bytes)
    If ByteBufferUbound < PutBytePos + 1 Then
        ReDim Preserve ByteBuffer(0 To PutBytePos + 1)
        ByteBufferUbound = PutBytePos + 1
    End If
    CopyMemory ByteBuffer(PutBytePos), Value, 2
    PutBytePos = PutBytePos + 2
 
End Sub
 
Public Sub Put_Long(ByVal Value As Long)
 
    'Store a long (4 bytes)
    If ByteBufferUbound < PutBytePos + 3 Then
        ReDim Preserve ByteBuffer(0 To PutBytePos + 3)
        ByteBufferUbound = PutBytePos + 3
    End If
    CopyMemory ByteBuffer(PutBytePos), Value, 4
    PutBytePos = PutBytePos + 4
 
End Sub
 
Public Sub Put_String(ByRef Value As String)
Dim tempB() As Byte
Dim i As Long
 
    'Store a string
 
    'Check for invalid value
    If Value = vbNullString Then Exit Sub
 
    'Cache the UBound
    i = Len(Value) - 1
 
    'Convert the string to a byte array
    tempB = StrConv(Value, vbFromUnicode)
 
    'Store a byte-long value that represents the size of the string
    If i > 254 Then Exit Sub
    Put_Byte i + 1
 
    'Resize the array to fit the string
    If ByteBufferUbound < PutBytePos + i Then
        ReDim Preserve ByteBuffer(0 To PutBytePos + i)
        ByteBufferUbound = PutBytePos + i
    End If
 
    'Store the byte array of the string into the buffer byte array
    CopyMemory ByteBuffer(PutBytePos), tempB(0), i + 1
    PutBytePos = PutBytePos + i + 1
 
End Sub
 
Public Sub Put_StringEX(ByRef Value As String)
Dim tempB() As Byte
Dim i As Long
 
    'Store a very long string
 
    'Check for invalid value
    If Value = vbNullString Then Exit Sub
 
    'Cache the UBound
    i = Len(Value) - 1
 
    'Convert the string to a byte array
    tempB = StrConv(Value, vbFromUnicode)
 
    'Store a byte-long value that represents the size of the string
    If i > 32760 Then Exit Sub
    Put_Integer i + 1
 
    'Resize the array to fit the string
    If ByteBufferUbound < PutBytePos + i Then
        ReDim Preserve ByteBuffer(0 To PutBytePos + i)
        ByteBufferUbound = PutBytePos + i
    End If
 
    'Store the byte array of the string into the buffer byte array
    CopyMemory ByteBuffer(PutBytePos), tempB(0), i + 1
    PutBytePos = PutBytePos + i + 1
 
End Sub
 
Public Sub Set_Buffer(ByRef Value() As Byte)
 
    'Clear the values
    Clear
 
    'Cache the UBound
    ByteBufferUbound = UBound(Value)
 
    'Set the byte buffer to the size of the array being sent in
    ReDim ByteBuffer(0 To ByteBufferUbound)
 
    'Copy to the byte buffer
    CopyMemory ByteBuffer(0), Value(0), ByteBufferUbound + 1
 
End Sub
 
Public Sub Allocate(ByVal NumBytes As Long)
 
    'Allocate the memory in bulk
    ByteBufferUbound = ByteBufferUbound + NumBytes
    ReDim Preserve ByteBuffer(0 To ByteBufferUbound)
 
End Sub
 
Public Sub PreAllocate(ByVal NumBytes As Long)
 
    'Allocate the memory in bulk without preserving data
    ByteBufferUbound = NumBytes - 1
    PutBytePos = 0
    GetBytePos = 0
    ReDim ByteBuffer(0 To ByteBufferUbound)
 
End Sub
 
Private Sub Class_Initialize()
 
    'Set the buffer UBound
    ByteBufferUbound = -1
 
End Sub

Using Your Byte Array

Hopefully you are able to figure out a way for sending variables into a byte array. Now what? Well first off you want to decide how to use the buffer. I myself use the buffer as a conversion buffer - every time I want to send the user data, I clear the buffer, put data in the buffer, then pull the data out so I can send it to a buffer (we will get into buffers later) for all the specified users. Another method would be just to set the byte array buffer as the user's buffer so you don't have to constantly be clearing it - overall, this method would be a bit faster, though may not be applicable in certain situations.

Whatever method you are using, you will have to create the packets in the same way every time, depending on what the command is for the packet. The first thing to place in the buffer is the Command ID, always. This will tell how much information will be in the packet and what it is for. Once you put in the Command ID, you just have to place in the data. Simple as that! Here is an example:

    Buffer.Put_Byte CommandID.Move
    Buffer.Put_Integer CharacterID
    Buffer.Put_Byte Direction

Now on to the client. Create a loop that will grab a byte (the Command ID) then process it. For the above example, the buffer will grab the first byte, read that it is a movement byte, then grab out an integer then a byte IN THAT ORDER. If there is no more information in the buffer, then your packet is at the end. Though, if there is more information, you either screwed up and placed more data then you took out (in which case, your whole packet is ruined and you probably have invalid data) or you have more then one command in the packet, which is great, and why we have the loop! Grab the next byte again, which should be another Command ID, then process it again.

Sorry, that is all I can really help without getting to specific. This should give you a good idea of what you need to do, though. It is not a very advanced concept, but you just have to make sure you do it correctly.

Buffering Your Data

Sending tons of tiny packets can come in handy... if you are using UDP in a FPS. Though packets have headers, so if you send 10 bytes of useful information, you may actually be sending over 30 bytes of information total. Combined with that, packets have been known to fill up with void characters to reach the maximum size for the packet. So sending as much data as you can at once can be a great way to decrease your bandwidth.

The easiest way to do this is to create a routine for putting data into a buffer - each user should have their own buffer (unless everyone recieves the same information). During the server's loop, fill up the buffer with the byte array information you created with the above method. Once the buffer is full, if it becomes full, send it straight to the user. Make sure you do not go over the maximum size - rather, check if the data you will place in the buffer will overload it, and send the buffer if it will. It is often best to send the buffer at the end of the server loop, too - that way the user doesn't have to wait a few loops just to get information which is outdated by now.

CommandIDs And You

This method does NOT require binary packet sending! As you have hopefully noticed (if you were reading), I stated the CommandIDs as being a single byte many times. Though, I have never told you how to do that yet. Well, now is the time. As said before, the CommandIDs are what the client and server use to recognize what kind of packet it is receiving so it will know how to process it. Often people will write "M" for Move, or "COBJ" for Create Object, or other things such as that. The problem with this is that it is large, you have to memorize what every CommandID is, and you are only using a few of the 256 characters you have. I recomend one of two ways: Create a series of Constants, or create a User Defined Type with each command.

    Constant Method:
    Public Const Move As Byte = 1
    Public Const Attack As Byte = 2
    Public Const Talk As Byte = 3
 
    UDT Method:
    Public Type CommandID
        Move As Byte
        Attack As Byte
        Talk As Byte
    End Type
    Public CommandID as CommandID

With the UDT method, just set the values at runtime and you are good to go. Just make sure you never use the same value more then once. The same method can be applied with strings:

    UDT Method:
    Public Type CommandID
        Move As String * 1
        Attack As String * 1
        Talk As String * 1
    End Type
    Public CommandID as CommandID

The * 1 on the string makes it a constant-length string of only 1 character - this isn't required, but makes the string much faster and makes sure you will never go more then one character. To set the values, just use Chr$(). Though, I still recomend using the binary method.

Compressing Packets

Compression is always a good way to make things smaller... to put it simply. Though, to compress data well, you often need a lot of data. How can you compress "ABC" to less then 3 characters? Compression will only really work if you have at least 100 bytes of information, which you should hardly ever have. Through my testing, I have found ZLib to be the best compression. There is an example for how to use it in Visual Basic somewhere on the website. My recomendation is to not worry about compression, rather worry about optimizing your packets to be as small as possible through what you put in there. Though, the best way to approach compression is to check the size of your buffer before sending it. If it is greater then at least 100 bytes (recomended greated then 200 bytes), create a temporary byte array with the same data as the buffer. Compress the temporary byte array, and see if the length is shorter - if it is, compress it, THEN place a CommandID at the very beginning of the packet that states the packet is compressed. Do not place the CommandID before compressing, or else you will lose it in the compression!

Nagle Algorithm

The Nagle Algorithm (Nagling) was implemented by John Nagle, working at Ford Aerospace, in 1984. The Nagle Algorithm was designed to make use of large packets, rather then sending tons of smaller packets. This works just like a Packet Buffer (see Buffering Your Data), though done through the socket itself instead of through the application. The Nagle Algorithm, in short, waits for a short time to see if more packets are sent to the socket before sending the data. Although this works well, the problem is the wait that is involved - leaving the Nagle Algorithm enabled will easily push your ping up an extra 200+ milliseconds. It is much preferred to just create a home brewed packet buffer, and to turn this option off. Some sockets come with this enabled by default, including WinSock! To turn this feature off, many packets will offer a TCP_NODELAY or similar flag. Set this flag to TRUE to turn off Nagling. Though, some sockets such as Catalyst SocketWrench and WinSock, when used through the Control, will not have the option to turn this off.

Client-Side Reference Files

Creating files on the client-side that the server can refer to can save a lot of bandwidth. This method is very similar to map files. An example is you can create a file that will store a NPC/Object and all of its information (stats, graphics, name). When the server goes to create this NPC or Object, instead of sending all the information at once, an integer pointing to the ID the NPC or Object information is stored under can be passed instead. This file, though, would have to be updated client-side every time it is updated server-side. Although the update can be taking up a bit of extra bandwidth (even less with a nice compression thrown on it), in the long run, the creation of NPCs and Objects, or any other information you can store in it, will be reduced.

A recomended method of using this is to make a hybrid system to gather information both from reference files and from the server. The reason for this is that with games like Diablo II, for instance, objects can be modified (unique items, sockets, gems in sockets, etc) - it would be too cumbersome to create a list of every single possible object, so instead, just create a list of the most common items. Make sure to create another CommandID - one for gathering information through the reference file, and another for gathering the information directly from the server.

Problems arise with this method when you want to keep information secret. Users can easily decrypt the reference file and read the information in it, finding out the stats of every NPC, Object, and any other kind of thing you stored in the game. Once you place information on the client computer, there is really no way to completely prevent them from reading it. You could just store some information (such as Name and Sprite(s) of the NPC and Objects) while the rest is passed through the server when it is needed, that way you can easily keep your information protected. This will still save you some bandwidth, but the method is crippled a bit as you can not use it to its full potential.