Using WebSockets With PHP
For my senior design project class, my group decided to try our hand at making a multiplayer 3D tank shooter game (called Mega Hovertank Wars) that plays in an HTML5-compliant browser. Along with one other team member (the other two were pretty much dead weight), we worked heartily on the game engine, but it turns out he didn’t have much experience with programming in anything except JavaScript. Obviously, that won’t cut it for writing a game server.
Luckily, I have a hefty amount of experience with PHP, and we were really set on writing our code so that it could run on just about any server or web hosting plan out there, otherwise I probably would have taken a C++ or Java approach.
My original plan was to use long-polling with AJAX since most other Comet implementations required packages to be installed on the server that usually weren’t allowed on most web hosting plans. However, through a combination of finding this to be too slow for effective gameplay and discovering that Chrome had websocket support, I decided to change over to using sockets, though sticking with PHP (just because I like it).
Client Side
Unfortunately, it turns out that since we had written our engine to use WebGL, we had to make the game run in Firefox 3.7a versions, aka Minefield, since that has the best WebGL support. The problem is that Minefield doesn’t have websocket support, so I had to use a bridge to use Flash sockets instead of websockets.
After perusing the web a bit, it turns out there was already a solution out there by gimite, aka Hiroshi Ichikawa, that uses a combination of other solutions floating around the Internet to provide websocket support to browsers that don’t yet provide it natively. You can check it out at GitHub, but it basically breaks down like this:
- FABridge.js – a JavaScript file apparently provided by Adobe itself from back in 2006. It’s responsible for navigating AS instances.
- WebSocketMain.swf – a Flash file you embed with swfobject.js. This is the file that actually connects via sockets with the socket server.
- swfobject.js – this is the result of a project called SWFObject by bobbyvandersluis and TenSafeFrogs that aimed to provide a standards-friendly way to embed Flash content with JavaScript.
- web_socket.js – this emulates websockets with the Flash object’s sockets for browsers that do not natively support websockets. Notice that if the WebSocket object exists, the file returns immediately
All you really have to do to set up is include these files into your webpage:
1 2 3 4 5 |
I also include JQuery because I use it in other places in the game client page (which we’ll get into at another time). Next, all you have to do is initialize your socket and start writing your client code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 | var ws; var up = 0, down = 0, left = 0, right = 0; var w = 0, a = 0, s = 0, d = 0; var space = 0; var gdata = 'userdata'; $(document).ready(function() { ws = new WebSocket("ws://192.168.1.67:12345/websocket/server.php"); ws.onopen = function(e) { log('WebSocket - status '); ws.send('Initialize connection data'); }; ws.onmessage = function(e) { log('Received: ' + e.data); }; ws.onclose = function(e) { log('Disconnected - status '); }; $('#msg').focus(); $(document).keydown(function(e) { var mapped = 0; switch(e.keyCode) { case 38: if(up == 0) up = mapped = 1; break; case 40: if(down == 0) down = mapped = 1; break; case 37: if(left == 0) left = mapped = 1; break; case 39: if(right == 0) right = mapped = 1; break; case 87: if(w == 0) w = mapped = 1; break; case 65: if(a == 0) a = mapped = 1; break; case 83: if(s == 0) s = mapped = 1; break; case 68: if(d == 0) d = mapped = 1; break; case 32: if(space == 0) space = mapped = 1; break; } if(mapped) send('1|'+gdata+'|'+up+'|'+down+'|'+left+'|'+right+'|'+w+'|'+a+'|'+s+'|'+d+'|'+space+ '|'+Player.bSphere.x+'|'+Player.bSphere.y+'|'+Player.bSphere.z+'|'+Player.facing); }); $(document).keyup(function(e) { var mapped = 1; switch(e.keyCode) { case 38: up = mapped = 0; break; case 40: down = mapped = 0; break; case 37: left = mapped = 0; break; case 39: right = mapped = 0; break; case 87: w = mapped = 0; break; case 65: a = mapped = 0; break; case 83: s = mapped = 0; break; case 68: d = mapped = 0; break; case 32: space = mapped = 0; break; } if(!mapped) send('1|'+gdata+'|'+up+'|'+down+'|'+left+'|'+right+'|'+w+'|'+a+'|'+s+'|'+d+'|'+space); }); }); function send(msg) { if(!msg) { alert('Message can not be empty\n' + msg); return; } try{ ws.send(msg); log('Sent: '+msg); } catch(ex){ log(ex); } } function quit() { log('Goodbye!'); ws.close(); ws = null; } function log(msg) { $('#log').append('<br />' + msg); } |
This, in and of itself, is based on the phpwebsocket project by georgenava. I kept his client-side example code largely intact because it turned out to actually serve as a pretty functional debugging console for my game server code. I’ve modified it, though, to also send keyboard input data. I actually turn this into JSON in my final code, but for now, this is good enough.
You’ll notice the key parts are line 7 which makes the websocket connection, line 8 which is the function that runs when the connection opens, line 12 which is the function that runs when the websocket receives a message, and line 15 which is the function that runs when the connection is terminated either client or server-side. This is where I put a solid chunk of my game client code, but I left it out so you could get the gist of what’s happening.
Server Side
Like I said before, this is based on georgenava’s phpwebsocket project. What I’ve kept mostly untouched are the WebSocket function (makes the socket), listen function (main loop that runs, waiting for data from the sockets), connect function, dohandshake function, getheaders function, and his helper functions like console, say, wrap, and unwrap. I mean, no need to fix what’s not broken, right?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 | <?php class socketServer { private $ip; private $port; private $users; private $games; private $master; private $sockets; private $debug; private $dbc; function __construct($ip, $port) { $this->ip = $ip; $this->port = $port; $this->games = array(); $this->users = array(); $this->debug = true; $this->master = $this->WebSocket($ip, $port); $this->sockets = array($this->master); $this->dbc = new dbConnect(); mysql_select_db('cs4311', $this->dbc->conn); $this->listen(); } private function WebSocket($address, $port) { $master = socket_create(AF_INET, SOCK_STREAM, SOL_TCP) or die("socket_create() failed"); socket_set_option($master, SOL_SOCKET, SO_REUSEADDR, 1) or die("socket_option() failed"); socket_bind($master, $address, $port) or die("socket_bind() failed"); socket_listen($master, 20) or die("socket_listen() failed"); echo "Server Started : " . date('Y-m-d H:i:s') . "\n"; echo "Master socket : " . $master."\n"; echo "Listening on : " . $address . " port " . $port . "\n\n"; return $master; } private function listen() { while(true){ $changed = $this->sockets; socket_select($changed, $write = NULL, $except = NULL, NULL); foreach($changed as $socket) { $this->checkTime(); if($socket == $this->master){ $client = socket_accept($this->master); if($client < 0) { $this->console('socket_accept() failed'); continue; } else{ $this->connect($client); } } else { $bytes = @socket_recv($socket, $buffer, 2048, 0); if($bytes == 0) { $this->disconnect($socket); } else { $user = $this->getuserbysocket($socket); if(!$user->handshake) { $this->dohandshake($user, $buffer); } else{ $this->process($user, $buffer); } } } } } } private function connect($socket) { $user = new User(uniqid(), $socket); array_push($this->users, $user); array_push($this->sockets, $socket); $this->console($socket . " CONNECTED!"); } private function process($user, $msg) { $msg = $this->unwrap($msg); $this->say("< " . $msg); $data = explode('|', $msg); switch($data[0]) { case 0: $this->send($user->socket, '10|'.$data[1].'|'.$data[2].'|'.$data[3].'|'.$data[4].'|Received initialized user data'); $user->gid = $data[1]; $user->uip = $data[2]; $user->uid = $data[3]; $user->gud = $data[4]; if(array_key_exists($data[1], $this->games) === FALSE) { $game = new Game($data[1]); $this->games[$data[1]] = $game; $this->console('New game object ' . $data[1]); } $this->games[$data[1]]->addPlayer($data[3], $user->socket); $this->incrementStat(0, $data[3]); if($this->games[$data[1]]->isFull()) { $players = $this->games[$data[1]]->getPlayers(); $msg = '0'; foreach($players as $player) { $usr = $this->getuserbyuid($player['uid']); $msg .= '|' . $usr->gud . '|' . $usr->uid; } $this->broadcast($data[1], 0, $user->socket, $msg); } break; case 1: $this->send($user->socket, 'Received input data!'); $this->broadcast($data[1], $data[3], $user->socket, $msg); break; case 2: if($this->getuserbyuid($data[5]) != null) { $this->incrementStat(1, $data[5]); $this->incrementStat(2, $data[6]); $this->broadcast($data[1], $data[3], $user->socket, $msg); } break; default: $this->console('Unknown command: ' . $msg); break; } } private function broadcast($gid, $uid, $socket, $msg = '') { $players = $this->games[$gid]->getPlayers(); foreach($players as $player) { if($player['uid'] != $uid) $this->send($player['socket'], $msg); } } private function send($client, $msg) { $this->say("> " . $msg); $msg = $this->wrap($msg); socket_write($client, $msg, strlen($msg)); } private function disconnect($socket) { $found = null; $n = count($this->users); for($i = 0; $i < $n; $i++) { if($this->users[$i]->socket == $socket) { $found = $i; break; } } if(!is_null($found)) { $this->broadcast($this->users[$found]->gid, $this->users[$found]->uid, 0, '2|'.$this->users[$found]->gid.'|'.$this->users[$found]->uip.'|'.$this->users[$found]->uid.'|'.$this->users[$found]->gud.'|'.$this->users[$found]->uid.'|0'); $this->games[$this->users[$found]->gid]->dropPlayer($this->users[$found]->uid, $socket); if(count($this->games[$this->users[$found]->gid]->players) < 1) { $query = sprintf("DELETE FROM games WHERE gid = '%s'", $this->users[$found]->gid); if(!mysql_query($query, $this->dbc->conn)) $this->console('Failed to delete game ' . $this->users[$found]->gid . ' from the database: ' . mysql_error()); unset($this->games[$this->users[$found]->gid]); } array_splice($this->users, $found, 1); } $index = array_search($socket, $this->sockets); socket_close($socket); $this->console($socket . " DISCONNECTED!"); if($index >= 0) { array_splice($this->sockets, $index, 1); } } private function dohandshake($user, $buffer) { $this->console("\nRequesting handshake..."); $this->console($buffer); list($resource, $host, $origin) = $this->getheaders($buffer); $this->console("Handshaking..."); $upgrade = "HTTP/1.1 101 Web Socket Protocol Handshake\r\n" . "Upgrade: WebSocket\r\n" . "Connection: Upgrade\r\n" . "WebSocket-Origin: " . $origin . "\r\n" . "WebSocket-Location: ws://" . $host . $resource . "\r\n" . "\r\n"; socket_write($user->socket, $upgrade . chr(0), strlen($upgrade . chr(0))); $user->handshake = true; $this->console($upgrade); $this->console("Done handshaking..."); return true; } private function getheaders($req) { $r = $h = $o = null; if(preg_match("/GET (.*) HTTP/" ,$req,$match)) { $r = $match[1]; } if(preg_match("/Host: (.*)\r\n/" ,$req,$match)) { $h = $match[1]; } if(preg_match("/Origin: (.*)\r\n/",$req,$match)) { $o = $match[1]; } return array($r, $h, $o); } private function getuserbysocket($socket) { $found = null; foreach($this->users as $user) { if($user->socket == $socket) { $found = $user; break; } } return $found; } private function getuserbyuid($uid = -1) { $found = null; foreach($this->users as $user) { if($user->uid == $uid) { $found = $user; break; } } return $found; } private function console($msg = "") { if($this->debug) { echo $msg . "\n"; } } private function say($msg = "") { echo $msg . "\n"; } private function wrap($msg = "") { return chr(0) . $msg . chr(255); } private function unwrap($msg = "") { return substr($msg, 1, strlen($msg) - 2); } } ?> |
The really important part is the listen function. It runs on an infinite while loop and listens to the socket for any incoming data. You’ll notice that in the socket_select function call (a built-in PHP method), the fourth parameter is NULL, meaning that it can block indefinitely to receive data. If it didn’t, we would end up disconnecting every connected client due to the else statement at line 47. It says that if the bytes received by the socket is equal to zero, then disconnect that client because it hasn’t sent any data and likely isn’t viewing the page anymore. This causes a slight problem with checking the time left for a game round, but that’s something we decided to live with for now.
Next, you should take notice of the dohandshake function. The way websockets work is that servers respond to GET requests that have the Connection field set to “Upgrade” (among other things) with a handshake response. They communicate a token so they can authenticate each other’s handshakes. After this, the client and server have full-duplex communication capabilities, perfect for our game.
I’ve removed a lot other functions from the SocketServer class so that we can focus on just the socket stuff. I’ve also chosen to not include the Game, User, and dbConnect classes since they don’t have anything whatsoever to do with sockets, but if you really want to see them, you can ask for them in the comments below. The process function, however, has been untouched just so you can see that the data sent from the JavaScript of the client is unchanged once it gets to the server.
To run the server, you’ve got instantiate the SocketServer from another PHP file with the proper IP and port that you’d like to run it from and then run the file from the server’s shell or command prompt. Also, in most server configurations, you’ve got to run a Flash socket policy server. You can head over to Jacqueline Kira Hamilton’s writeup on the topic and run her Perl script, otherwise connecting to your server may end in terminal sadness.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | #!/usr/bin/php -q <?php error_reporting(E_ALL); set_time_limit(0); ob_implicit_flush(); include_once('classes/SocketServer.php'); include_once('classes/User.php'); include_once('classes/Game.php'); include_once('classes/dbConnect.php'); $ip = '129.118.120.190'; $port = 12345; try{ $mySocketServer = new SocketServer($ip, $port); } catch (Project_Exception_AutoLoad $e) { echo "FATAL ERROR: CAN'T FIND SOCKET SERVER CLASS."; } ?> |
Anyways, that’s enough of that. If I feel like it, I may start to go over the game engine, but that is upwards of 1,000 lines of code, so that may never happen.
Thank you for this great article.
Greate article. Keep posting such kind of info on your blog.
Im really impressed by it.
Hey there, You have done an incredible job. I’ll definitely digg it and for my part suggest to my friends.
I’m confident they’ll be benefited from this website.