Number Genie

Our goal in this sample is to develop a conversational action random number game using API.AI.

Requirements

The random number game has the following requirements:

  1. The user should be able to start the game by using a voice command on Assistant.
  2. The action will use the invocation name Number Genie.
  3. The user has to guess a number to win the game.
  4. The number is in the range 1 to 100 and is selected randomly by the action.
  5. The user can try an unlimited number of times to guess the number.
  6. For each guess from the user, the action will guide the user if the number is smaller or larger than the guess.

Design

Using the game requirements and following the VUI design principles, we document various typical conversations for the game.

Based on each of these user flows, note the following aspects of the design:

  • The user triggers the game by using the trigger phrase talk to: talk to number genie. This is one of the supported trigger phrases for invoking a conversational action.
  • The Assistant introduces the action with Sure, Here’s Number Genie. and then plays an earcon tone to indicate the transition to the conversational action.
  • The conversational action voice isn't the same as the voice for the Google Assistant. The conversational action voice is configurable and you can select one from a list of appropriate personas.
  • It's important for the action to introduce itself by confirming its name with a welcome introduction: Welcome to Number Genie! (On paper, this might look like duplicating the confirmation of the action name to the user, but by listening to the actual voice conversation, it will be clearer why it's important for the user to know that the conversation has moved to the action persona.)
  • Immediately after the welcome message, the user is given instructions on the game and what the next step is.
  • The game ends when the user has guessed the correct number and doesn’t want to play another round.

Conversation Action Implementation

Create a project in API.AI and name it "NumberGenie". Enable fulfillment for the project.

Create two intents:

  • start_game with user phrases number genie and number genie game. Configure a generate_answer action and enable fulfillment.
  • provide_guess with user phrases 25, is it 5, and how about 1. Configure check_guess action and enable fulfillment: API.AI should have automatically added a parameter that you can name to check_guess and make required. Configure the following prompts for the guess parameter:
    • Sorry, say that again?
    • Sorry, that number again?
    • What was that number?
    • Sorry what was that?
    • I didn't hear a number. What's your guess?
    • Say that again.

Create a webhook

Follow the instructions to enable a webhook to implement the logic behind the game.

Create a file called package.json and paste the following code into the file:

{
  "name": "number-genie",
  "description": "Google Actions Number Genie sample for Node.js",
  "version": "0.0.1",
  "private": true,
  "license": "Apache-2.0",
  "author": "Google Inc.",
  "scripts": {
    "start": "node app.js",
    "monitor": "nodemon app.js",
    "deploy": "gcloud app deploy"
  },
  "dependencies": {
    "body-parser": "^1.15.2",
    "express": "^4.13.4",
    "sprintf-js": "^1.0.3",
    "actions-on-google": "^1.0.0"
  }
}

In the Node.js logic, parse the incoming JSON payload from the API.AI request and then generate a JSON payload for the response. Note the package.json file is using our actions-on-google Node.js module that wraps all interactions with API.AI with a simplified client API.

For our action, we need a web server to respond to the API.AI incoming HTTP POST webhook request. We will use the Node.js "express" module for creating the web server and the "body-parser" module to parse the HTTP body JSON payload:

'use strict';

// Enable actions client library debugging
process.env.DEBUG = 'actions-on-google:*';

let Assistant = require('actions-on-google');
let express = require('express');
let bodyParser = require('body-parser');

let app = express();
app.set('port', (process.env.PORT || 8080));
app.use(bodyParser.json({type: 'application/json'}));

const GENERATE_ANSWER_ACTION = 'generate_answer';
const CHECK_GUESS_ACTION = 'check_guess';

app.post('/', function (request, response) {
  console.log('headers: ' + JSON.stringify(request.headers));
  console.log('body: ' + JSON.stringify(request.body));

  const assistant = new Assistant({request: request, response: response});
  response.sendStatus(200); // OK
});

