Demo:
Available Here (Note: app may be down to conserve server resources)
This is an attempt to create an electronic version of "Ultimate Monopoly". The idea originates from here.
The Website:
- Users can create accounts and sign in (no verification needed for demo simplicity)
- Customizable profile avatars based on user "level"
- Live updating list of game rooms that can be joined
- Page for creating a game room
Server/Client Synchronization:
One challenge is synchronizing server and client events. I wanted to keep these in sync without sending constant packets, which means the movement paths of tokens have to be pre-programmed as opposed to simply sending updated coordinates each frame.
Example of a synchronized event:
Client side queue:
- Move token from Boardwalk to "Go"
- Pause on "Go" and show animation of player gaining $200
- Continue moving token to Vermont Avenue
- Stop and show animation of player paying $6 to another player
Server side queue:
- Wait the time for token to move from Boardwalk to "Go"
- Add $200 to the player's balance and delay the same amount of time as the client. Send update to clients so that they see the updated balance.
- Wait the time for the token to move from "Go" to Vermont Avenue
- Take $6 from the player and give it to the other player. Send update to clients so that they see the updated balance.
- End action and send game update to show menu again for player
Note that the client receives this entire queue as a single packet. This allows for a smooth chain of animations and requires less bandwidth. If a client lags behind for some reason, it will "jump" forward to resync when a new animation queue is received from the server.
The Event Chain and Javascript Promises
In a turn-based game like Monopoly, almost all user input events occur as a chain (the main exceptions to this being trading and auctions).
The server must not only keep track of the event chain through user input events, but also handle exceptions, inject events into the chain, and interrupt the chain when necessary.
My first approach was using callbacks in the traditional Nodejs style. However, I quickly realized that passing callbacks to functions was cluttering the code. It also created issues with interrupting event chains. Some events immediately end a player's turn -- for example, being sent to jail. With callbacks, there was no easy way to handle this scenario without adding more and more complexity to the functions being passed as arguments to every function.
After struggling with this for awhile, I decided to rewrite the entire application with promises. This turned out to be quite a challenge, but greatly improved the flow of the program.
The heart of the event chain is the "menu" stage. This is where the application generates a list of possible options for the player and awaits a decision:
var actionAfter = () => {
result(null);
return this.showOptions();
}
switch (input.action) {
case 'debugRoll':
return this.rollDice(input.value).then(actionAfter).catch(onError);
case 'roll':
return this.rollDice().then(actionAfter).catch(onError);
case 'rollJail':
// TODO separate roll function for this check
this.inJail = false;
return this.endTurn();
case 'rollAgain':
return this.rollDice().then(actionAfter).catch(onError);
case 'move':
return this.moveDistance(this.rollDistance).then(() => this.moved = true).then(actionAfter).catch(onError);
case 'moveAnywhere':
result(null); // Update immediately to hide menu
return this.jumpTo(this.game.getTile(input.value)).then(() => this.moved = true).then(actionAfter).catch(onError);
case 'voucher':
result(null); // Update immediately to hide menu
return this.useVoucher(input.value).then(actionAfter).catch(onError);
case 'continue':
return this.gotoNextUnowned().then(actionAfter).catch(onError);
case 'upgrade':
return this.upgradeProperty(this.game.getTile(input.value)).then(actionAfter).catch(onError);
case 'downgrade':
return this.downgradeProperty(this.game.getTile(input.value)).then(actionAfter).catch(onError);
case 'card':
return this.useCard(input.value).then(actionAfter).catch(onError);
case 'endTurn':
return this.endTurn();
case 'declareBankruptcy':
this.active = false;
return this.endTurn();
default:
return console.log('invalid input from player ' + this.name);
}
At this point, there are no active promises in effect. But once the player sends an input, the promise chain is started. Each possible action is wrapped in an overarching promise which includes an error handling routine. On resolve, the event chain runs to completion before returning back to the menu generation stage. On reject, the exception routine is called, usually ending the player's turn early.
There a HUGE benefit to this setup: any unexpected exceptions that occur during this event chain (specifically, bugs in the program), will simply end the player's turn and go on to the next player's turn. Without promises, these exceptions would require individual error handlers. Without promises, javascript does not natively pass exceptions back up the callback chain.
Socket.io Through a Reverse Proxy
This app runs on a local server port, and is mapped to the "monopoly.connorguingrich.com" subdomain through a proxy.
ServerName monopoly.connorguingrich.com
ProxyPass / https://localhost:3004/ retry=0
ProxyPassReverse / https://localhost:3004/
RewriteEngine on
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule ^/?(.*) "wss://localhost:3004/$1" [P,L]
Include /etc/letsencrypt/options-ssl-apache.conf
SSLCertificateFile /etc/letsencrypt/live/monopoly.connorguingrich.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/monopoly.connorguingrich.com/privkey.pem
A proxy and reverse proxy ensures a one-to-one mapping between requests to "monopoly.connorguingrich.com" and the backend port.
One complication in this scenario is the websocket connection. By default, Socket.io will fall back to an HTTP connection if the websocket fails. This can give the illusion that the websocket is working properly. However on further inspection, you can see that the request for the websocket connection has failed.
The websocket will still operate on HTTP only, but with increased latency and connection timeouts.
The easiest way to correct this is to use ReWrite Engine to properly direct wss:// requests to the backend server.