Build a Whatsapp Game Bot with Laravel and Twilio

There's a word game I find very addictive. I play it often, usually in transit and stuck in one terrible traffic from Opebi to Ogba.

Word Cross Game On Mobile

The game is pretty straight forward. You're given a couple of letters and some empty word blocks. You are expected to construct some words with the given letters. A correctly formed word fills the empty block. If you fill all the blocks correctly, you get points and move to a new level.

We could replicate this game and its functionality with a WhatsApp bot.

Prerequisites:

  1. Twilio Account
  2. Composer
  3. Laravel Installation
  4. Good Knowledge of PHP
  5. Good Knowledge of Laravel Framework
  6. Ngrok

Twilio Account:

Twilio is a service that allows us to programmatically add communication services through its suite of APIs. We are going to use Twilio to make API calls between WhatsApp and our application, because getting direct access to WhatsApp's API involves a rigid approval process.

Visit Twilio on your browser by going to https://twilio.com (PS: Referral Link, I get sms credits if you sign up with my link)

Twilio's Landing Page
Register with you details
Check your inbox for the verification mail

After verifying your email, you need to also verify your phone number before you'll be granted access to the sandbox.

Verify your phone number as well

You'll be taken to a setup page were certain questions will be asked to customize your experience. You can answer as you please.

Our goal today is to use Twilio in a project
We want to Send Whatsapp Messages first

If you selected the "Send WhatsApp Messages" option on the last setup page. You'll be taken to the messaging dashboard.

Twilio's messaging dashboard

Click on the "Sandbox" menu option on the sidebar to go to our sandbox

Twilio WhatsApp Sandbox

Congratulations, you've just set up your Twilio WhatsApp sandbox. We shall come back here to configure our call back url.

Laravel Installation:

Next, we create our Laravel project. I'm assuming you already have composer installed.

From your terminal, type:

composer create-project --prefer-dist laravel/laravel word-game


This will scaffold a new Laravel application for us into a folder called word-game.

We need to also install Twilio's sdk which we will use to return our responses. (We''ll see this in use later)

composer require twilio/sdk

Okay great. Now we're set up. Its time to jump into some coding action.

Let's define our callback url. Twilio will send our WhatsApp messages to this endpoint as a post request, then we can look at the message and determine what to send back to the user.

In our routes/api.php file, add this line:

Route::post('/word-game', 'WordGameController');

Here we are telling Laravel to invoke WordGameController any time the endpoint is matched. We need to create that controller, since it doesn't exist yet.
You can spin up a controller by typing in the terminal:

php artisan make:controller WordGameController --invokable

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Twilio\TwiML\MessagingResponse;

class WordGameController extends Controller
{
    public function __invoke(MessagingResponse $messageResponse)
    {
        
    }
}
Invokable Controller

Our Controller has only a single action, which is why we are using an invokable controller. Laravel will automatically call the invoke method. You can read more on invokable controllers in the Laravel documentation

Twilio provides us with a MessagingResponse class to help us handle our messages. It converts our message to Twilio's TwiML response format. We shall come back to this controller later.

Schemas:

On your terminal, type:

php artisan make:migration create_gamers_table && php artisan make:migration create_games_table

This will scaffold two migrations for us in database/migrations folder

Schema::create('gamers', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('name')->nullable();
            $table->string('phone_number')->unique();
            $table->string('points')->default(0);
            $table->string('level')->default(0);
            $table->timestamps();
        });
Gamer Schema Up Method
Schema::create('games', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->unsignedBigInteger('gamer_id');
            $table->longText('question');
            $table->longText('answer');
            $table->integer('points')->default(0);
            $table->integer('attempts')->default(0);

            $table->foreign('gamer_id')
                    ->references('id')
                    ->on('gamers')
                    ->onDelete('cascade');
            $table->timestamps();
        });
Game Schema Up Method

Our Gamer will contain a name, a phone number, points (To track total game points) and game level.

Our Game will hold the question, its answer, points, and gamer_id because each game will belong to a gamer.


Models:


We will scaffold the models for our migrations by running the command:

php artisan make:model "Models/Gamer" && php artisan make:model "Models/Game"

This will create our models in an App\Models directory. PS: I prefer having my models in that directory structure but it's entirely optional and a personal preference.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Game extends Model
{
    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'gamer_id', 'question', 'answer',
        'points', 'attempts'
    ];

    /**
     * Gamer
     */
    public function gamer()
    {
        return $this->belongsTo(Gamer::class);
    }
}
Game Model
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Gamer extends Model
{
    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'name', 'phone_number',
        'points', 'level'
    ];

    /**
     * Gamer's Game
     */
    public function game()
    {
        return $this->hasOne(Game::class);
    }
}
Gamer Model

