In one of our previous articles, we showed how to develop a custom Alexa skill using an example of a Trip Planner app (How To Build A Dialogue Custom Alexa Skill Using JavaScript (Without Lambda). In this article, we will show you a more complex example of the interaction between Alexa and the user. Today, we will build a “Guess My Number” game where Alexa (or technically, our Alexa skill) thinks of a number and the user tries to guess it while the skill suggests whether it is lower or higher.
Here’s a sample dialogue a user may have with Alexa once you implement the skill:
In this article, we will omit some details about the user interface of the Amazon Developer Account and assume that you are already familiar with this from the previous article.
As before, you will need the following to get started:
The first step is to set up the skill in the Amazon Developer Account. To do this, go to the Amazon Developer console and create a new skill from the empty template (see the previous article for details). We called the new Alexa Skill “Guess My Number”, but you are welcome to name it anything you’d like.
Next step is to create a new intent named NumberGuessIntent and an Intent Slot called number with the type Amazon.NUMBER. Make sure to add the Sample Utterances as shown below:
Finally, it is important to add all the Built-in Intents listed in the screenshot above as you will need them later. For now, save the model by clicking the Save Model button.
If you have already installed the SDK for Amazon Alexa, you can skip steps 2 and 3.
'use strict' const SERVICE_NAME = 'AlexaUtils' class AlexaBase { invoke(method, body) { return Backendless.CustomServices.invoke(SERVICE_NAME, method, body) } /** * @param {Object} req */ verifyRequest(req) { const { headers } = this.request const { Signature: signature, SignatureCertChainUrl: signatureCertChainUrl } = headers return this.invoke('verifyRequest', { signatureCertChainUrl, signature, req }) } /** * @param {Object} request * @param {String} intentName * @param {String} slotName */ getSlotValue(request, intentName, slotName) { return this.invoke('getSlotValue', { request, intentName, slotName }) } /** * @param {Object} request */ getIntentName(request) { return this.invoke('getIntentName', request) } /** * @param {String} [whatToSay] * @param {Boolean} [waitForResponse] */ sendAlexaResponse(whatToSay, waitForResponse) { return this.invoke('sendAlexaResponse', { whatToSay, waitForResponse }) } /** * @param {Object} request */ isStarted(request) { return this.invoke('isStarted', request) } /** * @param {Object} request */ isInProgress(request) { return this.invoke('isInProgress', request) } /** * @param {Object} request */ isCompleted(request) { return this.invoke('isCompleted', request) } delegate() { return this.sendAlexaResponse(null, true) } /** * @param {Object} request */ getRequestType(request) { return this.invoke('getRequestType', request) } /** * @param {Object} request * @param {String} speech */ sendProgressiveAlexaResponse(request, speech) { return this.invoke('sendProgressiveAlexaResponse', { request, speech }) } } module.exports = AlexaBase
For the explanation of each method see the previous article.
Now, open the file response-builder.js for editing, and enter the following code:
// response-builder 'use strict' /** * @typedef {Object} OutputSpeech * @property {String} type * @property {String} ssml */ /** * @typedef {Object} Reprompt * @property {OutputSpeech} outputSpeech */ /** * @typedef {Object} Response * @property {Boolean} shouldEndSession * @property {OutputSpeech} [outputSpeech] * @property {Reprompt} [reprompt] */ /** * @typedef {Object} ResponseObject * @property {String} version * @property {Response} response */ class ResponseBuilder { constructor() { this.res = { version : '1.0', response: { shouldEndSession: false } } } /** * Has Alexa say the provided speech to the user * @param {String} speechOutput * @returns {ResponseBuilder} */ speak(speechOutput) { this.res.response.outputSpeech = { type: 'SSML', ssml: '<speak>' + speechOutput + '</speak>', } return this } /** * Has alexa listen for speech from the user. If the user doesn't respond within 8 seconds * then has alexa reprompt with the provided reprompt speech * @param {String} repromptSpeechOutput * @returns {ResponseBuilder} */ reprompt(repromptSpeechOutput) { this.res.response.reprompt = { outputSpeech: { type: 'SSML', ssml: '<speak>' + repromptSpeechOutput + '</speak>', }, } return this } /** * Sets shouldEndSession value to null/false/true * @param {Boolean} shouldEndSession * @return {ResponseBuilder} */ shouldEndSession(shouldEndSession) { this.res.response.shouldEndSession = shouldEndSession return this } /** * Returns the response object * @return {ResponseObject} */ getResponse() { return this.res } } module.exports = ResponseBuilder
Now open the file skill-helper.js for editing, and enter the following code:
// skill-helper.js 'use strict' const ResponseBuilder = require('./response-builder') class SkillHelper { constructor() { this.requestHandlers = [] this.errorHandlers = [] } /** * @private * @param {Object} input * @param {Object} err */ _processError(input, err) { this.requestHandlers.forEach(errorHandler => { if (errorHandler.canHandle(input)) { return errorHandler.handle(input, err) } }) } addRequestHandlers(...requestHandlers) { this.requestHandlers = requestHandlers return this } addErrorHandlers(...errorHandlers) { this.errorHandlers = errorHandlers return this } /** * If table name specified, it will be used as persistent store for session attributes * @param {String} table * @return {SkillHelper} */ withTable(table) { this.table = table return this } /** * @private * @param {Object} req */ _getInput(req) { const input = { req } const { sessionId } = req.session input.responseBuilder = new ResponseBuilder() input.getSessionAttributes = () => Backendless.Cache.get(sessionId) input.setSessionAttributes = attributes => Backendless.Cache.put(sessionId, attributes) input.getPersistentAttributes = async () => { const [attributes] = await Backendless.Data.of(this.table).find() return attributes || {} } input.setPersistentAttributes = async attributes => { const prevAttributes = await input.getSessionAttributes() const newAttributes = Object.assign(prevAttributes, attributes) input.setSessionAttributes(newAttributes) return Backendless.Data.of(this.table).save(Object.assign(prevAttributes, attributes)) } return input } /** * @param {Object} req * @return {ResponseObject} */ async start(req) { const input = this._getInput(req) for (const handler of this.requestHandlers) { if (await handler.canHandle(input)) { try { return handler.handle(input) } catch (err) { this._processError(input, err) } } } } } module.exports = SkillHelper
And finally, open the file guess-my-number.js for editing. In this file you need to declare the guess-my-number service and register it with Backendless:
// guess-my-number.js 'use strict' const AlexaBase = require('./alexa-base') const SkillHelper = require('./skill-helper') const GUESS_MY_NUMBER_INTENT = 'NumberGuessIntent' class GuessMyNumber extends AlexaBase { /** * @route POST / * @param {Object} req * @returns {Promise<ResponseObject>} */ async guessMyNumber(req) { } } Backendless.ServerCode.addService(GuessMyNumber)
In the code above, you have created a Backendless API service method named guessMyNumber. The method will be accepting Alexa requests and respond with answers.
Amazon requires that Alexa requests must be verified – meaning your code must ensure that requests are indeed sent by Alexa. To achieve this you will use the this.verifyRequest(req) method, which is inherited from the AlexaBase class.
To begin implementing the logic for the guessMyNumber method, define requestType, intentName, and skillHelper, as shown below:
/** * @route POST / * @param {Object} req * @returns {Promise<ResponseObject>} */ async guessMyNumber(req) { this.verifyRequest(req) const requestType = await this.getRequestType(req) const intentName = await this.getIntentName(req) const skillHelper = new SkillHelper() }
Next, you need to declare Intent handlers for each of the Intents. Schematically, an intent handler should look like this:
const IntentNameHandler = { /** * Method to check if handler can handle intent request * @param {Object} input * @returns Promise.<Boolean> | Boolean */ canHandle(input) { }, /** * Method to handle request if canHandle return true * @param {Object} input * @returns {ResponseObject} Promise<Response> | Response */ handler(input) { } }
The first handler that you need to declare is the LaunchRequest handler. In this handler, you will set the initial attributes, greet the user, report how many times they have already played, and offer to start the new game. If the user replies “Yes”, the built-in AMAZON.YesIntent will be invoked, and if they reply “No” – the AMAZON.NoIntent will run. Handling of these intents will be done later.
const LaunchRequest = { canHandle(input) { // launch only if session is new or request type is 'LaunchRequest' return input.req.session.new || requestType === 'LaunchRequest' }, async handle(input) { const attributes = await input.getPersistentAttributes() || {} if (Object.keys(attributes).length === 0) { attributes.endedSessionCount = 0 attributes.gamesPlayed = 0 attributes.gameState = 'ENDED' } await input.setSessionAttributes(attributes) const speechOutput = `Welcome to Guess My Number. You have played ${attributes.gamesPlayed} times. Would you like to play?` return input.responseBuilder .speak(speechOutput) .reprompt('Say yes to start the game or no to quit.') .getResponse() } }
The input argument has the following properties and methods (just in case if you decide to explore further):
Now your the first Intent Handler is ready. In case the user decides to end the session, we can use one of the built-in intents such as AMAZON.CancelIntent or AMAZON.StopIntent: and implement the ExitHandler and SessionEndedRequest:
const ExitHandler = { canHandle() { return requestType === 'IntentRequest' && (intentName === 'AMAZON.CancelIntent' || intentName === 'AMAZON.StopIntent') }, handle(input) { return input.responseBuilder .speak('Thanks for playing!') .getResponse() } } const SessionEndedRequest = { canHandle() { return requestType === 'SessionEndedRequest' }, handle(input) { const { req } = input console.error(`Session ended with reason: ${req.request.reason}`) if (input.req.request.error) { console.error(`Session ended with error: ${req.request.error.message}`) } return input.responseBuilder.getResponse() } }
The next Intent that you need to declare for a good user experience is AMAZON.HelpIntent. It is needed if the user does not understand how to work with your skill and asks for help. This is a great place to add instructions on your skill:
const HelpIntent = { canHandle() { return requestType === 'IntentRequest' && intentName === 'AMAZON.HelpIntent' }, handle(input) { const speechOutput = 'I am thinking of a number between zero and one hundred, try to guess it and ' + 'I will tell you if it is higher or lower.' return input.responseBuilder .speak(speechOutput) .reprompt('Try saying a number.') .getResponse() } }
Now you need to process the user’s answer to the question of whether they want to play or not. Possible answers “yes” or “no” will be processed by the built-in intents AMAZON.YesIntent and AMAZON.NoIntent. Below is the handler for the “yes” answer:
const YesIntent = { async canHandle(input) { let isCurrentlyPlaying = false const sessionAttributes = await input.getSessionAttributes() if (sessionAttributes.gameState && sessionAttributes.gameState === 'STARTED') { isCurrentlyPlaying = true } return !isCurrentlyPlaying && requestType === 'IntentRequest' && intentName === 'AMAZON.YesIntent' }, async handle(input) { const sessionAttributes = await input.getSessionAttributes() sessionAttributes.gameState = 'STARTED' sessionAttributes.guessNumber = Math.floor(Math.random() * 101) await input.setSessionAttributes(sessionAttributes) return input.responseBuilder .speak('Great! Try saying a number to start the game.') .reprompt('Try saying a number.') .getResponse() }, }
Note that in canHandle, you should add a check to see if the game has already started. This is necessary for those cases when the user for some reason says “Yes” or “No” during the game. You don’t want the game to start over again because of this and the handler is invoked only if the game has not yet started.
Notice that the gameState variable is used in the session attributes. It identifies that the game is already in process. Also, the guessNumber variable contains the actual number which the user is trying to guess. For a more advanced version of this skill, you can have Alexa ask the user at beginning of the game what maximum number to use – for example, the user may want to make the game harder so that Alexa guesses a number between 0 and 1 million.
Another idea to explore is making an Intent for a hint. The user asks for a hint and Alex reports a slightly more minimal search circle for the desired number.
Let’s move on with the implementation. The next step is for you to handle the case where the user responds “no”. In that case, you need to handle the AMAZON.NoIntent intent. Here, you also need to make sure that the game is not in progress, and in the permanent store we record information – for example, that we had a session, but the game was not continued for some reason (and as a result, the gamesPlayed counter was not updated).
Then, if you receive the “no” response from the user, the shouldEndSession should be invoked, signaling that the session should end:
const NoIntent = { async canHandle(input) { let isCurrentlyPlaying = false const sessionAttributes = await input.getSessionAttributes() if (sessionAttributes.gameState && sessionAttributes.gameState === 'STARTED') { isCurrentlyPlaying = true } return !isCurrentlyPlaying && requestType === 'IntentRequest' && intentName === 'AMAZON.NoIntent' }, async handle(input) { const sessionAttributes = await input.getSessionAttributes() sessionAttributes.endedSessionCount += 1 sessionAttributes.gameState = 'ENDED' await input.setPersistentAttributes(sessionAttributes) return input.responseBuilder .speak('Ok, see you next time!') .shouldEndSession(true) .getResponse() }, }
The next Intent is NumberGuessIntent. It is the one responsible for the logic and progress of the game itself. In canHandle, you will check that the game is in progress, and also that the Intent is GUESS_MY_NUMBER_INTENT.
In the body of the handler (the handle function), you get the number the user spoke (available in the input.req.request.intent.slots.number.value variable), and compare it with the number to be guessed. Depending on the result, you will determine the response to be sent back. If the number was guessed correctly, the attributes are stored in the permanent storage and prompt the user to play again.
const NumberGuessIntent = { async canHandle(input) { let isCurrentlyPlaying = false const sessionAttributes = await input.getSessionAttributes() if (sessionAttributes.gameState && sessionAttributes.gameState === 'STARTED') { isCurrentlyPlaying = true } return isCurrentlyPlaying && requestType === 'IntentRequest' && intentName === GUESS_MY_NUMBER_INTENT }, async handle(input) { const { responseBuilder } = input const guessNum = parseInt(input.req.request.intent.slots.number.value, 10) const sessionAttributes = await input.getSessionAttributes() const targetNum = sessionAttributes.guessNumber if (guessNum > targetNum) { return responseBuilder .speak(`${guessNum} is too high.`) .reprompt('Try saying a lower number.') .getResponse() } if (guessNum < targetNum) { return responseBuilder .speak(`${guessNum} is too low.`) .reprompt('Try saying a higher number.') .getResponse() } if (guessNum === targetNum) { sessionAttributes.gamesPlayed += 1 sessionAttributes.gameState = 'ENDED' await input.setPersistentAttributes(sessionAttributes) return responseBuilder .speak(`${guessNum} is correct! Would you like to play a new game?`) .reprompt('Say yes to start a new game, or no to end the game.') .getResponse() } return responseBuilder .speak('Sorry, I didn\'t get that. Try saying a number.') .reprompt('Try saying a number.') .getResponse() }, }
The basic logic of the skill is almost finished, it only remains to handle errors and any unhandled intents. To do this, add the following ErrorHandler:
const ErrorHandler = { canHandle() { return true }, handle(input, error) { console.log(`Error handled: ${error.message}`) const speech = 'Sorry, I can\'t understand the command. Please say again.' return input.responseBuilder .speak(speech) .reprompt(speech) .getResponse() }, }
And for all other cases in which the request could not be processed by all previous handlers, add UnhandledIntent:
const UnhandledIntent = { canHandle() { return true }, handle(input) { const outputSpeech = 'Say yes to continue, or no to end the game.' return input.responseBuilder .speak(outputSpeech) .reprompt(outputSpeech) .getResponse() }, }
Now you need to register all handlers and build skills with skillHelper:
return skillHelper .addRequestHandlers( LaunchRequest, ExitHandler, SessionEndedRequest, HelpIntent, YesIntent, NoIntent, NumberGuessIntent, FallbackHandler, UnhandledIntent ) .withTable('GuessNumberGame') .addErrorHandlers(ErrorHandler) .start(req)
Please note that the order of these handlers is very important because they will be called exactly in the order in which you register them. The addErrorHandlers method, like addRequestHandlers, takes an unlimited number of handlers. Each handler will be called in the order in which you list them.
The final method in the sequence is called start. It is called at the very end and takes the required req parameter.
In order to work with the persistent storage, you must specify the name of the table where the data will be stored. Otherwise, you can not use the get/setPersistentAttributes methods. Make sure to create the GuessNumberGame table in the Backendless Database. The table schema must be configured as shown below:
The final version of the service should look like this:
// guess-my-number.js 'use strict' const AlexaBase = require('./alexa-base') const SkillHelper = require('./skill-helper') const GUESS_MY_NUMBER_INTENT = 'NumberGuessIntent' class GuessMyNumber extends AlexaBase { /** * @route POST / * @param {Object} req * @returns {Promise<ResponseObject>} */ async guessMyNumber(req) { await this.verifyRequest(req) const requestType = await this.getRequestType(req) const intentName = await this.getIntentName(req) const skillHelper = new SkillHelper() const LaunchRequest = { canHandle(input) { return input.req.session.new || requestType === 'LaunchRequest' }, async handle(input) { const attributes = await input.getPersistentAttributes() || {} if (Object.keys(attributes).length === 0) { attributes.endedSessionCount = 0 attributes.gamesPlayed = 0 attributes.gameState = 'ENDED' } await input.setSessionAttributes(attributes) const speechOutput = `Welcome to Guess My Number. You have played ${attributes.gamesPlayed} times. Would you like to play?` return input.responseBuilder .speak(speechOutput) .reprompt('Say yes to start the game or no to quit.') .getResponse() } } const ExitHandler = { canHandle() { return requestType === 'IntentRequest' && (intentName === 'AMAZON.CancelIntent' || intentName === 'AMAZON.StopIntent') }, handle(input) { return input.responseBuilder .speak('Thanks for playing!') .getResponse() } } const SessionEndedRequest = { canHandle() { return requestType === 'SessionEndedRequest' }, handle(input) { const { req } = input console.error(`Session ended with reason: ${req.request.reason}`) if (input.req.request.error) { console.error(`Session ended with error: ${req.request.error.message}`) } return input.responseBuilder.getResponse() } } const HelpIntent = { canHandle() { return requestType === 'IntentRequest' && intentName === 'AMAZON.HelpIntent' }, handle(input) { const speechOutput = 'I am thinking of a number between zero and one hundred, try to guess it and ' + 'I will tell you if it is higher or lower.' return input.responseBuilder .speak(speechOutput) .reprompt('Try saying a number.') .getResponse() } } const YesIntent = { async canHandle(input) { let isCurrentlyPlaying = false const sessionAttributes = await input.getSessionAttributes() if (sessionAttributes.gameState && sessionAttributes.gameState === 'STARTED') { isCurrentlyPlaying = true } return !isCurrentlyPlaying && requestType === 'IntentRequest' && intentName === 'AMAZON.YesIntent' }, async handle(input) { const sessionAttributes = await input.getSessionAttributes() sessionAttributes.gameState = 'STARTED' sessionAttributes.guessNumber = Math.floor(Math.random() * 101) await input.setSessionAttributes(sessionAttributes) return input.responseBuilder .speak('Great! Try saying a number to start the game.') .reprompt('Try saying a number.') .getResponse() }, } const NoIntent = { async canHandle(input) { let isCurrentlyPlaying = false const sessionAttributes = await input.getSessionAttributes() if (sessionAttributes.gameState && sessionAttributes.gameState === 'STARTED') { isCurrentlyPlaying = true } return !isCurrentlyPlaying && requestType === 'IntentRequest' && intentName === 'AMAZON.NoIntent' }, async handle(input) { const sessionAttributes = await input.getSessionAttributes() sessionAttributes.endedSessionCount += 1 sessionAttributes.gameState = 'ENDED' await input.setPersistentAttributes(sessionAttributes) return input.responseBuilder .speak('Ok, see you next time!') .shouldEndSession(true) .getResponse() }, } const NumberGuessIntent = { async canHandle(input) { let isCurrentlyPlaying = false const sessionAttributes = await input.getSessionAttributes() if (sessionAttributes.gameState && sessionAttributes.gameState === 'STARTED') { isCurrentlyPlaying = true } return isCurrentlyPlaying && requestType === 'IntentRequest' && intentName === GUESS_MY_NUMBER_INTENT }, async handle(input) { const { responseBuilder } = input const guessNum = parseInt(input.req.request.intent.slots.number.value, 10) const sessionAttributes = await input.getSessionAttributes() const targetNum = sessionAttributes.guessNumber if (guessNum > targetNum) { return responseBuilder .speak(`${guessNum} is too high.`) .reprompt('Try saying a lower number.') .getResponse() } if (guessNum < targetNum) { return responseBuilder .speak(`${guessNum} is too low.`) .reprompt('Try saying a higher number.') .getResponse() } if (guessNum === targetNum) { sessionAttributes.gamesPlayed += 1 sessionAttributes.gameState = 'ENDED' await input.setPersistentAttributes(sessionAttributes) return responseBuilder .speak(`${guessNum} is correct! Would you like to play a new game?`) .reprompt('Say yes to start a new game, or no to end the game.') .getResponse() } return responseBuilder .speak('Sorry, I didn\'t get that. Try saying a number.') .reprompt('Try saying a number.') .getResponse() }, } const ErrorHandler = { canHandle() { return true }, handle(input, error) { console.log(`Error handled: ${error.message}`) const speech = 'Sorry, I can\'t understand the command. Please say again.' return input.responseBuilder .speak(speech) .reprompt(speech) .getResponse() }, } const UnhandledIntent = { canHandle() { return true }, handle(input) { const outputSpeech = 'Say yes to continue, or no to end the game.' return input.responseBuilder .speak(outputSpeech) .reprompt(outputSpeech) .getResponse() }, } return skillHelper .addRequestHandlers( LaunchRequest, ExitHandler, SessionEndedRequest, HelpIntent, YesIntent, NoIntent, NumberGuessIntent, UnhandledIntent ) .withTable('GuessNumberGame') .addErrorHandlers(ErrorHandler) .start(req) } } Backendless.ServerCode.addService(GuessMyNumber)
The new service is now ready to use! You will need to deploy the code to Backendless with the npm run deploy command, and install the Endpoint URL in Amazon Developer Console. For more details, see the steps 16-18 from the previous article.
When you are back in the Amazon Developer Console, make sure to click the Build Model button. Once the model is built, you can start testing your skill! Simply ask Alexa: “Play guess my number”, and the game should start.
In the future, we plan to expand and add new features to our Backendless SDK for Alexa. If there are features that you would like us to add, please do not hesitate to tell us about it, we will be happy to improve the capabilities of the SDK.
Happy Coding!
If you have any questions about this procedure, please post your questions in our support forum (https://support.backendless.com) or on Slack (http://slack.backendless.com).