Build a Wordle-like SMS game with Twilio Serverless

January 14, 2022
Written by
Reviewed by

Build a Wordle-like SMS game with Twilio Serverless

I've recently become obsessed with Wordle, a word puzzle game created by Brooklyn-based software engineer Josh Wardle for his word game-loving partner. As a homage to Josh, and just-for-fun, I created a version of the game that can be played via text message. Read on to learn how to build an SMS word game using the Dictionary API, Twilio Functions, the Twilio Serverless Toolkit, Twilio Assets, and cookies in Twilio Runtime, and play Twordle yourself by texting a 5-letter word or "?" to +12155156567, or here on WhatsApp

SMS conversation with the Twordle game phone number.

Prerequisites

  1. A Twilio account - sign up for a free one here and receive an extra $10 if you upgrade through this link
  2. A Twilio phone number with SMS capabilities - configure one here
  3. Node.js installed - download it here

Get Started with the Twilio Serverless Toolkit

The Serverless Toolkit is CLI tooling that helps you develop locally and deploy to Twilio Runtime. The best way to work with the Serverless Toolkit is through the Twilio CLI. If you don't have the Twilio CLI installed yet, run the following commands on the command line to install it and the Serverless Toolkit:

npm install twilio-cli -g
twilio login
twilio plugins:install @twilio-labs/plugin-serverless

Create your new project and install our lone requirement got, an HTTP client library to make HTTP requests in Node.js, by running:

twilio serverless:init twordle
cd twordle
npm install got@^11.8.3

Add a Static Text File to Twilio Assets

Twilio Assets is a static file hosting service that allows developers to quickly upload and serve the files needed to support their applications. You want your Twilio Asset to be private–this means it will not be accessible by URL or exposed to the web; rather, it will be packaged with our Twilio Function at build time. For more information on Private, Public, and Protected Assets, check out this page.

Copy this file from GitHub containing five-letter words from the English dictionary and add it to your assets folder as words.private.text. You will read the file from your Twilio Function and generate a random word from it that will be used for each Wordle game. The word will be different for each person, and each person can play multiple times a day.

Write the Word Game Logic with JavaScript

cd into the \functions directory and make a new file called game.js containing the following code to import the got module, read the words.txt file from Twilio Assets, create a randomWord function to return a random word from the Asset, and initialize two constants (the user always has five chances to guess the word, and all the words are five letters):

const got = require('got');
let words = Runtime.getAssets()['/words.txt'].open().toString().split("\n");
const randomWord = () => {
    return words[words.length * Math.random() | 0];
}
const maxGuesses = 5;
const wordLength = 5;

Next, add the meaty handleGuess function below the existing code in game.js. The handleGuess takes in a parameter player (an object representing each player), and a guess (the word they text in as a guess.) The function makes a score card which will contain the boxes you return based on how close the user's guess is to the generated random word. In a try block, make an HTTP request with got to the dictionary API using guess: if the page exists, the guess is a word, and you increment the guessesAttempted attribute of the player object. For each letter in the guess, you check if it is in the goal word: if a letter is in the same spot, that spot in the score card will contain a green square (🟩). If a letter is not in the same index as the generated word for the player, but the letter is in the generated word, the score card will contain a yellow square (🟨). Else, the score card will contain a black square (⬛). If the HTTP request is unsuccessful, the score card will be a string telling the user to try again.

const handleGuess = async (player, guess) => {
  let newScoreCard = [];
  try {
    const response = await got(`https://api.dictionaryapi.dev/api/v2/entries/en/${guess}`).json();
    if (response.statusCode !== 404) {
      player.guessesAttempted+=1;
      for (let i = 0; i < guess.length; i++) {
        if (guess.charAt(i) == player.randWord.charAt(i)) {
          if (player.dupLetters[i] != null) {
            player.numCorrectLetters+=1;
          }
          player.dupLetters[i] = null;
          newScoreCard.push('🟩');
        } else if (guess.charAt(i) != player.randWord.charAt(i) && player.randWord.includes(guess.charAt(i))) {
          newScoreCard.push('🟨');
        } else {
          if (!player.incorrectLettersArr.includes(guess.charAt(i))); {
            player.incorrectLettersArr.push(guess.charAt(i));
          }
          newScoreCard.push('⬛');
        }
      }
      console.log(`newScoreCard ${newScoreCard}`);
      return newScoreCard;
    } 
    else { //404 word not in dict
      newScoreCard = "word not found in dictionary! try again!";
      console.log('Word not found!');
      return newScoreCard;
    }
  }
  catch (err) {
    newScoreCard = "word not found in dictionary! try again!";
    console.log('Word not found!');
    return newScoreCard;
  }  
}

After the function to handle each guess, make a function to check if the game is over. For parameters it accepts the player object and scoreCard. If the number of attempted guesses for the player is greater than or equal to five (the most number of guesses a player can have), the number of correct letters guessed is equal to the word length (five), or the score card contains five green squares, the game is over and endFunc returns true. Else, the game continues and returns false.