// Start the server
var server = app.listen(app.get('port'), function () {
  console.log('App listening on port %s', server.address().port);
  console.log('Press Ctrl+C to quit.');
});

Lets test this code locally by using node, but first install the necessary Node.js dependencies by using npm:

npm install

Now, use node to confirm that your code is syntactically correct and to run the code locally:

node app.js

If everything is working, the you should see the following message printed on the console: App listening on port 8080

Follow the steps to deploy the webhook using Google App Engine. After you get the hosting URL from the gcloud CLI, you must configure your webhook in API.AI by clicking Fulfillment in the left sidebar. Remember to click Save.

Handling Intents

Now, lets implemement the support for the API.AI intents created earlier. There are two incoming intents we need to handle: GENERATE_ANSWER_ACTION and CHECK_GUESS_ACTION.

For each of these actions, write a corresponding function, as shown below.

function getRandomNumber(min, max) {
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

function generateAnswer(assistant) {
    console.log('generateAnswer');
    var answer = getRandomNumber(0, 100);
    assistant.data.answer = answer;
    assistant.ask('I\'m thinking of a number from 0 and 100. What\'s your first guess?');
  }

 function checkGuess(assistant) {
      console.log('checkGuess');
      let answer = assistant.data.answer;
      let guess = parseInt(assistant.getArgument('guess'));
      if (answer > guess) {
       assistant.ask('It\'s higher than ' + guess + '. What\'s your next guess?');
      } else if (answer < guess) {
       assistant.ask('It\'s lower than ' + guess + '. Next guess?');
      } else {
        assistant.tell('Congratulations, that\'s it! I was thinking of ' + answer);
      }
  }

  let actionMap = new Map();
  actionMap.set(GENERATE_ANSWER_ACTION, generateAnswer);
  actionMap.set(CHECK_GUESS_ACTION, checkGuess);

  assistant.handleRequest(actionMap);
  

Each of these functions return a function, but could also return a Promise which provides a well defined way in ECMAScript 6 to call any code asynchronously and to handle errors in a standard way.

Note how the function uses the assistant.data to store any JSON data for each action sesssion. For each subsequent request from API.AI to this action, the assistant.data values will be accessible.

The assistant.ask method is used to provide a voice response and expect a corresponding response from the user.

Note how the checkGuess function retrieves the assistant.data value that was stored in the first function call. The value of the "guess" argument for the incoming "check_guess" intent is obtained using the assistant.getArgument method.

To provide a voice response that doesn't expect a user response, the assistant.tell method is used.

We need to tell the client library which functions are mapped to which intent names. We use a JavaScript Map data structure. Note the use of assistant.handleRequest, to let the assistant use the declared intent mappings to handle the incoming POST request and create a corresponding response in the format expected by API.AI. We no longer need the response.sendStatus line from the previous code snippet, since the actions module will handle that.

Test Your Action

In the API.AI web GUI, use the embedded simulator on the right side of the page to test your action. Use one of the start_game intent trigger phrases:

You Talk to Number Genie

The simulator will display the response from your action logic:

Number Genie Let's play Number Genie! I'm thinking of a number from 0 to 100. What's your first guess?

Use the gcloud CLI to take a look at the logs:

gcloud app logs read

Find the log entry that starts with Request from Assistant:

Request from Assistant: {...}

Note in the log that the incoming request intentName value is start_game as expected.

Now enter a guess in the API.AI simulator:

You 50
Number Genie It's higher than 50. What's your next guess?

Use the gcloud CLI to find this request:

Request from Assistant: {...}

Note in the log that the incoming request intentName value is provide_guess and parameters has the answer value.

Continue playing the game until you guess the number:

Number Genie Congratulations, that's it! I was thinking of 54

Now that we know the incoming requests for the action fulfillment by API.AI, we can use the curl command to test our action locally by copying the incoming JSON requests from the log.

Use curl to emulate the GENERATE_ANSWER_ACTION action:

curl -X POST -H "Content-Type: application/json" -d '{...}' http://localhost:8080

Use curl to emulate the CHECK_GUESS_ACTION action:

curl -X POST -H "Content-Type: application/json" -d '{...}' http://localhost:8080

Improved VUI Design

We want to make our game more fun, so we add new requirements:

  • Let the user play the game again.
  • Give custom message when user is within 10 of the number.
  • Give custom message when the user is within 3 of the number.
  • Give custom message when user goes against hint.
  • Give custom message if user selects min or max range values.
  • Give custom message when user is far away from number (> 75).
  • Give custom message when user is 2 away, then 1 away in same direction.
  • Give the user option to quit if the interaction is outside game typical response (e.g. "what time is it?").
  • Handle twice unrecognized responses as a way to quit the game.
  • Provide multiple possible no-match responses.
  • Handle multiple guesses that have the same value.
  • The user can exit the game at any time.

By following the VUI design principles, we do a design walkthrough for the game to satisfy these requirements. Now, let's implement the VUI design in the following sections.

Support Exiting the Game at Any Time

We need a new intent that will catch user input that handles the case when the user wants to quit the game. To make this work at any time during the game, we need an intent that is aware of the game context.

Using the API.AI GUI, add a new intent called quit_game with the following user phrases: i quit, i give up, stop, quit. Change the intent context to game. Set the Action value to quit and enable the webhook for this intent. Save the new intent.

We now need to update our action logic to support the new intent. Add the following code to your existing random number action Node.js logic:

const QUIT_ACTION = 'quit';

function quit(assistant) {
      console.log('quit');
      let answer = assistant.data.answer;
      assistant.tell('Ok, I was thinking of ' + answer + '. See you later.');
  }

  ...
  actionMap.set(QUIT_ACTION, quit);

Use the API.AI simulator to say quit at various stages of the conversation.

Let the User Play the Game Again

At the end of the game when the user has guessed the number, we will prompt the user to play again.

We need to add new intents to handle the user's response. Since the user can say yes or no, we need an intent for each. We also need to support typical variants on those answers like yep, sure for yes and nope, not really. But we need to design our agent to only support yes/no when the conversation asks for that input at the end of the game.

We are going to use a new context named yes_no when the conversation invokes the provide_guess intent and the user has guessed the right number. Since only the logic within the fulfillment knows when the number is guessed, that logic will dynamically change the context to yes_no to ensure that API.AI only supports the yes/no input for the next expected user input.

Create a new intent called play-again-yes:

  • Add user phrases yes, yep, and sure.
  • Name the action play_again_yes and enable the webhook.
  • Set both the input and output contexts to game.
  • Set another input context to yes_no.
  • Save this new intent.

Create a new intent called play-again-no:

  • Add user phrases no, nope and not really.
  • Name the action play_again_no and enabled the webhook.
  • Set both the input and output contexts to game.
  • Set another input context to yes_no.
  • Save this new intent.

Change the logic to ask the user to respond:

function checkGuess(assistant) {
      console.log('checkGuess');
      let answer = assistant.data.answer;
      let guess = parseInt(assistant.getArgument('guess'));
      if (answer > guess) {
        assistant.ask('It\'s higher than ' + guess + '. What\'s your next guess?');
      } else if (answer < guess) {
        assistant.ask('It\'s lower than ' + guess + '. Next guess?');
      } else {
        assistant.setContext('yes_no');
       assistant.ask('Congratulations, that\'s it! I was thinking of ' + answer + '. Wanna play again?');
      }
  }
  

Note the use of the assistant.setContext method to set the new context to yes_no with a default query lifespan of 1 (meaning that the context is automatically removed after the next query and yes/no user inputs would no longer trigger the play_again_yes or play_again_no intents).

Now lets add the new intent names to the fulfillment code:

const PLAY_AGAIN_YES_ACTION = 'play_again_yes';
const PLAY_AGAIN_NO_ACTION = 'play_again_no';

We need to add the following action handler logic for PLAY_AGAIN_YES_ACTION:

function playAgainYes(assistant) {
      console.log('playAgainYes');
      var answer = getRandomNumber(0, 100);
      assistant.data.answer = answer;
      assistant.ask('Great! I\'m thinking of a number from 0 and 100! What\'s your guess?');
    });
  }

