How To Make a Multiplayer iPhone Game Hosted on Your Own Server Part 1
A while back, I wrote a tutorial on How To Make A Simple Multiplayer Game with Game Center. That tutorial showed you how to use Game Center to create a peer-to-peer match. This means that packets of game data were sent directly between the connected devies, and there was no central game server. However, sometimes […] By Ray Wenderlich.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
How To Make a Multiplayer iPhone Game Hosted on Your Own Server Part 1
50 mins
Writing Socket Data
When you look at sample code online, people often write to the output stream willy-nilly assuming it will always work.
However, it turns out sometimes you can’t write to an output stream, because it is full! To be safe, you should only write to it when it tells you there are bytes available, with the NSStreamEventHasSpaceAvailable message.
So we’ll keep a buffer of data to write, and only send it to the output stream when it tells us it has space available.
But how do we know how much to write? Well, you just make a reasonable guess (we’ll try 1024) and give it your best shot. The output stream will return to you how much actually got written (which could be less than 1024), so you update your buffer accordingly.
So try this out by making the following changes to NetworkController.h:
// Inside @interface
NSMutableData *_outputBuffer;
BOOL _okToWrite;
// After @interface
@property (retain) NSMutableData *outputBuffer;
@property (assign) BOOL okToWrite;
This creates the data buffer for the output as mentioned above, and also a variable that keeps track of whether it’s currently OK to write to the output buffer.
Then make the following changes to NetworkController.m:
// Add in synthesize section
@synthesize outputBuffer = _outputBuffer;
@synthesize okToWrite = _okToWrite;
// Add at top of connect
self.outputBuffer = [NSMutableData data];
// Add in disconnect, at end of _outputStream != nil case:
self.outputBuffer = nil;
// Add above outputStreamHandleEvent
- (BOOL)writeChunk {
int amtToWrite = MIN(_outputBuffer.length, 1024);
if (amtToWrite == 0) return FALSE;
NSLog(@"Amt to write: %d/%d", amtToWrite, _outputBuffer.length);
int amtWritten = [self.outputStream write:_outputBuffer.bytes maxLength:amtToWrite];
if (amtWritten < 0) {
[self reconnect];
}
int amtRemaining = _outputBuffer.length - amtWritten;
if (amtRemaining == 0) {
self.outputBuffer = [NSMutableData data];
} else {
NSLog(@"Creating output buffer of length %d", amtRemaining);
self.outputBuffer = [NSMutableData dataWithBytes:_outputBuffer.bytes+amtWritten length:amtRemaining];
}
NSLog(@"Wrote %d bytes, %d remaining.", amtWritten, amtRemaining);
_okToWrite = FALSE;
return TRUE;
}
// In outputStreamHandleVent, in NSStreamEventHasSpaceAvailable case
BOOL wroteChunk = [self writeChunk];
if (!wroteChunk) {
_okToWrite = TRUE;
}
In writeChunk, we choose to write either the amount of data in the buffer or 1024, whichever is smaller, then try to write it.
The output stream will tell us how much actually got written - we use this to shorten our data buffer.
If we wrote something, set set _okToWrite to FALSE, because every time you write something, NSStreamEventHasSpaceAvailable will be called when there's more space available. But if we didn't write something, NSStreamEventHasSpaceAvailable won't be called again until you write something, so we set _okToWrite to TRUE so we know it's safe to write to the output stream right away next time someone adds to the output buffer.
OK - now we have the code that will write whatever's in the output buffer out on the wire. We just need the code to put something into the output buffer - by marshalling the message parameters!
Marshalling Data
Marshalling is a fancy way of saying "take some data you want to send to the other side, and convert it into a sequence of bytes."
You might think "why not use NSCoding? That's a pretty handy way of converting objects to bytes."
That would probably be a bad idea. When sending data across the network, you want the data to be as small as possible, because the smaller the data is the faster it will be transmitted and the less of the user's bandwidth you will consume. NSCoding serialization is pretty verbose, especially when compared to handcrafted messages.
To make things easier, let's write a helper class to handle all of the data marshalling. Go to File\New\New File, choose iOS\Cocoa Touch\Objective-C class, and click Next. Enter NSObject for Subclass of, click Next, name the new class MessageWriter.m, and click Save.
Replace MessageWriter.h with the following:
#import <Foundation/Foundation.h>
@interface MessageWriter : NSObject {
NSMutableData * _data;
}
@property (retain, readonly) NSMutableData * data;
- (void)writeByte:(unsigned char)value;
- (void)writeInt:(int)value;
- (void)writeString:(NSString *)value;
@end
This is an object that creates an NSMutableData storing the message to send across the wire. It provides some methods you can use to write bytes, integers, and strings to the data buffer to be sent across.
Switch to MessageWriter.m and replace it with the following:
#import "MessageWriter.h"
@implementation MessageWriter
@synthesize data = _data;
- (id)init {
if ((self = [super init])) {
_data = [[NSMutableData alloc] init];
}
return self;
}
- (void)writeBytes:(void *)bytes length:(int)length {
[_data appendBytes:bytes length:length];
}
- (void)writeByte:(unsigned char)value {
[self writeBytes:&value length:sizeof(value)];
}
- (void)writeInt:(int)intValue {
int value = htonl(intValue);
[self writeBytes:&value length:sizeof(value)];
}
- (void)writeString:(NSString *)value {
const char * utf8Value = [value UTF8String];
int length = strlen(utf8Value) + 1; // for null terminator
[self writeInt:length];
[self writeBytes:(void *)utf8Value length:length];
}
- (void)dealloc {
[_data release];
[super dealloc];
}
@end
writeByte is pretty straightforward - it just appends the byte directly ot the NSMutableData.
writeInt is similar, except it has to deal with endianness.
If you're not familiar with endianness, it turns out that different types of machines store bytes in different orders. Some machines store an integer with the least significant byte at the highest address, and some store the least significant byte at the lowest address.
This matters for network programming, because we don't want the order at which integers are written to mess up our code. So to avoid this, we always send data in "network byte order", which is big-endian btw.
Long story short, whenever you want to send data across the network it should be stored in network byte order. Luckily this is really easy - you can use the htonl and ntohl functions, which stand for "network to host long" and "host to network long".
So writeInt converts the integer to network bytes order with htonl, then writes the resulting bytes.
Finally, writeString is a special case. When we send the string across, the other side needs to know how long the string is before reading it out. So we write the length of the string first, followed by the actual bytes of the string.
OK, let's put this all together and make use of it! Start with NetworkController.h - add two new states to the NetworkState enum:
NetworkStatePendingMatchStatus,
NetworkStateReceivedMatchStatus,
The NetworkStatePendingMatchStatus is when we are sending our initial message over to the server and waiting for it to tell us what our match status is, and the NetworkStateReceivedMatchStatus is for when we receive this.
Then switch to NetworkController.m and make the following changes:
// Add to top of file, after #import
#import "MessageWriter.h"
@interface NetworkController (PrivateMethods)
- (BOOL)writeChunk;
@end
typedef enum {
MessagePlayerConnected = 0,
MessageNotInMatch,
} MessageType;
// Add right after init method
#pragma mark - Message sending / receiving
- (void)sendData:(NSData *)data {
if (_outputBuffer == nil) return;
int dataLength = data.length;
dataLength = htonl(dataLength);
[_outputBuffer appendBytes:&dataLength length:sizeof(dataLength)];
[_outputBuffer appendData:data];
if (_okToWrite) {
[self writeChunk];
NSLog(@"Wrote message");
} else {
NSLog(@"Queued message");
}
}
- (void)sendPlayerConnected:(BOOL)continueMatch {
[self setState:NetworkStatePendingMatchStatus];
MessageWriter * writer = [[[MessageWriter alloc] init] autorelease];
[writer writeByte:MessagePlayerConnected];
[writer writeString:[GKLocalPlayer localPlayer].playerID];
[writer writeString:[GKLocalPlayer localPlayer].alias];
[writer writeByte:continueMatch];
[self sendData:writer.data];
}
// In inputStreamHandleEvent, right after "TODO: Send message to server"
[self sendPlayerConnected:true];
// In outputSreamHandleevent, right after "TODO: Send message to server"
[self sendPlayerConnected:true];
Here we import the MessageWriter class we just wrote, and predeclare our writeChunk method in a private category. This is a little trick you can use to predeclare a method without putting it in the header file where other clases can be aware of it.
We then create an enum for our message types. There's just two right now. MessagePlayerConnected is the "hello" message we're about to send to the server as soon as we connect. MessageNotInMatch is the message we expect the server will send back to us, telling us we're not already in a match.
sendData is a helper method that will take a buffer of data and enqueue it to the output buffer. It prepends it with the length of the buffer, which is important so the other side knows when it has fully received the message.
This is where the _okToWrite flag comes in handy. If it's set, we can call writeChunk to write some of it to the outputStream right away - otherwise we have to wait for the next NSStreamEventHasSpaceAvailable.
sendPlayerConnected changes the state to NetworkStatePendingMatchStatus, and uses the MessageWriter to send a MessagePlayerConnected over to the other side. It contains the current player's Game Center player ID, alias, and whether we want to continue any previous match.
Finally, we call sendPlayerConnected whenever the input/output streams are both opened.
One last change - switch to HelloWorldLayer.m and add the debug printouts for the new states at the end of stateChanged:
case NetworkStatePendingMatchStatus:
debugLabel.string = @"Pending Match Status";
break;
case NetworkStateReceivedMatchStatus:
debugLabel.string = @"Received Match Status";
break;
w00t - your code can now send a message over to your server! Compile and run your code, and you should see your debug label advances to a new state:
You should also see something like the following in the console log:
CatRace[37306:207] Opened output stream CatRace[37306:207] Queued message CatRace[37306:207] Ok to send CatRace[37306:207] Amt to write: 32/32 Wrote 32 bytes, 0 remaining. Ok to send
Of course, nothing happens because we haven't updated our server to be able to read any incoming data! So let's do that next.