Multiplayer Platformer Log #1 – Naive Implementation
Motivation
After finishing the project for my parents I was talking about last time, I finally had some time to get back to game development. After spending something over a month playing with unity engine, I decided to switch my focus back to something that got me to programming in the first place.
Multiplayer online games.
Back in high school, I implemented very naive multiplayer bomberman game as my school-leaving project. That was the last time I did anything with networking, so about 7 years ago.
Given how popular the so-called io games are becoming lately, and how often I see developers attempting to make one, I decided to try it too. I had always planned to try making one, but it never seemed like a good time to start. Until now. I was looking for something more challenging anyway.
Initially I thought of making a small simple non-persistent semi-rpg online multiplayer game. Seemingly it might be easier to do that than a good single player one. But then I talked with a friend about potentially developing some game together and a new idea was born. For now, let’s just call it a multiplayer platformer.
The Naive Implementation
The first iteration was done in basically two days. When I say naive, I mean it wouldn’t work properly in the world.
Initial Design
The game, as we envisioned it, doesn’t have active interaction between players. It therefore doesn’t need any sort of synchronization between players.
This means that we can get away with doing the physics simulation on the clients and the server would just broadcast positions. Great, that seems simple enough.
Client Physics
This was easier than I expected. Using the powerful Phaser game framework, heavily copying code from Hexus’ Arcade Slopes I had simple demo running locally in no time.
There isn’t even a reason for me to post any code here, it was mostly just simplified version of arcade slopes demo mentioned above.
The Server
Since the game is supposed to be an HTML5 web game, we have to use WebSocket to pass around data. This is simple enough on the client side, which runs inside a web browser, but what about server?
For most people with JavaScript experience I would recommend NodeJS. I’m not very versed in JS, so I chose Haxe. There are mainly three reasons for this:
- I used it a lot, so I am more comfortable with it.
- Proper type checking, code completion and compile time errors is something without which my productivity drops significantly. I also love Haxe macros.
- If the game is also written in Haxe, I get the benefit of being able to share parts of codebase between the game and the server. It’s the same benefit as when using JS/TypeScript and NodeJS for the server.
First step was to create Haxe externs for Phaser.
Now we can create a simple class to connect the client to server:
class Connection {
static private var ws:haxe.net.WebSocket;
public static function connect(game:Game){
ws = WebSocket.create("ws://127.0.0.1:8000/");
ws.onopen = function() {
trace('connected!');
game.state.start("Play");//start the Play state
};
ws.onmessageString = function(message) {
//TODO
};
}
static public function informPos(x:Float, y:Float) {
ws.sendString('update,$x,$y');
}
}
All we need to do is call Connection.connect(game)
from Preloader
state and once we are connected, the Play state is started.
In there, we need client code to inform server about the current player’s position every 100 ms.
Why 100 ms? Sending the data every frame would take too much bandwidth. We can send less state updates and interpolate between them.
//class Play that extends phaser.State
var acc:Float = 0;//accumulator to keep track of how much time passed since we informed server about our position
override public function update():Void {
acc += game.time.elapsedMS;
if (acc > 100){
acc -= 100;
Connection.informPos(player.x, player.y);
}
//rest of the update omitted
}
So, now we can move around and also send our position to server every 100 ms. We can’t see anybody else though, let’s implement the server and broadcast the positions.
Server code uses Haxe WebSocket library by soywiz.
public static function main(){
//start listening on 0:8000
var server = WebSocketServer.create('0.0.0.0', 8000, 5, true);
//keep a list of connected players
var handlers:Array<WebSocketHandler> = [];
while (true) {//server loop
try {
//check for incoming connection
var websocket = server.accept();
if (websocket != null) {
handlers.push(new WebSocketHandler(websocket));
}
var toRemove = [];
for (handler in handlers) {
//if this returns false, it means we lost the connection
if (!handler.update()) {
//store the handler so we can remove it out of the loop
toRemove.push(handler);
//inform every other player that this one was removed
for (h in handlers){
if (h != handler)
try {
handler._websocket.sendString('rem,${handler._id}');
} catch (err:Dynamic){ }
}
} else {
//broadcast this player's position to every other one
for (h in handlers){
if(h != handler)
h._websocket.sendString(
'update,${handler._id},${handler.x},${handler.y}');
}
}
}
while (toRemove.length > 0)//remove disconnected players
handlers.remove(toRemove.pop());
Sys.sleep(0.1);//wait 100 ms, this is our server tick
} catch (e:Dynamic) {
trace('Error', e);
trace(CallStack.exceptionStack());
}
}
}
class WebSocketHandler {
static var _nextId = 0;
public var _id = _nextId++;
public var _websocket:WebSocket;
public var x:Float;
public var y:Float;
public function new(websocket:WebSocket) {
_websocket = websocket;
_websocket.onmessageString = onmessageString;
}
public function update():Bool {//check for incoming messages
try{
_websocket.process();
} catch (e:Dynamic){
return false;
}
return _websocket.readyState != Closed;
}
function onmessageString(message:String):Void {
var data = message.split(",");
switch (data[0]) {
case "update":
x = Std.parseFloat(data[1]);
y = Std.parseFloat(data[2]);
default:
trace('unrecognized header ${data[0]}');
}
}
}
Right, that should do it. Now we should be sending everyone’s position information to everyone else. We don’t send a message when new player connects, but client can handle that.
Back to client, we need to handle the "update"
message coming from the server.
Inside Connection
class we implement the onmessageString
that we omitted before.
ws.onmessageString = function(message) {
var data:Array<String> = message.split(",");
switch (data[0]) {
case "update":
playState.updatePos(
Std.parseInt(data[1]), //id
Std.parseFloat(data[2]), //x
Std.parseFloat(data[3])); //y
case "rem":
playState.rem(Std.parseInt(data[1]));
default:
trace('Unrecognized data with header ${data[0]}');
}
};
Now we just implement the required functionality inside the Play
state and we are done.
var otherPlayers:Map<Int, Sprite> = new Map();
public function updatePos(id:Int, x:Float, y:Float) {
//if the other player is missing, add him first
if (!otherPlayers.exists(id)){
var other = add.sprite(0, 0);
//attaching some texture omitted
instance.otherPlayers.set(id, other);
}
instance.otherPlayers.get(id).position.set(x, y);
}
public function rem(id:Int) {
if (instance.otherPlayers.exists(id)){
instance.otherPlayers.get(id).destroy();
instance.otherPlayers.remove(id);
}
}
It looks something like this (the movement on the right is local, on the left sent via websocket):
We can see how choppy the left side looks, which is because there isn’t any interpolation implemented. That would normally be the next step, but at this point in development I realized it’s not a good way to go after all.
For some information on how to implement client side interpolation, see this great post by Gabriel Gambetta.
The problems
Ignoring code quality (it is a prototype after all), there are a couple of issues.
The main one is how data is passed around. It should be optimized for bandwidth, i.e. instead of passing strings we should pass around binary data. The header could be single byte, the position data could be turned into 2 bytes and the precision would still suffice. There are techniques to go even further with bandwidth optimization, but anything’s better than sending around strings.
Another fairly crucial one is that, since server is just broadcasting values, nothing stops players from cheating. It would be trivial to teleport around or change gravity. There are checks that server could do, but it would never be perfect.
Also as the game design evolves, maybe we might want to allow interaction between players. That would be pretty difficult when each player is running the physics simulation locally.
Solution
The solution to both the player interaction and the cheating is to do the physics simulation on the server. This is lot more complex, especially if we want the game to feel good.
This will probably include a lot of the usual concepts like Client-Side Prediction and Server Reconciliation. I will have to think about it a bit (or a lot) and I will get back to you with log #2.