This logic is very similar to that of the GENERATE_ANSWER_ACTION handler.

Add another action handler logic for PLAY_AGAIN_NO_ACTION:

function playAgainNo(assistant) {
    console.log('playAgainNo');
    assistant.tell('Alright, talk to you later then.');
  }

This is similar to the QUIT_ACTION logic but uses a different bye phrase.

Finally, tell the actions library how to make the actions to the handlers:

  ...
  actionMap.set(PLAY_AGAIN_YES_ACTION, playAgainYes);
  actionMap.set(PLAY_AGAIN_NO_ACTION, playAgainNo);

Use the API.AI simulator to try both the "yes" and the "no" paths of the conversation.

Track Previous Query Values

Several of the VUI requirements are about tracking previously entered values by the user. The actions library provides support for storing values in a session by using assistant.data. We have already used assistant.data for storing the number to guess.

We are going to modify the action logic to also store the previous user guess and hint given to the user:

assistant.data.hint = 'higher';
assistant.data.previousGuess = guess;

We are going to track the hint given to users as either "higher" or "lower".

Change the CHECK_GUESS_ACTION handler logic to check if the user is going against the hints:

function checkGuess(assistant) {
      console.log('checkGuess');
      let answer = assistant.data.answer;
      let guess = parseInt(assistant.getArgument('guess'));
      if (assistant.data.hint) {
        if (assistant.data.hint === 'higher' && guess <= assistant.data.previousGuess) {
         assistant.ask('Nice try, but it’s still higher than ' + assistant.data.previousGuess);
          return;
        } else if (assistant.data.hint === 'lower' && guess >= assistant.data.previousGuess) {
         assistant.ask('Nice try, but it’s still lower than ' + assistant.data.previousGuess);
          return;
        }
      }
      if (answer > guess) {
        assistant.data.hint = 'higher';
        assistant.data.previousGuess = guess;
       assistant.ask('It\'s higher than ' + guess + '. What\'s your next guess?');
      } else if (answer < guess) {
        assistant.data.hint = 'lower';
        assistant.data.previousGuess = guess;
       assistant.ask('It\'s lower than ' + guess + '. Next guess?');
      } else {
        assistant.data.hint = 'none';
        assistant.data.previousGuess = -1;
        assistant.setContext('yes_no');
       assistant.ask('Congratulations, that\'s it! I was thinking of ' + answer + '. Wanna play again?');
      }
  }

