My 5th book "50 Tips on JavaScript" is on sale. Go get it from https://50tips.dev.
Illustrated guide to 50 of the most interesting JavaScript topics.

Taking Wordle to the next level (case study)

Taking Wordle to the next level (case study) Photo by Alessia Cocconi

I'm sure you know what Wordle is. The game got a lot of traction a couple of months ago when The New York Times bought it. I was playing it too, so I decided to create my version in my native language (Bulgarian). This is how duma.fun was born (in Bulgarian duma means word). And as usually happens with my "little" experiments, I didn't stop at just implementing the basics. I managed to translate the initial ~300 lines of code into a three months-long project. Here's why, what, and how.

The very first draft

First of all, the game's logic is around 30 lines of code. The idea is simple - we have a dictionary of valid words and pick one of them as "today's" word. After that, we compare a guess word against the selected one. If a letter matches, we mark it as green. If a letter exists, but it is on the wrong spot, we color it yellow. Here's how this looks like in JavaScript:

function check(guess, word) {
  const matches = Array.from(Array(5).keys()).map(() => 'X');
  const guessArr = guess.split('').map(l => l.toLowerCase());
  const wordArr = word.split('').map(l => l.toLowerCase());
  // checking for exact matching
  guessArr.forEach((guessLetter, i) => {
    if (guessLetter === wordArr[i]) {
      matches[i] = 'E';
      wordArr[i] = '@';
      guessArr[i] = '_';
    }
  });
  // checking for matching
  guessArr.forEach((guessLetter, i) => {
    const idx = wordArr.findIndex(l => l === guessLetter);
    if (idx >= 0 && idx !== i) {
      matches[i] = 'M';
      wordArr[idx] = '@';
      guessArr[i] = '_';
    }
  });
  return matches;
}

console.log(check('CHAIR', 'TIGER')); // [ 'X', 'X', 'X', 'M', 'E' ]
console.log(check('TIMER', 'TIGER')); // [ 'E', 'E', 'X', 'E', 'E' ]
console.log(check('TIGER', 'TIGER')); // [ 'E', 'E', 'E', 'E', 'E' ]

I decided to represent the matching result in an array of single-letter strings. X means "no match", M means "match but on the wrong spot", and E is about exact matching. As we can see in the example, if the word is TIGER and we try with CHAIR we have I marked with M and R marked with E. Of course, there are a couple of edge cases. The duplication of letters is probably the most interesting one. If we search for HYPER and our guess is APPLE, for example. Only one of the P's should be colored. The second one is in green. To fix this, I decided to loop over the guessed letters twice. The first one is about matching and marking the used letters with @ and _ so they can't be used in the second pass.

What lives where

I had to create a bucket of words in Bulgarian. I found a few dictionaries on the web, and I wrote a few simple scrappers using puppeteer. Quickly I gathered over 5000 words. Also, quickly I realized that a significant part of those is not suitable for the game. I spent a good chunk of time filtering. This was because I got some valuable feedback from the people playing the game. I removed locations, some verbs, and names of people and events.

In the original game, the words were part of the game itself. It was possible to look at the JavaScript bundle and find what the current one is and what comes next. I decided to keep my dictionary and the present word on the server. This decision made my implementation a bit more complicated. Since the words are not on the client, I needed an endpoint to perform the matching.

For the last couple of months, I'm been playing a lot with GCP. So, I decided that this "little" project would be a good exercise. I created a dedicated cloud run for serving the game. The HTML, assets, and the matching endpoint. The words I kept in a single JSON file. After the filtering, they were close to 2000, so the file wasn't so big. The current word was saved into a small Firestore collection.

duma.fun

So, the player gets the game's bundle and starts guessing. My cloud run compares the suggested word with the one from the Firestore collection and gives a response in the format mentioned above.

Updating the word

Since everything is on the server, I set up a special endpoint that a GCP scheduler will hit. From day one, I decided to change the word twice a day at the beginning and end of the day (Bulgarian time). The code behind that new endpoint picks a new word from the bucket and updates the entry in Firestore. It also increments a number that the client uses to understand if the word is changed or not. If yes, it flushes the current progress and starts a new session.

duma.fun

What's in the local storage

An essential aspect of the game is the local storage integration. This is where the current progress is saved. I know that a lot of people look at those stats. For example, how many words you did or how many wins you have in a row. I implemented the same thing. However, I wanted to make the game a bit more collaborative. Or, in other words, to engage somehow with other players. This was the moment when I introduced the registration/profiles.

Registration and rankings

I've implemented a simple registration/login process. Again in Firestore, the players got a chance to create their profile. At this point, the game determined who found the word and made a simple list. This is where the rankings for the current session come from. Of course, registration is not required to play the game. You will be recorded as <anonymous> if you nail the word.

duma.fun

This feature was, I believe, well-accepted because it made the game more competitive. You can see who did the word and when. A week ago, I extended this idea by adding points for each win. The players collect points based on their results in the current session's list. It's not balanced well, though, so I plan to change this bit soon.

The modes

In the original game, the players search for a five letters word. I decided to extend this a bit and offer two other modes. Searching 6 letters word and searching a word based on a picture. This led to some complexity and code refactoring. Mainly because I wanted to reuse the matching logic, it also made me create two new buckets of words. The six-letter words and a different dictionary for those words that have a nice image. The image I got from Google's knowledge API. So, I went through all the words and checked if they had a good visual representation. I did a small app just for this process.

The joker

I spent some time thinking about making the game a bit more interesting. Whether I succeeded or not is debatable. Some people liked the "joker" feature; some did not. It's an option available on the keyword and costs one guess.

duma.fun

If the player agrees to use it, the game will reveal one of the letters. According to my data, almost all the people that clicked on that button used the joker.

duma.fun

Facebook integration

From time to time, I got questions about what the word was today. Those were the people that didn't find it. So, I decided that when the scheduler is changing the word will also post the current one to Facebook. I spent some time making a Facebook page and figured out the needed API calls. The picture became:

duma.fun

Pricing

So, how much does all this cost? Suppose I'm not counting my work; the game costs ~$2 per month. Around 200 people are playing it daily, which means that I'll pay more if there are more players. I have to admit that I did a bunch of optimizations to lower the bill. The most expensive part was the cloud run. Initially, I was hitting it for everything. Here are some of my changes:

  • All the assets, like images and styles, were moved to cloud storage. For the first two months, my cloud run was serving them. Big mistake.
  • Getting the winners for the current session happened in intervals. This approach was producing a lot of requests. I moved that to the Firebase API. So now we have an HTTP request, but it is not to the cloud run.
  • I've made the game a SPA. In the beginning, all the modes were separate pages, and changing from 5 letters to 6 letters meant two requests.

Final words

I'll be more than happy to see you playing the game and giving me some feedback. Check it out here duma.fun.

If you enjoy this post, share it on Twitter, Facebook or LinkedIn. To leave a comment go here.