Actors and Workflow:

Our game bot has a guided workflow. There's no fancy AI going on. No NLP to detect intents and actions. We are basically giving instructions to our gamers through keywords and expecting specified answers.

Actors:

We will make use of a handler class, which we'll call an Actor, to interact with any incoming message from a gamer. The Actor class will simply do two things. Take the incoming message, check if it should respond, and return a response to the gamer.

Actor Contract:

From our workflow, we know what an actor is suppose to do. We will create an interface to enforce this contract.

<?php

namespace App\Contracts;

interface Actor
{
    /**
     * Talk to gamer by returning a response
     * @param string|null
     * @return string
     */
    public function talk($data = null): string;

    /**
     * Check whether to respond to a gamer's message
     * @param App\Models\Gamer $gamer
     * @param string $message
     * 
     * @return bool
     */
    public static function shouldTalk(Gamer $gamer, string $message): bool;
}
App/Contracts/ActorContract.php

The static shouldTalk method will take in the gamer and the message sent as parameters, then return a boolean if the actor should talk or not.

The talk method will take an optional data parameter, perform some logic and return a response to the gamer.

Let's define a base class that will implement this contract, because why not? The father bears the burden of the children.

<?php

namespace App\Actors;

use App\Contracts\Actor as ActorContract;

abstract class Actor implements ActorContract
{
    /**
     * @var Gamer
     */
    protected $gamer;

    /**
     * @var string
     */
    protected $message;

    public function __construct($gamer, $message)
    {
        $this->gamer = $gamer;
        $this->message = $message;
    }

    /**
     * Call an Actor from within an Actor
     * @param string $actor
     * @param mixed $data
     * @return string $convo
     */
    protected function call($actor, $data = null)
    {
        $actor = new $actor($this->gamer, $this->message);
        return $actor->talk($data);
    }
}
App\Actors\Actor.php

Our call method will allow us to pass an actor from within an actor, as we will see later.

Keywords:


Lets create the keywords our game will interact with. I shall put this in a constants class.

<?php

namespace App\Constants;

class Keywords
{
    const START = "play";
}
App\Constants\KeyWords.php


I'm keeping the keywords restricted to four letters. Just to make it concise.

The play keyword will begin the game. We will define actors that will respond to this keyword.

Conversations:


I'll add another constants class to hold conversation values.

<?php

namespace App\Constants;

class Conversations
{
    const SALUTE = "Hey there! extra_greet_key Let's play";

    const EXTRA_GEETING = "Welcome. So you think you're smart right? How good is your vocabulary?";
}
App\Constants\Conversations.php

Notice our SALUTE constant has a place holder (extra_greet _key). This placeholder will be replaced within our actor when responding to a gamer

Actor Factory:

The Actor Factory is responsible for generating the actor suitable for an incoming message.

<?php

namespace App\Factories;

use App\Models\Gamer;
use Illuminate\Support\Facades\Request;

class ActorFactory
{

    /**
     * Actor
     * @var App\Contracts\Actor;
     */
    protected $actor;

    /**
     * Gamer
     * @var App\Models\Gamer
     */
    protected $gamer;

    /**
     * Construct
     */
    public function __construct($phoneNumber = null, $message = null)
    {
        $this->resolveGamer($phoneNumber);
        $this->actor = $this->resolveActor($message);
    }

    /**
     * Make Actor
     */
    public static function make()
    {
        $self = new static(
            Request::get("From"), 
            Request::get("Body"));

        return $self->actor;
    }

    /**
     * Resolve Gamer
     * @param $gamer
     * @return void
     */
    protected function resolveGamer($phoneNumber)
    {
        $this->gamer = Gamer::firstOrCreate([
            'phone_number' => $phoneNumber
        ]);
    }

    /**
     * Resolve Actor
     * @param $message
     * 
     * @return App\Contracts\Actor
     */
    protected function resolveActor($message)
    {
        $message = $this->normalizeMessage($message);

        $actors = $this->getActors();

        foreach ($actors as $actor) {

            if ($actor::shouldTalk($this->gamer, $message)) 
                return new $actor($this->gamer, $message);
        }
        return new \App\Actors\SaluteActor($this->gamer, $message);
    }

    /**
     * Trim and lower case the message
     */
    protected function normalizeMessage($message)
    {
        return trim(strtolower($message));
    }

    /**
     * Get available actors
     * @return array
     */
    protected function getActors()
    {
        return [
            App\Actors\PlayKeywordActor::class,
            App\Actors\GameActor::class,
            App\Actors\SaluteActor::class,
        ];
    }
}
App\Factories\ActorFactory.php