Similar logic can be used to handle repeated user inputs and improving hints by providing custom messages when the user gets closer or further away from the number. You can also provide custom messages when the user gets closer to the number.

Handle Unrecognized Input

The user might provide a response that is out of the context of the game. We need to prompt the user if they want to keep playing the game.

In the API.AI web GUI, select the Default Fallback Intent. This intent is invoked if none of the other intents or domains are matched. The intent provides various default prompts.

We will not be using these prompts anymore. Enable the webhook for this intent so that our logic will generate the prompt. Remember to save the changes to the intent.

We now need to update the fulfillment logic to handle the default fallback action input.unknown:

const DEFAULT_FALLBACK_ACTION = 'input.unknown';

assistant.data.fallbackCount = 0;

function defaultFallback(assistant) {
      console.log('defaultFallback');
      assistant.data.fallbackCount++;
      if (assistant.data.fallbackCount == 1) {
        assistant.setContext('done_yes_no');
        assistant.ask('Are you done playing Number Genie?');
      } else {
        assistant.tell('We can stop here. Let’s play again soon.');
      }
    });
  }

actionMap.set(DEFAULT_FALLBACK_ACTION, defaultFallback);

Now you have to create two new intents to handle the user's response to the first fallback question. This is similar to the intents for asking the user to play the game again after the number is guessed, but uses a different context, done_yes_no.

