Planning poker app - a journey from Vue.js to Express through Socket.IO
Daniel Werner
Posted on May 30, 2023
In agile development process it is common to use planning poker to determine the story point of the tickets. There are plenty of free planning poker apps out there, but while using those we usually had some trouble with them. Either it cannot create a new game, or it just freezed and cannot restart, or any other. What every developer thinks in these cases? Let's build our own app, it is simple. Well, yeah, kind of... :)
How it should work?
For the MVP we looked for the basic functionality:
- A user creates a new game, and becomes an admin in that game
- Sharing the game url so the other players can join the game
- Every player gets a card where the points will appear
- Use fibonacci numbers for the possible points
- The admin has the ability to reveal the cards and start a new round
- When the cards are revealed it is not possible to change the points anymore
- The process should be interactive, every change should appear immediately for all the players
Which stack to use?
The obvious choise for the frontend was Vue.js. I am using it on daily basis, and used in many projects before
For the interactivity we need websockets, thus the choice was Socket.IO, because it is a mature enough, battle tested library.
The backend uses Express.js server
How it was built?
First step was to create a new Vue.js app with npm cli:
npm init vue@latest
Install Express:
npm install express
Install Socket.IO
npm install socket.io
Using a single file for the backend stuff: express.js. We use vue router and have 3 routes to hanle: the main route (/), the game route (/game/hashid), and an about page (/about). The goal is to serve both the app and the socketio server on the same domain, we tell express to serve the vue app from public directory for both routes:
app.use('/', express.static('public'));
app.use('/game/:id', express.static('public'));
app.use('/about', express.static('public'));
We define a route in express and handle the new game generation, it generates a new uid for the game, store it in an object and return the id to the frontend. We also store the admin_id of the player who created the game.
app.post('/generate', (req, res) => {
const id = uid(16);
games[id] = {
admin_id: req.body.player_id,
players: {}
};
res.send({id: id});
});
The next step is to handle the connection of the players. We check if the game_id exists, and add the player to the games object. If the admin has joined we return isAdmin: true , to let the frontend know that the current player is an admin, and show them the reveal and reset buttons.
io.on('connection', (socket) => {
let game_id = socket.handshake.auth.game_id;
if(!games.hasOwnProperty(game_id)) {
socket.disconnect();
return;
}
let currentGame = games[game_id];
socket.join(socket.handshake.auth.game_id);
io.to(game_id).emit("players", currentGame.players);
socket.on('join', (data, callback) => {
let name = data.name,
player_id = socket.handshake.auth.player_id;
games[game_id].players[player_id] = {player_id: player_id, name: name, points: null};
io.to(game_id).emit("players", currentGame.players);
callback({
isAdmin: socket.handshake.auth.player_id === games[game_id].admin_id
});
});
The next event we want to handle is when the players choose points. We find the user who sent the points, set the points to that user, and emit the whole players object to all the connected players.
socket.on("points", (...args) => {
let data = args.pop();
games[game_id].players[socket.handshake.auth.player_id].points = data.points;
io.to(game_id).emit("players", currentGame.players);
});
We reached to the admin events, reveal and reset. In both cases we ensure that the event has come from the admin. Reveal is quite simple only sending the reveal event, and all the clients will show the point of the players:
socket.on("reveal", (...args) => {
if(games[game_id].admin_id === socket.handshake.auth.player_id) {
io.to(game_id).emit("reveal");
}
});
In the reset we delete the points of the users, send the reset and players events to the clients:
socket.on("reset", (...args) => {
if(games[game_id].admin_id === socket.handshake.auth.player_id) {
currentGame = games[game_id];
for (let key in currentGame.players) {
currentGame.players[key].points = null;
}
games[game_id] = currentGame;
io.to(game_id).emit("reset");
io.to(game_id).emit("players", currentGame.players);
}
});
The last but not least thing to handle is the disconnect event, where we remove the disconnected player for the data, and let everyone know that we don't have that player anymore:
socket.on('disconnect', () => {
socket.leave(socket.handshake.auth.game_id);
delete games[socket.handshake.auth.game_id].players[socket.handshake.auth.player_id];
io.to(game_id).emit("players", games[socket.handshake.auth.game_id].players);
});
With this we finished the server part, let's take a look at some interesting parts of the frontend. I don't want to bore the reader with all the details of the desing and html, we'll just focus on the socket communication.
Let's create a socket.js where we create a state to store all the game related data:
import { reactive } from "vue";
import { io } from "socket.io-client";
export const state = reactive({
connected: false,
greetEvents: [],
players: {},
isAdmin: false,
game: {
status: null
}
});
export const socket = io('/', {autoConnect: false});
socket.on("connect", () => {
state.connected = true;
});
socket.on("disconnect", () => {
state.connected = false;
});
socket.on("players", (...args) => {
state.players = args.pop();
});
When the player opens the home page, and starts a new game, we send and ajax request to the backend and if it was successfull, redirect the user to the game page.
generateGame() {
if (!this.player_id) {
this.player_id = uid(16);
}
axios.post('/generate', {
player_id: this.player_id
}).then(
(response) => {
console.log(response)
this.url = '/game/'+response.data.id;
sessionStorage.setItem(response.data.id + '_player_id', this.player_id);
state.isAdmin = true;
this.$router.push('/game/'+response.data.id);
});
}
After this step a modal appears and the player can fill in their name and connect to the game. This step is handled in the mounted hook of the vue component. To prevent losing the active game when reloading the page, we store the player_id for the active game in the session storage, and use it to connect to the game if present (if a page reload happens).
mounted() {
this.game_id = this.$route.params.id;
this.selectBonusPoint();
this.player_id = sessionStorage.getItem(this.game_id + '_player_id');
if (sessionStorage.getItem(this.game_id + '_player_name')) {
this.name = sessionStorage.getItem(this.game_id + '_player_name');
}
if (!this.player_id) {
this.player_id = uid(16);
sessionStorage.setItem(this.game_id + '_player_id', this.player_id);
}
socket.auth = {
game_id: this.$route.params.id,
player_id: this.player_id
};
socket.connect();
if (this.name) {
this.joinGame();
} else {
this.showModal = true;
}
}
Before we finish our journey let's check the join game method, which will send the payer's name to the backend to show up on the players card. This is the point where we receive and store the isAdmin property to determine if our user is admin in the current game:
joinGame() {
socket.emit('join', {
name: this.name
}, (response) => {
state.isAdmin = response.isAdmin
});
sessionStorage.setItem(this.game_id + '_player_name', this.name);
}
Conclusion
This was my very first time playing with Socket.IO and the Express framework. I had fun developing this project, and I see a lot of possibilities using this technology for the future projects as well. If you are interested in the source code, it is open source you can find it here: https://github.com/wdev-rs/planningpoker.
Posted on May 30, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.