const endFunc = (player, scoreCard) => {
  if (player.guessesAttempted >= maxGuesses) { 
    console.log(`guessesAttempted >= maxGuesses`);
    return true;
  }
  else if (player.numCorrectLetters == wordLength) {
    console.log("in numCorrect");
    return true;
  }
  else if(scoreCard == `🟩,🟩,🟩,🟩,🟩`) {
    console.log(`scorecard = 🟩,🟩,🟩,🟩,🟩`);
    return true;
  }
  else {
    console.log(`game still going`);
    return false;
  }
}

Call Game Logic in Twilio Functions' Handler Function

The handler function is like the entry point to your app, similar to a main() function in Java or __init__ in Python. In this tutorial, it will run each time someone texts your Twilio number. For more information on Function invocation and execution, read this page.

At the beginning of the handler function, initialize a Twilio Messaging Response object to respond to the player's guess text message, initialize a guess variable which is whatever the player texted in, initialize a responseText string as empty text that you will append to depending on the guess, create a Twilio Response object to handle state management with cookies, and declare a player object whose attributes you will initialize based on the guess. So far, this is what the function should look like, and it should go after the existing code.

exports.handler = async function(context, event, callback) {
  let twiml = new Twilio.twiml.MessagingResponse();
  let responseText = '';
  let guess = event.Body.toLowerCase().trim();
  let response = new Twilio.Response();
  let player;

If the player texts in a question mark, return a message about Josh Wardle who made the game as well as instructions on how to play the game. Add the following lines of code to the handler function:

  if (guess == "?") {
    twiml.message(`Wordle was made by Josh Wardle, a Brooklyn-based software engineer, for his partner who loves word games. You guess a 5-letter word and the responding tiles reflect how close your guess was to the goal word. 🟩 means a letter was in the right spot, 🟨 means the letter was correct but in the wrong spot, and ⬛️ means the letter is not in the goal word.`);
    return callback(null, twiml); //no need for cookies
  }

Then, check if the player has texted in before with cookies. If the player does not exist, generate a new word for them and initialize a new player object with the random word, the guesses attempted (none so far), number of correct letters (none so far), an array of duplicate letters, and an array of incorrect letters guessed (currently empty.) If the player does exist, pull data off the cookie to get the player state and make that the player object. Add the following lines of code to the handler function:

  if (!event.request.cookies.player) { //any guesses attempted? -> new player
    let randWord = randomWord(); //new random word
    player = { //init new player
      randWord: randWord, 
      guessesAttempted: 0,
      numCorrectLetters: 0,
      dupLetters: [...randWord],
      incorrectLettersArr: []
    }
  } else { //else pull data off cookie to get player state
    player = JSON.parse(event.request.cookies.player);
  }

Check the length of the guess and if it's five letters, run the handleGuess function and pass in the player and guess variable from above. Then check if the game is over and if it was a win, send a congratulatory response; otherwise if it's a loss, send a more apologetic message. Under both conditions, remove the player from the cookies to start the player over using response.removeCookie("player");.

If the game is not over, the response message is the scorecard with squares and you can save the game state with the player object using response.setCookie. Set a four-hour time limit in setCookie,  so the user has four hours to guess before the game state is lost–the default time limit for cookies in a Twilio Function is one hour. Lastly, if the guess is not five letters-long, tell the player to send a five-letter word. These instructions should look like the following JavaScript. Add the following lines to the handler function:

  if (guess.length == wordLength) { //5 letters
    let scoreCard = await handleGuess(player, guess); //guessesAttempted increments
    console.log(`scoreCard ${scoreCard}`);
    if(endFunc(player, scoreCard)) { //over, win
      if(guess == player.randWord) {
        responseText += `Nice🔥! You guessed the right word in ${player.guessesAttempted}/${maxGuesses} guesses. You can play again by sending a 5-letter word to guess a new random word 👀 \nThanks for playing our SMS Twordle game. Do head to https://www.powerlanguage.co.uk/wordle for web-based word fun! Original Wordle creator Josh Wardle: as fellow builders we salute you and thank you for inspiring us to create our SMS experiment`
        response.removeCookie("player");
      }
      else if (guess != player.randWord) { //over, lose
        responseText += `Game over 🙈\nThe correct word was ${player.randWord}. Send a 5-letter guess to play again! \nThanks for playing our SMS Twordle game. Do head to https://www.powerlanguage.co.uk/wordle for web-based word fun! Original Wordle creator Josh Wardle: as fellow builders we salute you and thank you for inspiring us to create our SMS experiment`;
        response.removeCookie("player");
      }
    }
    else { //keep guessing, not over
      responseText += `${scoreCard.toString()} \n${player.guessesAttempted}/${maxGuesses} guesses`;
      response.setCookie("player", JSON.stringify(player), [
        'Max-Age=14400' //4 hour time-limit
      ]);
    }
  }
  else { //not 5 letters
    responseText += `"${guess}" is not valid. Please send a word in the dictionary that is 5 letters to get started!`;
    // twiml.message(`"${guess}" is not valid. Please send a word in the dictionary that is 5 letters to get started!`);
    console.log(`randWord ${player.randWord} in invalid `);
  }

At the bottom of the handler method, append the content-type header as XML, add information to the response about playing if the player has only guessed one time, send the responseText in twiml.message, and set the  twiml as the body of the response. Add the following lines to the handler function:

  response.appendHeader('Content-Type', 'text/xml');
  // see if player.guessesAttempted == 1
  if (player.guessesAttempted == 1) {
    responseText += `\nText "?" for help on how to play`
  }
  twiml.message(responseText);
  response.setBody(twiml.toString());
  return callback(null, response);
}