Provide Alternative Responses

Instead of repeating the same phrases in the responses, to make the conversation appear more natural, we will randomly select from various alternate responses.

We will declare all the responses in arrays of strings and then use a utility method to randomly select from the available options.

We are going to use the Node.js sprintf-js module to dynamically create string based on dynamic values in the game.

let sprintf = require("sprintf-js").sprintf;

function getRandomNumber(min, max) {
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

const GREETING_PROMPTS = ["Let's play Number Genie.", "Welcome to Number Genie!"];
const INVOCATION_PROMPT = ["I\'m thinking of a number from %s and %s. What's your first guess?"];

function generateAnswer(assistant) {
      console.log('generateAnswer');
      var answer = getRandomNumber(MIN, MAX);
      assistant.data.answer = answer;
      assistant.data.guessCount = 0;
      assistant.ask(sprintf(sprintf(getRandomPrompt(GREETING_PROMPTS), guess) + ' ' + getRandomPrompt(INVOCATION_PROMPT), MIN, MAX));
  }

For the complete list of alternate strings and the modified prompting logic, see the final game source code.

Handling No Inputs

The assistant.ask method has a second parameter that allows the action to provide prompts when the user says nothing or if the user response isn’t heard. We highly recommend that developers specify no-input prompts for each of the assistant.ask requests to ensure a good user experience.

assistant.ask('It\'s higher than ' + guess + '. What\'s your next guess?', 'I didn\'t hear a number', 'If you\'re still there, what\'s your guess?', 'We can stop here. Let\'s play again soon.');

The Number Genie game does not support deep linking. However, if the user were to say "talk to number genie about frogs," then we can handle that elegantly in our fulfillment logic.

To create a new fallback intent in API.AI, click on the three dots next to Create Intent. Call this new intent Unknown-deeplink. Set the output context to game and the Action to deeplink.unknown. Enable the fulfillment webhook and save the intent.

Change the Default Fallback Intent to include the game context for both its input and output and save that intent.

This means that if the user says "talk to number genie about frog," the Unknown-deeplink intent is invoked, but if the user says something unrecognizable during the game, the Default Fallback Intent is invoked.

Now we need to add some logic to our fulfillment code to handle the new intent:

const UNKNOWN_DEEPLINK_ACTION = 'deeplink.unknown';
const RAW_TEXT_ARGUMENT = 'raw_text';

function unhandledDeeplinks (assistant) { console.log('unhandledDeeplinks'); let answer = getRandomNumber(MIN, MAX); assistant.data.answer = answer; assistant.data.guessCount = 0; assistant.data.fallbackCount = 0; let text = assistant.getArgument(RAW_TEXT_ARGUMENT); if (text) { let numberOfLetters = text.length; if (numberOfLetters < answer) { assistant.ask(sprintf('%s has %s letters. It\'s higher than %s.', text, numberOfLetters, numberOfLetters)); } else if (numberOfLetters > answer) { assistant.ask(sprintf('%s has %s letters. It\'s lower than %s.', text, numberOfLetters, numberOfLetters)); } else { assistant.data.hint = NO_HINT; assistant.data.previousGuess = -1; assistant.setContext(YES_NO_CONTEXT); assistant.ask(sprintf('%s has %s letters. Wow! The number I was thinking of was %s!', text, numberOfLetters, answer) + ' ' + sprintf('Wanna play again?')); } } else { defaultFallback(assistant); } }

Instead of ignoring the user input, we will count the number of letters in the text provided by the user and start the game by using that as the first guessed number. For example, if the user said "talk to number genie about frog," the action would respond with "Frog has 4 letters. It’s lower than 4."

Note the use of the raw_text argument value to get the text the user said.