This is an online implementation of a game: Compatibility. You can find the rules here. The idea is to have a browser that perform all the game's mechanics and to discuss with your friends through a video call. In my version of the game, I only implemented the part of the rules for individual play.
Here is the workflow:
- Every player is discussing in a video call
- One of the player is the master of the game and will create a new game with its browser using the route
/compatibility
- When done, the master will send a link to all the players via the video call
- Each player can click on its name to begin the game
- The master has a dedicated web page to start a new round with a new theme
- At each round, each player selects five images corresponding to the theme
- When every player has send their images, everyone can review the results and the score
- The game continues from step 5
Here are the different files in my project:
app.js
: it is the server of the application. It initializes the game and receives every request from the clients and manages them. It also contains the classGame
that is used to represent a current game and a utility functiongetGetParameters
.master.js
andplayer.js
: these files corresponds to the client applications. We have two kind of client: the master of the game and the players. These files will be included respectively bymaster.html
andplayer.html
.master.html
andplayer.html
: represent the data the master and the players will see on their browser.start.tmpl
andplay.tmpl
: template files used during the creation of the game. These files will be modified at run time to generate a dynamic HTML page.create.html
: HTML form to create a new game.dico.txt
: text file containing the list of the themes.
There is also an img
folder containing all the images of the game named like n.jpg with n a number between 1 and 52.
This is my first application using Node.js. I followed these tips to stay focused and finish your hobby project and specially the idea of coming up with a working prototype as quickly as you can. I know there is a lot to improve but I'm specially interested on the following aspects:
- Route management: in my code,
app.js
parses the request URL to determine which page it should respond. I'm wondering how it is possible to do it better. - Client/Server workflow: the clients of the application, when they are waiting for an event from the server, send a request every second. Does it exist something to prevent that?
- Template management: I created some
.tmpl
file thatapp.js
will uses to produce dynamic HTML files. Maybe something more efficient exists.
However I'm also interested by every reviews about security (how to not cheat, input validation,...), good coding practices in JavaScript, locale management (the project is in French but I translated every messages and labels manually for this review) and anything else you find important. Just, I'm currently not interested about CSS considerations.
You can find the full code of the project on its GitHub page. As the project is in French, here is the English translation (I just omit the dictionary and the images here):
app.js
const http = require('http');
const fs = require('fs');
const port = 3000;
var games = {};
var words = [];
fs.readFile('dico.txt', 'utf8', (err, data) => {
words = data.split('\n');
});
class Game {
constructor(players) {
this.state = "starting";
this.word = '';
this.remainingWords = words;
this.send = {};
this.matcher = '';
this.matcher_id = 0;
this.players = {};
decodeURIComponent(players).split(';').forEach((player) => {
this.players[player] = -1;
});
}
newWord() {
var idx = Math.floor(Math.random() * Math.floor(this.remainingWords.length));
this.word = this.remainingWords.splice(idx, 1)[0];
this.state = "waitingPlayers";
this.matcher = Object.keys(this.players)[this.matcher_id];
this.matcher_id = (this.matcher_id + 1) % Object.keys(this.players).length;
this.send = {};
return this.word + '#' + this.matcher;
}
}
function getGetParameters(req) {
var get = {};
var idx;
if(idx = req.url.indexOf('?')) {
var paramList = req.url.substring(idx+1);
if(paramList.indexOf('&')) {
paramList.split('&').forEach((param) => {
get[param.split('=')[0]] = decodeURIComponent(param.split('=')[1]);
});
} else {
get[paramList.split('=')[0]] = decodeURIComponent(paramList.split('=')[1]);
}
}
return get;
}
const server = http.createServer((req, res) => {
const get = getGetParameters(req);
/*
/compatibility
Print the form to create the game
*/
if(req.url == '/compatibility') {
fs.readFile('create.html', 'utf8', (err, data) => {
if(err) {
res.statusCode = 500;
console.log('Unable to read create.html');
res.end('Server error');
} else {
res.statusCode = 200;
res.end(data);
}
});
}
/*
/compatibility/create
Create a new game and print the link to give to the players
*/
else if(req.url == '/compatibility/create') {
var body = '';
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => {
var players = decodeURIComponent(body.split('=')[1]);
if(!games[players]) {
games[players] = new Game(players);
}
fs.readFile('play.tmpl', 'utf8', (err, data) => {
if(err) {
res.statusCode = 500;
console.log('Unable to read play.tmpl');
res.end('Server error');
} else {
res.statusCode = 200;
res.end(data.replace(/<PLAYERS>/g, players));
}
});
});
}
/*
/compatibility/start
Print one link for each players in the game
*/
else if(req.url.startsWith('/compatibility/start')) {
var links = '';
get["players"].split(';').forEach((player) => {
links += '<p><a href="/compatibility/game?player='+player+'&players='+encodeURIComponent(get["players"])+'">'+player+'</a></p>';
});
fs.readFile('start.tmpl', 'utf8', (err, data) => {
if(err) {
res.statusCode = 500;
console.log('Unable to read start.tmpl');
res.end('Server error');
} else {
res.statusCode = 200;
res.end(data.replace(/<LINKS>/g, links));
}
});
}
/*
/compatibility/game
Load the HTML page corresponding to the player
*/
else if(req.url.startsWith('/compatibility/game')) {
var file;
if(get['master'] == 1) {
file = 'master.html';
} else {
file = 'player.html';
}
fs.readFile(file, 'utf8', (err, data) => {
if(err) {
res.statusCode = 500;
console.log('Unable to read ' + file);
res.end('Server error');
} else {
if(get['master'] != 1) {
// This is a player
// We need to replace <IMAGES>
var html = '';
for(i = 1; i < 53; i++) {
html += '<img src="img/' + i.toString() + '.jpg" />';
}
res.end(data.replace(/<IMAGES>/g, html));
} else {
res.statusCode = 200;
res.end(data);
}
}
});
}
/*
/compatibility/load
Returns the Game object
*/
else if(req.url.startsWith('/compatibility/load')) {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(games[get['players']]));
}
/*
/comptatibility/connect
A new player is connecting
*/
else if(req.url.startsWith('/compatibility/connect')) {
if(games[get['players']].players[get['player']] == -1) {
games[get['players']].players[get['player']] = 0;
var allConnected = 1;
for(const [player, score] of Object.entries(games[get['players']].players)) {
if(score == -1) {
allConnected = 0;
break;
}
}
if(allConnected) {
games[get['players']].state = "waitingWord";
}
}
res.statusCode = 200;
res.end();
}
/*
/compatibility/newWord
Start a new round with a new word
*/
else if(req.url.startsWith('/compatibility/newWord')) {
var currentGame = games[get['players']];
if(currentGame.state != "waitingWord" && currentGame.state != "computingResults") {
res.statusCode = 500;
console.log("Bad /compatibility/newWord");
console.log(req);
res.end();
} else {
var data = currentGame.newWord();
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end(data);
}
}
else if(req.url.startsWith('/compatibility/send')) {
var currentGame = games[get['players']];
currentGame.send[get['player']] = get['images'];
if(currentGame.state == 'waitingPlayers' && Object.keys(currentGame.send).length == Object.keys(currentGame.players).length) {
currentGame.state = 'computingResults';
const matcher_images = currentGame.send[currentGame.matcher];
for(const [player, images] of Object.entries(currentGame.send)) {
if(player != currentGame.matcher) {
images.split('-').forEach((image, idx) => {
matcher_images.split('-').forEach((matcher_image, matcher_idx) => {
if(image == matcher_image) {
if(idx == matcher_idx) {
currentGame.players[player] += 3;
} else {
currentGame.players[player] += 2;
}
}
});
});
}
}
}
res.statusCode = 200;
res.end();
}
/*
Load *.js file
*/
else if(req.url.startsWith('/compatibility/') && req.url.endsWith('.js')) {
var file = req.url.replace(/\/compatibility\//, '');
fs.readFile(file, 'utf8', (err, data) => {
if(err) {
res.statusCode = 500;
console.log('Unable to read ' + file);
res.end('Server error');
} else {
res.statusCode = 200;
res.end(data);
}
});
}
/*
Load *.img file
*/
else if(req.url.startsWith('/compatibility/img/') && req.url.endsWith('.jpg')) {
var file = req.url.replace(/\/compatibility\//, '');
fs.readFile(file, (err, data) => {
if(err) {
res.statusCode = 500;
console.log('Unable to read ' + file);
res.end('Server error');
} else {
res.statusCode = 200;
res.end(data);
}
});
}
else {
res.statusCode = 404;
res.end('Not found');
}
});
server.listen(port);
master.js
:
/*
getUrlParameter
Return the corresponding HTTP GET parameter.
*/
var getUrlParameter = function getUrlParameter(sParam) {
var sPageURL = window.location.search.substring(1),
sURLVariables = sPageURL.split('&'),
sParameterName,
i;
for (i = 0; i < sURLVariables.length; i++) {
sParameterName = sURLVariables[i].split('=');
if (sParameterName[0] === sParam) {
return sParameterName[1] === undefined ? true : decodeURIComponent(sParameterName[1]);
}
}
};
var refreshInterval;
/*
refreshGame
Function called every seconds to update the data of the game
*/
function refreshGame() {
$.get("/compatibility/load", { players: getUrlParameter('players') }).done( game => {
$('#load').hide();
if(game['state'] == 'starting') {
var html = '';
for(const [player, score] of Object.entries(game['players'])) {
html += '<p>' + player + ' : ' + ( score == -1 ? 'Not connected' : 'Connected' ) + '</p>';
}
$('#starting').html(html);
} else if(game['state'] == 'waitingWord') {
clearInterval(refreshInterval);
$('#starting').hide();
var html = '';
for(const [player, score] of Object.entries(game['players'])) {
html += '<p>' + player + ' : ' + score.toString() + '</p>';
}
$('#ranking').html(html);
$('#newWord').show();
} else if(game['state'] == 'waitingPlayers') {
var html = '';
for(const [player, images] of Object.entries(game['send'])) {
html += '<p>' + player + ' : ' + images + '</p>';
}
$('#results').html(html);
} else if(game['state'] == 'computingResults') {
var html = '';
for(const [player, images] of Object.entries(game['send'])) {
html += '<p>' + player + ' : ' + images + '</p>';
}
$('#results').html(html);
html = '';
for(const [player, score] of Object.entries(game['players'])) {
html += '<p>' + player + ' : ' + score.toString() + '</p>';
}
$('#ranking').html(html);
$('#newWord').show();
$('#game').show();
clearInterval(refreshInterval);
}
});
}
/*
newWord
Ask to the server a new word
*/
function newWord() {
$.get("/compatibility/newWord", { players: getUrlParameter('players') }).done ( data => {
$('#word').text(data.split('#')[0]);
$('#matcher').text(data.split('#')[1]);
$('#game').show();
$('#newWord').hide();
$('#results').html('');
});
refreshInterval = setInterval(refreshGame, 1000);
}
/*
loadGame
Get the informations of the current game to the server and display them.
This function is launched only once at the loading of the page
*/
function loadGame() {
refreshInterval = setInterval(refreshGame, 1000);
}
master.html
:
<html>
<head>
<meta charset="UTF-8">
<script type="text/javascript" src="jquery-3.5.1.min.js"></script>
<script type="text/javascript" src="master.js"></script>
</head>
<body onLoad="loadGame()">
<div id="load">
<p>Loading...</p>
</div>
<div id="starting"></div>
<div id="ranking"></div>
<div id="newWord" style="display: none">
<input type="button" value="Next word" onclick="newWord()" />
</div>
<div id="game" style="display: none">
<p>Current word: <b><span id="word" /></b>
<p>Matcher: <b><span id="matcher" /></b>
<div id="results"></div>
</div>
</body>
</html>
player.js
/*
getUrlParameter
Return the corresponding HTTP GET parameter.
*/
var getUrlParameter = function getUrlParameter(sParam) {
var sPageURL = window.location.search.substring(1),
sURLVariables = sPageURL.split('&'),
sParameterName,
i;
for (i = 0; i < sURLVariables.length; i++) {
sParameterName = sURLVariables[i].split('=');
if (sParameterName[0] === sParam) {
return sParameterName[1] === undefined ? true : decodeURIComponent(sParameterName[1]);
}
}
};
var refreshInterval;
/*
waiting
Function called every seconds when waiting something from the server
*/
function waiting() {
$.get("/compatibility/load", { players: getUrlParameter('players') }).done( game => {
if(game['state'] == 'starting') {
$('#results').hide();
if(game['players'][getUrlParameter('player')] == -1) {
$.get("/compatibility/connect", { players: getUrlParameter('players'), player: getUrlParameter('player') }).done((data) => {
});
}
$('#starting').show();
} else if(game['state'] == 'waitingWord') {
$('#results').hide();
$('#starting').hide();
$('#waiting').show();
} else if(game['state'] == 'waitingPlayers') {
$('#results').hide();
if(!game['send'][getUrlParameter('player')]) {
$('#waiting').hide();
$('#word').text(game['word']);
$('#matcher').text(game['matcher']);
$('#game').show();
clearInterval(refreshInterval);
} else {
$('#game').hide();
$('#starting').show();
}
} else if(game['state'] == 'computingResults') {
$('#starting').hide();
$('#waiting').hide();
var html = '';
for(const [player, images] of Object.entries(game['send'])) {
html += '<p>' + player + ' :</p>';// + images + '</p>';
images.split('-').forEach((image) => {
html += '<img src="img/'+image+'.jpg" />';
});
}
for(const [player, score] of Object.entries(game['players'])) {
html += '<p>' + player + ' : ' + score.toString() + '</p>';
}
$('#results').html(html);
$('#results').show();
}
});
$('#load').hide();
}
/*
sendImages
Send the list of images to the server.
*/
function sendImages() {
var images = [];
for(i = 1; i < 6; i++) {
var image = $('#image' + i.toString()).val();
if(image == '') {
alert("You have to fill all the fields");
return;
}
if(!/^[0-9]+$/.test(image)) {
alert(image + " is not a number");
return;
}
var n = parseInt(image);
if(n < 1 || n > 52) {
alert(image + " should be between 1 and 52");
return;
}
if(images.includes(n)) {
alert(image + " is used multiple times");
return;
}
images.push(n);
}
var images_text = images.join('-');
console.log(images_text);
$.get("/compatibility/send", { players: getUrlParameter('players'), player: getUrlParameter('player'), images: images_text }).done((data) => {
$('#game').hide();
$('#starting').show();
refreshInterval = setInterval(waiting, 1000);
});
}
/*
loadGame
Get the informations of the current game to the server and display them.
This function is launched only once at the loading of the page
*/
function loadGame() {
waiting();
refreshInterval = setInterval(waiting, 1000);
}
player.html
<html>
<head>
<meta charset="UTF-8">
<script type="text/javascript" src="jquery-3.5.1.min.js"></script>
<script type="text/javascript" src="player.js"></script>
</head>
<body onLoad="loadGame()">
<div id="load">
<p>Loading...</p>
</div>
<div id="starting" style="display: none;">
<p>Waiting for the other players</p>
</div>
<div id="waiting" style="display: none;">
<p>Waiting for a new word</p>
</div>
<div id="game" style="display: none;">
<p>Current word: <b><span id="word"></span></b></p>
<p>Matcher: <b><span id="matcher"></span></b></p>
<p>Ordered list of the five images corresponding to the word:</p>
<input type="text" id="image1" />
<input type="text" id="image2" />
<input type="text" id="image3" />
<input type="text" id="image4" />
<input type="text" id="image5" />
<input type="button" value="Send" onclick="sendImages()" />
<div id="image_list">
<IMAGES>
</div>
</div>
<div id="results" style="display: none;">
</div>
</body>
</html>
start.tmpl
<html>
<head>
<meta charset="UTF-8">
</head>
<body>
<LINKS>
</body>
</html>
play.tmpl
<html>
<head>
<meta charset="UTF-8">
</head>
<body>
<p><a href="/compatibility/start?players=<PLAYERS>">Link of the game</a></p>
<p><a href="/compatibility/game?master=1&players=<PLAYERS>">Link for the master of the game</a></p>
</body>
</html>
create.html
<html>
<head>
<meta charset="UTF-8">
</head>
<body>
<form action="compatibility/create" method="POST">
<p>Name of the players (separated by ';') :</p>
<input type="text" name="players" />
<input value="Create" type="submit" />
</form>
</body>
</html>