This code is pretty explanatory, first we get the phone number and message from the request body in the static make method. Next we pass these values to the resolveGamer method, which just retrieves or persist the gamer from our data store. We then go ahead to pass the message to the resolveActor Method. This method gets all the actors (We'll create them soon), loops through them and calls the shouldTalk method already defined in the Actor contract. If the return value is true, the Actor Class is instantiated with the phone number and message as parameters.

SaluteActor:

Lets start with our first Actor. This Actor will greet the gamer on first interaction. Our Salute Actor Class will look like this:

<?php

namespace App\Actors;

use App\Actors\Actor;
use App\Constants\Keywords;
use App\Constants\Conversations;

class SaluteActor extends Actor
{
    /**
     * should talk
     */
    public static function shouldTalk($gamer, $message)
    {
        return $gamer->game == null;
    }

     /**
     * Converse
     * @return string
     */
    public function talk()
    {
        $conversation = Conversations::SALUTE;

        foreach ($this->buildConvo() as $key => $value) {
            $conversation = str_replace($key, $value, $conversation);
        }
        return $conversation;
    }

    /**
     * Build Conversation
     */
    protected function buildConvo()
    {
        $extra_greet_key = $this->addExtraGreeting();

        return compact('extra_greet_key');
    }

    /**
     * Add Extra Greeting
     */
    protected function addExtraGreeting()
    {
        $extraGreeting = "";
        if ($this->gamer->level < 1) {
            $extraGreeting .= Conversations::EXTRA_GEETING;
        } elseif ($this->gamer->level > 1 && $this->gamer->points < 7) {
            $extraGreeting .= "Nice to see you again. 
            You didn't do well the last time. You are in the bottom ".rand(10, 100)." players 
            with just {$this->gamer->points} points. Try beating your last score";
        } else {
            $extraGreeting .= "Nice to see you again. 
            You are smashing it. You're in the top ".rand(10, 100)." players with {$this->gamer->points} points.
            Try beating your last score";
        }
        return $extraGreeting;
    }
}
App\Actors\SaluteActor.php

In our shouldTalk method, we only want to greet the gamer, if there's no active game, while in our talk method, we are getting the placeholder values from our constants and replacing them with dynamic values.

PlayKeyword Actor:

This actor will act on the play keyword, if there is no current game.

<?php

namespace App\Actors;

use App\Actors\Actor;
use App\Traits\ActorTrait;
use App\Constants\Keywords;
use App\Factories\QuestionFactory;

class PlayKeywordActor extends Actor
{
    use ActorTrait;
    
    /**
     * should talk
     */
    public static function shouldTalk($gamer, $message)
    {
        return $gamer->game == null && 
        Keywords::START == $message;
    }

     /**
     * Converse
     * @return string
     */
    public function talk()
    {
        $question = QuestionFactory::make()->generate();
        
        $puzzle = join("\n", $question["puzzle"]);

        $this->gamer->game()->updateOrCreate(['gamer_id' => 
            $this->gamer->id],[
            "question" => $question]);

        return $this->printPuzzle($puzzle, $question["shuffled"]);
    }
}
App\Actors\PlayKeywordActor.php

In our talk method, we generate our questions from a question factory, store the question in the db and return the question to the gamer.

Game Actor:

The Game actor is responsible for handling the gamers responses to question.

<?php

namespace App\Actors;

use App\Actors\Actor;
use App\Traits\ActorTrait;
use App\Helpers\PuzzleResolver;

class GameActor extends Actor
{
    use ActorTrait;

    const ANSWERED_PREVIOUSLY = 100;
    const ANSWERED_CORRECTLY = 200;
    const ANSWERED_WRONGLY = 300;
    const POINTS = 3;

    protected $successMessages = [
        "You’re the most brilliant person I know. Well done.",
        "Good one mate. Bravo",
        "You're becoming a legend.",
        "You did it again. Amazing!",
        "You're making me cry. OMG!!!",
        "I'm impressed. You did it",
        "You're the bomb. You're doing great",
        "Way to go. You're a genius"
    ];

    /**
     * should talk
     */
    public static function shouldTalk($gamer, $message)
    {
        return (bool)$gamer->game;
    }

     /**
     * Converse
     * @return string
     */
    public function talk()
    {
        $answerScenario = $this->checkAnswer();

        switch ($answerScenario) {
            case self::ANSWERED_PREVIOUSLY:
                return $this->answeredPreviouslyActivity();
            case self::ANSWERED_WRONGLY:
                return $this->wrongAnswerActivity();
            case self::ANSWERED_CORRECTLY:
                return $this->correctAnswerActivity();
            default:
                return "I don't know what to do";
        }
    }

    /**
     * Check Answer
     */
    protected function checkAnswer()
    {
        if (in_array($this->message, $this->gamer->game->answer ?? [])) {
            return self::ANSWERED_PREVIOUSLY;
        }
        if (in_array($this->message, 
        $this->gamer->game->question["missings"])) {
            return self::ANSWERED_CORRECTLY;
        }
        return self::ANSWERED_WRONGLY;
    }

    /**
     * Perfom if gamer answers correctly
     */
    protected function correctAnswerActivity()
    {
        $this->givePoints();
        
        return $this->successMessages
        [rand(0, count($this->successMessages) - 1)]
                . " You're on {$this->gamer->game->points} points" 
                . $this->dropQuestion();
    }

    /**
     * Perform if gamer has answers previously
     */
    protected function answeredPreviouslyActivity()
    {
        return $this->repeatQuestion();
    }

    /**
     * Generate next question
     */
    protected function dropQuestion()
    {
        if ($this->shouldMoveLevel()) {
            $this->saveGame();

            $level = $this->gamer->level + 1;
            return "*** Level {$level} ***" . "\n\n" .
            $this->call(PlayKeywordActor::class);
        }

        return $this->repeatQuestion();
    }

    /**
     * Checks if Questions Should move to another level
     * @return bool
     */
    protected function shouldMoveLevel()
    {
        return count($this->gamer->game->answer ?? []) 
        >= count($this->gamer->game->question["missings"]);
    }

    /**
     * Give points to gamer
     */
    protected function givePoints()
    {
        $game = $this->gamer->game;
        $answer = $game->answer ?? [];

        $game->points += self::POINTS;
        $answer[] = $this->message;
        $game->attempts = count($answer);
        $game->answer = $answer;
        $this->gamer->push();
    }

    /**
     * Perfom if gamer answers wrongly
     */
    protected function wrongAnswerActivity()
    {
        return $this->repeatQuestion();
    }

    /**
     * Repeat Question with already submitted answers
     */
    protected function repeatQuestion()
    {
        $question = $this->gamer->game->question;
        $words = $this->gamer->game->answer ?? [];
        $words = array_map("strtoupper", $words);

        $puzzle = $question["puzzle"];

        if (empty($words)) {
            $resolvedPuzzle = $puzzle;
        } else {
            $puzzleResolver = new PuzzleResolver;
            $resolvedPuzzle = $puzzleResolver->solve(
            0, count($words), $words, $puzzle);
        }
        
        $resolvedPuzzle = join("\n", $resolvedPuzzle);

        return "\n\n" . $this->printPuzzle(
            $resolvedPuzzle, $question["shuffled"]
        );
    }
}
App\Actors\GameActor.php

It looks like a lot of things are going on here, but its quite straightforward.

In the shouldTalk method, we check if the gamer has a current game,

In the talk method, we check the correctness or wrongness of the response. If the gamer answered correctly, we will give points to the gamer, and return the remaining questions. If the gamer answered wrongly, we repeat the question to the gamer.

In the dropQuestion method, we can see that we made use of the call method, previously defined in our abstract Actor class to pass the action to the PlayKeyword actor.

WordGameController:

Lets put everything together by revisiting our WordGameController

public function __invoke(MessagingResponse $messageResponse)
{
    $actor = ActorFactory::make();
    $messageResponse->message($actor->talk());

    return response($messageResponse, 200)->header(
        'Content-Type', 'text/xml'
    );
}
App\Http\Controllers\WordGameCOntroller.php

Now we're ready to start playing our game. We'll use ngrok to quickly expose our application. Ngrok is a really cool open source tool that will allow us expose our local environment to the internet. It works by creating a secure tunnel on our local machine along with a public url. This is pretty useful in situations where you need to test a webhook, just like in our case.  If you don't have ngrok installed, you can visit the ngrok download page for instructions on how to set up.

Ngrok on terminal

Run the following command to tunnel our app using ngrok.

ngrok http 8000

Copy the forwarding url and go back to the Twilio WhatsApp sandbox. we shall paste the url in the callback field.

Using the Game Bot:

Add the Twilio sandbox number +1 415 523 8886 to your phone contact list. Open whatsapp and join the sandbox by sending join empty-toward (My sandbox code would be different from yours)

Congratulations! We now have a functional WhatsApp bot.

Feel free to let me know what you think. I'm on twitter @gabrielnwogu

Link to repo: https://github.com/nwogu/word-game

Link to Demo: https://api.whatsapp.com/send?phone=14155238886&text=join empty-toward


*Thanks to Etinosa Obaseki for the edits