Wow that was a lot! The complete handler function is below.

exports.handler = async function(context, event, callback) {
  let twiml = new Twilio.twiml.MessagingResponse();
  let responseText = '';
  let guess = event.Body.toLowerCase().trim();
  let response = new Twilio.Response();
  let player;
  if (guess == "?") {
    twiml.message(`Wordle was made by Josh Wardle, a Brooklyn-based software engineer, for his partner who loves word games. You guess a 5-letter word and the responding tiles reflect how close your guess was to the goal word. 🟩 means a letter was in the right spot, 🟨 means the letter was correct but in the wrong spot, and ⬛️ means the letter is not in the goal word.`)
    return callback(null, twiml); //no need for cookies
  }
  
  if (!event.request.cookies.player) { //any guesses attempted? -> new player
    let randWord = randomWord(); //new random word
    player = { //init new player
      randWord: randWord, 
      guessesAttempted: 0,
      numCorrectLetters: 0,
      dupLetters: [...randWord],
      incorrectLettersArr: []
    }
  } else { //else pull data off cookie to get player state
    player = JSON.parse(event.request.cookies.player);
  }
  
  if (guess.length == wordLength) { //5 letters
    let scoreCard = await handleGuess(player, guess); //guessesAttempted increments
    console.log(`scoreCard ${scoreCard}`);
    if(endFunc(player, scoreCard)) { //over, win
      if(guess == player.randWord) {
        responseText += `Nice🔥! You guessed the right word in ${player.guessesAttempted}/${maxGuesses} guesses. You can play again by sending a 5-letter word to guess a new random word 👀 \nThanks for playing our SMS Twordle game. Do head to https://www.powerlanguage.co.uk/wordle for web-based word fun! Original Wordle creator Josh Wardle: as fellow builders we salute you and thank you for inspiring us to create our SMS experiment`
        response.removeCookie("player");
      }
      else if (guess != player.randWord) { //over, lose
        responseText += `Game over 🙈\nThe correct word was ${player.randWord}. Send a 5-letter guess to play again! \nThanks for playing our SMS Twordle game. Do head to https://www.powerlanguage.co.uk/wordle for web-based word fun! Original Wordle creator Josh Wardle: as fellow builders we salute you and thank you for inspiring us to create our SMS experiment`;
        response.removeCookie("player");
      }
    }
    else { //keep guessing, not over
      responseText += `${scoreCard.toString()} \n${player.guessesAttempted}/${maxGuesses} guesses`;
      console.log(`randWord in not over ${player.randWord}`);
      response.setCookie("player", JSON.stringify(player), [
        'Max-Age=14400' //4 hour time-limit
      ]);
    }
  }
  else { //not 5 letters
    responseText += `"${guess}" is not valid. Please send a word in the dictionary that is 5 letters to get started!`;
    // twiml.message(`"${guess}" is not valid. Please send a word in the dictionary that is 5 letters to get started!`);
    console.log(`randWord ${player.randWord} in invalid `);
  }
  response.appendHeader('Content-Type', 'text/xml');
  // see if player.guessesAttempted == 1
  if (player.guessesAttempted == 1) {
    responseText += `\nText "?" for help on how to play`
  }
    // Add something to responseText that says: "Text 'HELP' for help" or whatever
  twiml.message(responseText);
  response.setBody(twiml.toString());
  return callback(null, response);
};

You can view the complete code on GitHub here.

Configure the Twilio Function with a Twilio Phone Number

To open up the app to the web with a public-facing URL, go back to the twordle root directory (cd ..) and run twilio serverless:deploy. Grab the link ending in /game from the output in the console. In the phone numbers section of your Twilio Console, select your purchased Twilio phone number and scroll down to the Messaging section. Under A MESSAGE COMES IN, change Webhook to Function and then under Service select Twordle, for Environment select dev-environment, and then for Function Path select /game.

Click the Save button below and tada🎉! You can now text your Twilio number a 5-letter word to play Twordle!

SMS conversation with the Twordle game phone number.

What's Next for Twilio Serverless, Assets, and Word Games?

Twilio's Serverless Toolkit makes it possible to deploy web apps quickly, and Twilio Runtime seamlessly handles servers for you. Dictionary API is—and always will be—free. Their mission is to provide users with an API that they can use to build a game, learning application, or next-generation speech and text technology. Your donation directly helps the development of the Dictionary API and keeps the server running. Let me know online what you're building with Serverless and what your current Wordle streak is! Mine is

Wordle 209 4/6

⬛⬛🟨⬛🟨
🟨🟩🟨⬛⬛
⬛🟩🟩🟨🟩
🟩🟩🟩🟩🟩