Building ghostgame.io - A multiplayer word game

How we designed, built, and deployed a multiplayer game

Back-story!

We're a group of friends who enjoy playing casual multiplayer games. We've played many different games so far and we've had differing opinions about them. But there was one that we played years ago and we all enjoyed and was in-fact, offline. It was the Ghost Game.

Ghost is a written or spoken word game in which players take turns adding letters to a growing word fragment, trying not to be the one to complete a valid word. [wikipedia)]

We're a bunch of programmers. There's this game that we used to play and like. And we didn't find one that we could play online. Can you see where this is headed? Yes, we built it.

What's this game?

Ghost is a simple turn-based word game. When it's your turn, add a letter to the end of the word fragment. You must make sure that the next player can form a word by adding another letter. That is, there's at least one valid, longer word that can be formed after your turn is done. If you fail to do so you lose a point and the next round starts.

ghost_play_1.png

Side note: In the classic variant the round would have ended at Player 3 because a valid word "DYE" is completed. But we removed this constraint in our game.

If you suspect that the previous player has ended a word or that no valid word can be made by adding more letters, challenge them! If they cannot respond with a longer word they lose a point.

ghost_play_2.png

In case Player 1 was unable to come up with a longer word when challenged, Player 1 would have lost the point instead of Player 2

That is all! You are now ready to play Ghost Game.

Game Design!

Once all players join a “room” the game starts. As each player adds a letter, the word fragment grows and everyone in the room is able to see the current fragment along with details such as scores and whose turn it is. When a player decides to challenge, the previous player is prompted for a response. Based on the validity of the response the score is updated and the next round starts.

One difference from the classic version is that the game does not end the moment a valid word is formed. Instead a round can end only when a player challenges another player. We made this choice because of the hundreds of words that people are unaware of, and how quickly it got annoying when a round ended by inadvertently forming a valid word. Ora? Frae? Stol? No, thank you. We didn’t want our players rage quitting.

ghost_simple_states.png

Our implementation consisted of a few more states and transitions to handle cases such as players exiting

With these details settled, we created mockups of the various screens. Each state in the figure above roughly translated into a page. We subsequently built it with HTML, CSS, and Javascript. We did this without using a Javascript framework, although in hindsight it might have simplified some of our work. We found Bulma CSS convenient for styling and responsive design.

Ghost _GIF.gif

A time-lapse of how the design changed over various commits

System Design!

The most important consideration in our system's design was to allow clients to receive updates in real time. That is, when someone played their turn in the game we needed all other players in that game to be updated. Since we had a turn based game we could get away with a delay of up to a few seconds. We did not require strict millisecond-latency guarantees.

We also wanted a database to persist the game state remotely. This was to allow clients to pick right back from where they left off, in case of intermittent connection failures (as long as it's within the rules of the game, of course).

Lastly we wanted a way to modify this state without allowing rogue players to tamper with it. Having this in the backend also gave us the benefit of keeping the client implementation relatively simple.

After iterating through multiple designs, we decided to use -

  1. Firebase Cloud Firestore, for storing state data and updating clients in real time
  2. Google Cloud Functions, for implementing game logic and updating the state

Clients listen to state changes with the help of Firebase SDK and update the state by calling the HTTP endpoints for Cloud Functions

ghost_backend.png

Cloud Firestore

Firestore is a NoSQL database offered as a managed service by Firebase. A NoSQL database gave us the flexibility of rapidly changing schema as we added features. We also didn't foresee the need to perform complex joins. Most importantly, Firestore provided a client SDK that allowed it to listen to changes in a document in real time. This saved us a huge amount of time and effort in building a realtime system with websockets. Polling the database periodically was another option, but something about many clients constantly pinging a backend made us squirm in discomfort. Maybe it'll make sense some day, if the number of open connections becomes a bottleneck or it begins to cost us more. But not today.

Each room is designated a document in the database, which holds the complete state of that game. This includes current word fragment, information about the players, whose turn it is, and scores. All players are subscribed to this document and are notified of changes. When there's a change, each client refreshes its screen to show the latest information. We capped the number of players who can join a room to ensure that there isn't a huge fan-out on a write. It's not fun to wait for your turn when playing with too many people either.

Cloud Functions

We used Google Cloud Functions to implement business logic and make updates to the state. Although Firebase itself provides Functions it didn't support Python at the time. We were okay with the minor inconvenience with deployments; and we could still access everything we needed using Firebase or Google Cloud console.

Google Cloud Functions also provides HTTP endpoints, which our clients use to trigger updates. We used Firebase Admin SDK to make writes to the database.

When a client triggers a function it first performs checks to verify that the player is authorized to make that move. If it is a valid move, it updates the database. To implement the challenge functionality, we used an open-source word list for validating words.

Concurrency bugs!

While testing the game we would occasionally run into situations where a player joins a room but is unable to play. Our backend would register a success but the player’s details would mysteriously disappear from the database. It took us some brainstorming to figure out why this happened.

Turned out that this was a consequence of concurrent calls to the player addition API. One function execution would read the state, make changes by adding the new player, and write it back. If another function modified the document just before this write it would get overwritten. To fix this we could use an atomic append operation. This would resolve the issue with overwrites but it also meant that we might occasionally breach the player cap for a room. So we decided to use transactions. Since the functions executed in a secure environment (i.e, not on the client) we used the Admin SDK which internally uses pessimistic concurrency control by use of database locks. We updated all other APIs where we expected similar issues. We also added retries for timeouts due to lock wait.

Privacy!

Being privacy conscious ourselves we made the decision to not include any form of tracking. No cookies, no trackers, no fingerprinting, no analytics scripts. We don’t ask for unnecessary info, nor do we require users to log in. You only need a username for the other players in the room to identify you. We don’t have ads and don’t plan on having them either. Support us on our BuyMeACoffee page (it has its own Terms and Privacy Policy, which seemed reasonable to the best of our understanding at the time)

So what does it look like?

Find out for yourself! Head over to the website. Bring some friends along. We hope you have fun ♥️

See you at ghostgame.io 👻