Fun with WebSockets - The communication layer
As someone coming from a web development background, where Statelessness is King and everything is asynchronous as you can get, WebSockets are an interesting concept. You keep a connection always open to the server and can then pass messages pretty-close to instantly.
A couple of years ago (ish), I went to a game jam in Guildford and one team made a dungeon-crawler-type game using Unity that you play on the PC as normal, but other people can vote to place the next room you're going to enter.
This got me thinking that in a future game jam, I could use PHP and JS to help make a game - the best game in the world! (TM) Or, at least, one that was actually playable and winnable, which is the greatest game jam challenge.
The latest Guildford Game Jam has just finished and I finally almost achieved that goal. I created a simple game where Unity on a PC powered the main game screen, and any device with a web browser could create a player and move their character around - trying not to die.
Granted, in the demo at the end a tiny bug prevented anyone from dying and the game locked up - but it was very, very close to actually being playable, and it did have 20 players at the same time.
I was asked a couple of time while there, "Can the websockets code be dropped into any game?" and the answer was a simple "No, this code is trash". But I figured it'd be easy enough to make a generic one that can then be taken and have game logic added to it.
This blog's going to be in 3 parts. The one will deal with the PHP server. At it's simplest, you'll be able to run this as a docker image and then move on. The next couple of steps will cover the JS client, and the Unity C# server.
What can we do with it?
The code'll be simple and relatively blog-friendly, so you'll be able to run a single server, and as many clients as it can handle. In theory that's up to 1000, but realistically it's probably somewhere in the middle-to-high hundreds.
To keep things simple, the communication layer in the middle is very generic, which means you'll have to do all validation in both the Unity server end, and the JS client. If you were going to make something more than a "game jam game", I'd recommend moving a lot more logic to the communication layer and just pushing around what you require, in a validated format. For this demo, though, we're just pushing around JSON object containing anything we want to give them.
The end result of this blog will give us a game that we can join and move around in. The positions for all players will be reflected on both the server and the client screen.
Client / server communication
There are 4 main interactions with our communication layer from either a client or a server. Two of these are relatively simple and based on connection events: connect, and disconnect.
There is a third connection event, message, which we're going to split into two interactions: client / server registration, and update client / server data. By default, WebSockets simply triggers an event on any message, but we want to artificially create the concept of a server and client, so need to create a basic message interpreter. By extension, if you didn't want the external server, you could move all game logic into this communication layer and simply have clients connect.
When either a client or server connects, we register their connection for later access. They're automatically assigned an id, which'll make it easy to track which user's which. Everything else occurs in other interactions.
...
public function onOpen(ConnectionInterface $connection): void
{
$this->connections[$connection->resourceId] = $connection;
echo 'New connection #' . $connection->resourceId . "\n";
}
When a client registers, the communication layer sets the connection as a client and informs the server of a new client. It also sends any current server data back to the client. Conversely, when a server registers, the communication layer sets the connection as server and informs all clients about the new server. The server is also sent all of the current client's data.
...
public function onMessage(ConnectionInterface $from, $message): void
{
...
$messageData = json_decode($message, true);
...
if (isset($messageData[self::INPUT_CONNECT])) {
if ($messageData[self::INPUT_CONNECT] === self::CONNECTION_CLIENT) {
$this->clients[$id] = [];
echo 'Connected client #' . $id . "\n";
$this->sendClientMessage([self::OUTPUT_CLIENT_ID => $id, self::OUTPUT_SERVER_DATA => $this->serverData], $id);
$this->sendServerMessage([self::OUTPUT_CLIENT_ID => $id, self::OUTPUT_CLIENT_DATA => []]);
}
elseif ($messageData[self::INPUT_CONNECT] === self::CONNECTION_SERVER) {
$this->serverId = $id;
echo 'Connected server #' . $id . "\n";
foreach ($this->clients as $clientId => $clientData) {
$this->sendServerMessage([self::OUTPUT_CLIENT_ID => $clientId, self::OUTPUT_CLIENT_DATA => $clientData]);
$this->sendClientMessage([self::OUTPUT_SERVER_DATA => $this->serverData], $clientId);
}
}
return;
}
...
}
If a client updates its data, the connection layer merges the new client data with data it's already holding, and then sends it to the server. If a server updates its data, then the connection layer also merges the new server data with the data it's already holding, and then sends it on. If a client id is set in the server data, then the data will only go that particular client. Otherwise, it'll go to all clients.
...
public function onMessage(ConnectionInterface $from, $message): void
{
...
$messageData = json_decode($message, true);
...
if (isset($messageData[self::INPUT_DATA])) {
$data = $messageData[self::INPUT_DATA];
if (isset($this->clients[$id])) {
$this->clients[$id] = array_merge($this->clients[$id], $data);
$this->sendServerMessage([self::OUTPUT_CLIENT_ID => $id, self::OUTPUT_CLIENT_DATA => $this->clients[$id]]);
}
elseif ($id === $this->serverId) {
$this->serverData = array_merge($this->serverData, $data);
$this->sendClientMessage([self::OUTPUT_SERVER_DATA => $this->serverData], isset($data[self::INPUT_CLIENT_ID]) ? $data[self::INPUT_CLIENT_ID] : null);
}
return;
}
...
}
Lastly, when a client disconnects from the connection layer, we remove their data and inform the server that the client has disconnected. When a server disconnects, all clients are informed that the server has left.
...
public function onClose(ConnectionInterface $connection): void
{
$id = $connection->resourceId;
if ($id === $this->serverId) {
$this->serverId = null;
$this->serverData = [];
$this->sendClientMessage([self::OUTPUT_SERVER_DISCONNECT => true]);
} elseif (isset($this->connections[$this->serverId])) {
$this->sendServerMessage([self::OUTPUT_CLIENT_DISCONNECT => $id]);
}
if (isset($this->clients[$id])) {
unset($this->clients[$id]);
}
if (isset($this->connections[$id])) {
unset($this->connections[$id]);
}
echo 'Connection #' . $id . ' has disconnected' . "\n";
}
Running the code
To run this code, as is, so you can try out the C# and JavaScript parts of this blog (coming later), run the following command:
docker run -d --name sockets-connection --restart unless-stopped -p 100:8080 dittto/sockets-connection:latest
This will download a version of the above code that's stored on Docker Hub.
If you want more information on how to run this with Docker, or want to see more of the source code, go to github.com/dittto/socket-connection.
That's it for now. The next blog will go in to how we can build a simple client in JavaScript.