Tuesday, January 22, 2019

Ghost Game, Part 2: Voice-Enabling with Alexa

Note: If you don't see proper syntax highlighting of code, change https to http in URL.

I few weeks ago, I wrote about a father-daugher project in which I and my college student daughter Susan collaborated on a web site implementing the game of GHOST. This is one of those games your family might have played when you were in the car or sitting around the table or a campfire: you say letters in turn until somebody makes a word, and then they lose the round. We'd next planned to have her design a user experience during Christmas break, but we didn't get around to it. This month, I got the idea to voice-enable Ghost for Amazon Alexa (accessible on the Echo, Echo Dot, FireStick, the Alexa App on phones, and lots of other places).

I had been looking for an excuse to create a voice-assistant application for Alexa, and decided a speech front-end for GHOST would be the perfect project since the back-end already existed. This post describes how I went about creating my Alexa Skill (as they're called), and how it turned out. I have not published my game--it isn't fully debugged yet, and there is already a Ghost Game skill on the Alexa marketplace (not mine). My main purpose was learning how to do an Alexa Skill, and that's what I accomplished.

The End Result

Before we go into how I created the skill, let's take a look at the end result. Below are some audio clips of actual gameplay on Alexa. Take a listen.

Session 1: Human makes word M-E-M-O-R-Y
Session 2: Computer bluffs, challenged by human. Human bluffs, challenged by computer.
Session 3: Human makes a word game doesn't know, teaches it a new word.
Session 4: Full game

Here's a visual rendering of a dialog with the game (bolded words spoken by human).

Human: Alexa, open Ghost David.
Alexa: Welcome to Ghost. To hear the rules, say help. I'll start. "M".
Human: "E"
Alexa; "M"
Human: "O"
Alexa: "R."
Human: "Y"
Alexa: "Memory. That's a word! I win. Youre a G. Let's start a new game. You go first."

Human: "T"
Alexa: "A"
Human: "R"
Alexa: "I challenge you. What's your word?"
Human: "My word was 'tarnish'."
Alexa: I see. You win, and I'm a G. Congratulations. Should I add this word to my dictionary? Tarnish. T-A-R-N-I-S-H
Human: Add Word
Alexa: Thanks. I have learned the word 'tarnish'.
Alexa: Let's start a new game. You can start.

Adapting a Web Site to be an Alexa Skill

Our GHOST web site had a pretty simple user interface: the word-in-progress letters were displayed. Users could type a letter to take a turn, and also had a few buttons they could click such as I Challenge You, That's a Word, and New Game. This should translate pretty readily to spoken phrases:

  • Instead of typing a letter to take a turn, the user will speak a letter: "C"
  • Instead of clicking a That's a Word button, the user will say "That's a word."
  • Instead of clicking the I Challenge You button, the user can say "I challenge you."
  • Instead of clicking the New Game button, the user can say "New Game."
Since this is a spoken interface, we'll also add some new action phrases:
  • If the user forgets what the current word is, they can ask "What is word?"
  • If the user forgets the rules for Ghost, they can say "help" or "What are the rules?"
And, when the computer challenge the human, we'll need to process their response:
  • They state their word: "My word was tofu."
  • They admit they were bluffing: "I was bluffing" or "you got me."

Architecture

In terms of the components we need to create, an Alexa Custom Skill usually has two (or more) parts:
  1. There's the skill defnition, which you create and configure in the Amazon Developer portal. This includes:
    • invocation name: official name of skill used to open it.
    • utterances: speech patterns to recognize.
    • intents: groups of utterances that express the same intended action.
    • slots: variables of various types that can occur in intents. You can define your own custom slot types by providing a list of values--such as letters of the alphabet.
  2. A cloud service, usuallly implemented as an AWS Lambda Function (a server-less computing component). This can be written in a variety of languages, including Node.js (JavaScript) or Python. 
  3. Lastly, your cloud service may in turn need to talk to something in the outside world, such as an API or service. For us, that's the existing back-end server functions of the Ghost web site.
In the case of Ghost, these components are:
  1. A skill definition named Ghost, invoked with the name "Ghost David".
  2. A Node.js AWS Lambda function. This function has to do the same things the Ghost web site's front-end does: implement the game logic and make calls to the back end server. The JavaScript logic that was originally in the Ghost web site's front end is replicated here. 
  3. The original Ghost web site's server will respond to the Lambda function, providing functionality such as playing a turn; checking the validity of a word; responding to a player challenge; and adding new words. The server queries and updates the word list SQL Server database. The Lambda function will be calling actions like /Home/Play/word, /Home/ValidWord/word, and /Home/Challenge/word.
Architecture: Alexa and GHOST Web Site Share a Common Back-End

This entire skill was written in Amazon portal interfaces: the Skill definition in the developer portal, and the JavaScript code for the Lambda Function in the AWS portal. No desktop tools required!

Skill Definition

A skill definition is configured in the Amazon developer portal. In the Intents section you specify intended user actions. Some of the intents for Ghost are "My Letter Is...", "I Challenge You", and "That's a Word".

Skills Developer Portal

For each intent, you can specify a collection of utterances. This allows you to cover variations in what a user might say. Your utterances can contain variable placeholder such as {Letter}, called slots. A slot is passsed to the Lambda Function. Thus, when someone says "My letter is B" to Ghost, that utterance will be recognized as intent MyLetterIsIntent. That will be passed to the back end with slot Letter set to "B". The back end will then have the opportunity to exercise logic and decide on a suitable response.

Intent

These slots can be of different types. There are many pre-defined slot types, such as a date or city. However, you can also define your own custom slots, where you provide a list of values. That is what was done for the Letter slot. The values define include not only A-Z, but also Alpha-Zulu: the NATO alphabet, a useful fallback if Alexa can't understand a single letter. Thus, I can say "B" or "Bravo": both will be understood as B by the Lambda Function.

Slot Values

On the Endpoint page, the skill is linked to the AWS Lambda Function. The Lambda function's ARN ID is stored in the skill's configuration. You can also associate the skill ID in the Lambda function's configuration which is recommended.

Skill Endpoint 

Lambda Function

The Lambda Function has functions for announcing a welcome message, and for responding to intents. Let's look at some of them.

The method getWelcomeResponse provides the initial spoken prompts when the game is started. We also create a structure of session variables here: we will need to maintain state throughout the sesson in order to track the word-in-play, who went first, and player scores. In this function the computer also takes the first turn, so there is an HTTP call to the server to get a letter to play. The letter is included in the spoken response. A callback passes the response back to the skill, and the user hears something like the following (the initial letter is random each time):

"Welcome to Ghost. To hear the rules, say help. I'll start. P."
// --------------- Functions that control the skill's behavior -----------------------
// getWelcomeResponse: intiial greeting to user. Initialize session attributes. Make first turn.

function getWelcomeResponse(callback) {
    // If we wanted to initialize the session to have some attributes we could add those here.
    let sessionAttributes = {
        inChallenge: false,  // true if in a challenge.
        gameInSession: true, // true if a game is in-progress.
        turn: true,          // true = human player turn, false = computer player turn
        word: '',            // current partial word in play.
        saveWord: '',        // saved word before last player move
        startPlayer: "0",    // 0: computer player starts, 1: human player starts.
        humanGhost: 0,       // human player's loss count toward being a G-H-O-S-T.
        computerGhost: 0,    // computer player's loss count toward being a G-H-O-S-T.
        unrecognizedLetterCount: 0, // number of times we've failed to understand the human's spoken letter.
        newWord: ''          // new word we learned from human
    };
    const cardTitle = 'Welcome';
    sessionAttributes.startPlayer = "0"; // 0: computer goes first, 1: human goes first
    var speechOutput = 'Welcome to Ghost. To hear the rules, say help. I\'ll start. ';
    var repromptText = 'Say a letter';
    var shouldEndSession = false;
    
     httpGet('Home/Play/' + sessionAttributes.word + '?sp=' + sessionAttributes.startPlayer,  (letter) => { 
         letter = trimLetter(letter);
         if (letter != null) {
             speechOutput = speechOutput + '"' + letter + '".';
             sessionAttributes.word = sessionAttributes.word + letter;
        }

        callback(sessionAttributes,
            buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
     });
}

One important method is onIntent, whose purpose is to invoke a handler function for an intent based its name. For example, the intent type MyLetterIsIntent is handled by the function humanPlay.
/**
 * Called when the user specifies an intent for this skill.
 */
function onIntent(intentRequest, session, callback) {
    console.log(`onIntent requestId=${intentRequest.requestId}, sessionId=${session.sessionId}`);

    const intent = intentRequest.intent;
    const intentName = intentRequest.intent.name;

    // Dispatch to your skill's intent handlers
    if (intentName === 'MyLetterIsIntent') {
        humanPlay(intent, session, callback);
    } else if (intentName === 'WhatIsWordIntent') {
        whatIsWord(intent, session, callback);
    } else if (intentName === 'ThatsAWordIntent') {
        thatsAWord(intent, session, callback);
    } else if (intentName==='NewGameIntent') {
        newGame(intent, session, callback);
    } else if (intentName==='IChallengeYouIntent') {
        humanChallenge(intent, session, callback);
    } else if (intentName==='MyWordIsIntent') {
        humanMyWordIs(intent, session, callback);
    } else if (intentName==='UndoIntent') {
        undoIntent(intent, session, callback);
    } else if (intentName==='BluffingIntent') {
        humanBluffing(intent, session, callback);
    } else if (intentName==='AddNewWordIntent') {
        addNewWord(intent, session, callback);
    } else if (intentName==='HelpIntent') {
        helpIntent(intent, session, callback);
    } else if (intentName==='DebugIntent') {
        debugIntent(intent, session, callback);
    } else if (intentName === 'AMAZON.HelpIntent') {
        getWelcomeResponse(callback);
    } else if (intentName === 'AMAZON.StopIntent' || intentName === 'AMAZON.CancelIntent') {
        handleSessionEndRequest(callback);
    } else if (intentName === 'AMAZON.FallbackIntent') {
         handleFallbackIntent(intent, session, callback)
    } else {
        console.log('intentName: ' + intentName);
        throw new Error('Invalid intent');
    }
}

the humanPlay function is one of the larger functions. It handles the human game play, where the human has spoken a letter. The letter can come in a few different ways, including a NATO alphabet word, so a function named trimLetter is employed to identify the letter and return a value like "A", "B", etc. The letter is added to the current word, and then evaluated.

If the user has made a valid word, determined by asking the server to check its word list, then the human has lost. Their GHOST count is incremented, and they are told they are now a G, or a G-H, etc. A new round starts. 

        "Stone. That's a word. I win! Let's start a new game. You go first."

Otherwise, the computer attempts to play, by passing the current word to the server and asking it to play. If the response is a letter, it is played (spoken) and added to the current word. If however the server responds with "?", a challenge is issued.
// -------------------- Human Plays Letter --------------------
// A letter was played. Set letter in session and prepares speech to reply to the user.

function humanPlay(intent, session, callback) {
    const cardTitle = intent.name;
    const letterSlot = intent.slots.Letter;
    let sessionAttributes = session.attributes;

    let repromptText = 'Please say a letter to continue making a word.';
    let shouldEndSession = false;
    let speechOutput = '';

    sessionAttributes.saveWord = sessionAttributes.word;
    console.log('humanPlay 02 saveWord: ' + sessionAttributes.saveWord);

    if (!sessionAttributes.gameInSession) {
        speechOutput = "To start a new game, say 'New Game'.";
        callback(sessionAttributes,
             buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
        return;
    }

    if (sessionAttributes.inChallenge) {
        speechOutput = "Please respond to the challenge. Either tell me what your word was, or admit you were bluffing. Or say 'New Game' to start a new game..";
        callback(sessionAttributes,
             buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
        return;
    }

    if (letterSlot) {
        var letter = trimLetter(letterSlot.value);
        
        if (letter===null) {
            speechOutput = "I'm not sure what your letter is.";
            sessionAttributes.unrecognizedLetterCount++;
            if (sessionAttributes.unrecognizedLetterCount==2) 
                speechOutput += " Please try again, or say a word from the NATO alphabet like Alpha or Bravo.";
            else
                speechOutput += " Please try again.";
            callback(sessionAttributes,
                 buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
             return;
        }
        else {
            console.log('Human played: ' + letter + ' | current word: ' + sessionAttributes.word);
            sessionAttributes.unrecognizedLetterCount = 0;
            //sessionAttributes = createLetterAttributes(letter);
            sessionAttributes.word = sessionAttributes.word + letter;

            // Human play - check for a completed word
            
            if (sessionAttributes.word.length >= 3) {
                httpGet('Home/ValidWord/' + sessionAttributes.word,  (res) => {
                    if (res=='true') {
                        sessionAttributes.humanGhost++;
                        speechOutput = sessionAttributes.word + ". That\'s a word! I win! You\'re a " + score(sessionAttributes.humanGhost) + ". Let's start a new game. You go first.";
                         sessionAttributes.startPlayer = "1"; // 0: computer goes first, 1: human goes first
                        sessionAttributes.word = '';
                        callback(sessionAttributes,
                            buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
                        return;
                    }
                });
            }
    
            // Computer play
    
            httpGet('Home/Play/' + sessionAttributes.word + '?sp=' + sessionAttributes.startPlayer,  (res) => {
                
                res = trimLetter(res);
                     
                if (res==='?') {   // challenge
                    speechOutput = 'I challenge you! What\'s your word?';
                }
                else {
                    console.log('Computer played: ' + letter + ' | current word: ' + sessionAttributes.word);
                    speechOutput = speechOutput + '"' + res + '".';
                    sessionAttributes.word = sessionAttributes.word + res;
                }
    
                // If the user either does not reply to the welcome message or says something that is not
                // understood, they will be prompted again with this text.
    
                callback(sessionAttributes,
                    buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
                return;
             });
        }
    } else {
        speechOutput = "There is a data problem in the skill: letter slot not found.";

        callback(sessionAttributes,
             buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
    }
}

humanPlay function

The user has other options besides playing a letter. The human can say "I challenge you", in which case the humanChallenge function is run. If the program had a valid word, it will spell it out and score a loss for the human. Otherwise, it will admit it was bluffing and score a loss for itself.
// -------------------- Human Challenges Computer --------------------
// Human challenges computer. Tell them our word, or admit we were bluffing.

function humanChallenge(intent, session, callback) {
    const cardTitle = intent.name;
    let sessionAttributes = session.attributes;
    let repromptText = 'Please say a letter to continue making a word.';
    let shouldEndSession = false;
    let speechOutput = '';

    httpGet('Home/Challenge/' + sessionAttributes.word,  (res) => {
        if (res==='^' || res==='"^"') {
            sessionAttributes.computerGhost++;
            speechOutput ="You got me - I was bluffing. That makes me a " + score(sessionAttributes.computerGhost) + ". Let's start a new game. You go first.";
            sessionAttributes.startPlayer = "1"; // 0: computer goes first, 1: human goes first
            sessionAttributes.word = '';
            callback(sessionAttributes,
                buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
            return;
        }
        else {
            sessionAttributes.humanGhost++;
            speechOutput = "My word was " + res + ". " + spellWord(res) + ". I win! You\'re a " + score(sessionAttributes.humanGhost) + ". Let's start a new game. You go first.";
            sessionAttributes.startPlayer = "1";
            sessionAttributes.word = '';
            callback(sessionAttributes,
                buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
            return;
        }
    });

}

humanChallenge function

Test and Debug

How do you test this kind of application? From the Developer Portal, there's an Alexa Simulator; but if you have an actual device on the same account then you'll be able to call up your skill even though it hasn't been officially published.

How do you debug an Alexa Skill? Well, all you have to do is add console.log(text); statements in your Lambda Function's JavaScript. The output will be captured in CloudWatch logs. Every time you conduct an Alexa Session, there's a CloudWatch log you can inspect. These logs are also where error detail will be found if your Lambda Function has an unhandled exception. In the log excerpt below, the "Human played..." and "Computer played..." lines were added by console.log statements.

CloudWatch Logs


Post-Mortem: Rapid development, Prolonged Debugging

Ghost was my very first attempt at developing an Alexa skill. To give you some idea of how rapidly you can learn Alexa Skills, here's what I experienced in my first 3 days:

1. Saturday afternoon. I went to Best Buy and purchased an Echo Dot for $24.
2. I went through the Color Picker quickstart.  I registered as a developer with Amazon. Some of the instructions in the quickstart were hard to follow, because some of the developer site screens and AWS screens had changed since the tutorial was written. But I stayed with it, and in a couple of hours I was able to say "Open Color Picker" to my Echo Dot and tell it my favorite color. I chose the Node.js version (JavaScript), but there are also versions of this quickstart where the Lambda Function is written in other languages such as Python.
3. Now feeling empowered, I worked on a derivative of Color Picker that would search my blog. To achieve that, I had to look up how to do an HTTP request from a Node.js Lambda function. In another couple of hours, I had this project working. I now felt ready and empowered to do something real.
4. On Sunday, I started work on my Ghost Alexa skill, again using Color Picker as my starting point.
5. By end of day Monday, I was finished with the basic implementation: I could play Ghost with my Echo Dot. All that remains is thorough testing and debug.

If you're going to work on an Alexa Skill of any depth, you're likely going to find like me that initial development goes pretty quickly, but you can expect to spend quite a bit of time on test and debug before your skill is ready for prime time. There are a number of reasons for this, chief of which is the surprise factor: the user may not say things you expect them to, or what they say may be misunderstood. It took some time to learn the nuances of  effectively communicating this way (you might call this "elastic communication"). If you want your Alexa Skill to be smarter than a voicemail menu, then you'll need to invest some effort--but it will be worth it in the usability of what you end up with.  All in all, I'm pleased with how quickly I became functional in this new area.

Will I publish Ghost? Perhaps, after I finish debugging and refining it.

Source Code

Below is the full source code to the Alexa Skill and the Node.js Lambda Function.

Alexa Skill

{
    "interactionModel": {
        "languageModel": {
            "invocationName": "ghost david",
            "intents": [
                {
                    "name": "AMAZON.FallbackIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.CancelIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.HelpIntent",
                    "samples": []
                },
                {
                    "name": "AMAZON.StopIntent",
                    "samples": [
                        "exit game",
                        "exit",
                        "stop game",
                        "quit game",
                        "stop",
                        "quit",
                        "end game"
                    ]
                },
                {
                    "name": "AMAZON.NavigateHomeIntent",
                    "samples": []
                },
                {
                    "name": "MyLetterIsIntent",
                    "slots": [
                        {
                            "name": "Letter",
                            "type": "LIST_OF_LETTERS"
                        }
                    ],
                    "samples": [
                        "Let's try {Letter}",
                        "{Letter}",
                        "My letter is {Letter}"
                    ]
                },
                {
                    "name": "WhatIsWordIntent",
                    "slots": [],
                    "samples": [
                        "What is the current word",
                        "What is current word",
                        "what's the word again",
                        "what's the word so far",
                        "tell me word",
                        "What's the current word",
                        "What's the word",
                        "Say word",
                        "What is the word",
                        "What is word"
                    ]
                },
                {
                    "name": "ThatsAWordIntent",
                    "slots": [],
                    "samples": [
                        "You lose",
                        "That is a word",
                        "I win",
                        "You made a word",
                        "Tha'ts a word"
                    ]
                },
                {
                    "name": "NewGameIntent",
                    "slots": [],
                    "samples": [
                        "redo",
                        "start over",
                        "start new game",
                        "new game"
                    ]
                },
                {
                    "name": "IChallengeYouIntent",
                    "slots": [],
                    "samples": [
                        "challenge",
                        "Tell me your word",
                        "What is your word",
                        "I give up",
                        "I challenge you"
                    ]
                },
                {
                    "name": "MyWordIsIntent",
                    "slots": [
                        {
                            "name": "ClaimedWord",
                            "type": "ANY_WORD"
                        }
                    ],
                    "samples": [
                        "My word was {ClaimedWord}",
                        "My word is {ClaimedWord}"
                    ]
                },
                {
                    "name": "DebugIntent",
                    "slots": [],
                    "samples": [
                        "log debug",
                        "log variables",
                        "debug log"
                    ]
                },
                {
                    "name": "UndoIntent",
                    "slots": [],
                    "samples": [
                        "do over",
                        "take it back",
                        "undo my letter",
                        "change my letter",
                        "change my last turn",
                        "backspace",
                        "undo",
                        "undo my last turn"
                    ]
                },
                {
                    "name": "HelpIntent",
                    "slots": [],
                    "samples": [
                        "i need the rule",
                        "explain",
                        "what do I do now",
                        "what do I do",
                        "what are the rules",
                        "what commands do you know",
                        "help"
                    ]
                },
                {
                    "name": "BluffingIntent",
                    "slots": [],
                    "samples": [
                        "busted",
                        "Oops",
                        "I have no word",
                        "I had no word",
                        "You caught me I was bluffing",
                        "You caught me",
                        "I didn't have a word",
                        "You got me I was bluffing",
                        "You got me",
                        "I was bluffing"
                    ]
                },
                {
                    "name": "AddNewWordIntent",
                    "slots": [],
                    "samples": [
                        "add word",
                        "add new word",
                        "yes add new word",
                        "yes add word",
                        "yes"
                    ]
                }
            ],
            "types": [
                {
                    "name": "LIST_OF_LETTERS",
                    "values": [
                        {
                            "name": {
                                "value": "zulu"
                            }
                        },
                        {
                            "name": {
                                "value": "yankee"
                            }
                        },
                        {
                            "name": {
                                "value": "x-ray"
                            }
                        },
                        {
                            "name": {
                                "value": "whiskey"
                            }
                        },
                        {
                            "name": {
                                "value": "victor"
                            }
                        },
                        {
                            "name": {
                                "value": "uniform"
                            }
                        },
                        {
                            "name": {
                                "value": "tango"
                            }
                        },
                        {
                            "name": {
                                "value": "sierra"
                            }
                        },
                        {
                            "name": {
                                "value": "romeo"
                            }
                        },
                        {
                            "name": {
                                "value": "quebec"
                            }
                        },
                        {
                            "name": {
                                "value": "papa"
                            }
                        },
                        {
                            "name": {
                                "value": "oscar"
                            }
                        },
                        {
                            "name": {
                                "value": "november"
                            }
                        },
                        {
                            "name": {
                                "value": "mike"
                            }
                        },
                        {
                            "name": {
                                "value": "lima"
                            }
                        },
                        {
                            "name": {
                                "value": "kilo"
                            }
                        },
                        {
                            "name": {
                                "value": "juliett"
                            }
                        },
                        {
                            "name": {
                                "value": "india"
                            }
                        },
                        {
                            "name": {
                                "value": "hotel"
                            }
                        },
                        {
                            "name": {
                                "value": "golf"
                            }
                        },
                        {
                            "name": {
                                "value": "foxtrot"
                            }
                        },
                        {
                            "name": {
                                "value": "echo"
                            }
                        },
                        {
                            "name": {
                                "value": "delta"
                            }
                        },
                        {
                            "name": {
                                "value": "alpha"
                            }
                        },
                        {
                            "name": {
                                "value": "charlie"
                            }
                        },
                        {
                            "name": {
                                "value": "bravo"
                            }
                        },
                        {
                            "id": "LETTER_Z",
                            "name": {
                                "value": "z",
                                "synonyms": [
                                    "zulu"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_Y",
                            "name": {
                                "value": "y",
                                "synonyms": [
                                    "Yankee"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_X",
                            "name": {
                                "value": "x",
                                "synonyms": [
                                    "X-Ray"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_W",
                            "name": {
                                "value": "w",
                                "synonyms": [
                                    "Whiskey"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_V",
                            "name": {
                                "value": "v",
                                "synonyms": [
                                    "Victor"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_U",
                            "name": {
                                "value": "u",
                                "synonyms": [
                                    "Uniform"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_T",
                            "name": {
                                "value": "t",
                                "synonyms": [
                                    "Tango"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_S",
                            "name": {
                                "value": "s",
                                "synonyms": [
                                    "Sierra"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_R",
                            "name": {
                                "value": "r",
                                "synonyms": [
                                    "Romeo"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_Q",
                            "name": {
                                "value": "q",
                                "synonyms": [
                                    "Quebec"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_P",
                            "name": {
                                "value": "p",
                                "synonyms": [
                                    "Papa"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_O",
                            "name": {
                                "value": "o",
                                "synonyms": [
                                    "Oscar"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_N",
                            "name": {
                                "value": "n",
                                "synonyms": [
                                    "November"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_M",
                            "name": {
                                "value": "m",
                                "synonyms": [
                                    "Mike"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_L",
                            "name": {
                                "value": "l",
                                "synonyms": [
                                    "Lima"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_K",
                            "name": {
                                "value": "k",
                                "synonyms": [
                                    "Kilo"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_J",
                            "name": {
                                "value": "j",
                                "synonyms": [
                                    "Juliett"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_I",
                            "name": {
                                "value": "i",
                                "synonyms": [
                                    "India"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_H",
                            "name": {
                                "value": "h",
                                "synonyms": [
                                    "Hotel"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_G",
                            "name": {
                                "value": "g",
                                "synonyms": [
                                    "Golf"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_F",
                            "name": {
                                "value": "f",
                                "synonyms": [
                                    "Foxtrot"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_E",
                            "name": {
                                "value": "e",
                                "synonyms": [
                                    "Echo"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_D",
                            "name": {
                                "value": "d",
                                "synonyms": [
                                    "Delta"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_C",
                            "name": {
                                "value": "c",
                                "synonyms": [
                                    "Charlie"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_B",
                            "name": {
                                "value": "b",
                                "synonyms": [
                                    "Bravo"
                                ]
                            }
                        },
                        {
                            "id": "LETTER_A",
                            "name": {
                                "value": "a",
                                "synonyms": [
                                    "alpha"
                                ]
                            }
                        }
                    ]
                },
                {
                    "name": "ANY_WORD",
                    "values": [
                        {
                            "name": {
                                "value": "Alpha"
                            }
                        }
                    ]
                }
            ]
        }
    }
}

Lambda Function

'use strict';
var http = require('http'); 

//---------- G H O S T ----------
// Ghost game AWS Lambda function. This function pairs with the Alexa skill David Ghost. 

// The Ghost game for Alexa has 3 components:
// 1. An Alexa skill named Ghost David (unpublished).
// 2. AWS Lambda function (this code), which takes the place of the web site JavaScript at http://ghost-susanpallmann.com.
// 3. The ASP.NET MVC web site back-end at http://ghost-susanpallmann.com. see http://davidpallmann.blogspot.com/2018/12/ghost-father-daughter-project-part-1.html
//
// To output to the CloudWatch logs for this function, simply add console.log('message'); statements to the code.

/**
 * This sample demonstrates a simple skill built with the Amazon Alexa Skills Kit.
 * The Intent Schema, Custom Slots, and Sample Utterances for this skill, as well as
 * testing instructions are located at http://amzn.to/1LzFrj6
 *
 * For additional samples, visit the Alexa Skills Kit Getting Started guide at
 * http://amzn.to/1LGWsLG
 */


// --------------- Helpers that build all of the responses -----------------------

function buildSpeechletResponse(title, output, repromptText, shouldEndSession) {
    return {
        outputSpeech: {
            type: 'PlainText',
            text: output,
        },
        card: {
            type: 'Simple',
            title: `SessionSpeechlet - ${title}`,
            content: `SessionSpeechlet - ${output}`,
        },
        reprompt: {
            outputSpeech: {
                type: 'PlainText',
                text: repromptText,
            },
        },
        shouldEndSession,
    };
}

function buildResponse(sessionAttributes, speechletResponse) {
    return {
        version: '1.0',
        sessionAttributes,
        response: speechletResponse,
    };
}


// --------------- Functions that control the skill's behavior -----------------------

// getWelcomeResponse: intiial greeting to user. Initialize session attributes. Make first turn.

function getWelcomeResponse(callback) {
    // If we wanted to initialize the session to have some attributes we could add those here.
    let sessionAttributes = {
        inChallenge: false,  // true if in a challenge.
        gameInSession: true, // true if a game is in-progress.
        turn: true,          // true = human player turn, false = computer player turn
        word: '',            // current partial word in play.
        saveWord: '',        // saved word before last player move
        startPlayer: "0",    // 0: computer player starts, 1: human player starts.
        humanGhost: 0,       // human player's loss count toward being a G-H-O-S-T.
        computerGhost: 0,    // computer player's loss count toward being a G-H-O-S-T.
        unrecognizedLetterCount: 0, // number of times we've failed to understand the human's spoken letter.
        newWord: ''          // new word we learned from human
    };
    const cardTitle = 'Welcome';
    sessionAttributes.startPlayer = "0"; // 0: computer goes first, 1: human goes first
    var speechOutput = 'Welcome to Ghost. To hear the rules, say help. I\'ll start. ';
    var repromptText = 'Say a letter';
    var shouldEndSession = false;
    
     httpGet('Home/Play/' + sessionAttributes.word + '?sp=' + sessionAttributes.startPlayer,  (letter) => { 
         letter = trimLetter(letter);
         if (letter != null) {
             speechOutput = speechOutput + '"' + letter + '".';
             sessionAttributes.word = sessionAttributes.word + letter;
        }

        callback(sessionAttributes,
            buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
     });
}


// -------------------- Human Plays Letter --------------------
// A letter was played. Set letter in session and prepares speech to reply to the user.

function humanPlay(intent, session, callback) {
    const cardTitle = intent.name;
    const letterSlot = intent.slots.Letter;
    let sessionAttributes = session.attributes;

    let repromptText = 'Please say a letter to continue making a word.';
    let shouldEndSession = false;
    let speechOutput = '';

    sessionAttributes.saveWord = sessionAttributes.word;
    console.log('humanPlay 02 saveWord: ' + sessionAttributes.saveWord);

    if (!sessionAttributes.gameInSession) {
        speechOutput = "To start a new game, say 'New Game'.";
        callback(sessionAttributes,
             buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
        return;
    }

    if (sessionAttributes.inChallenge) {
        speechOutput = "Please respond to the challenge. Either tell me what your word was, or admit you were bluffing. Or say 'New Game' to start a new game..";
        callback(sessionAttributes,
             buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
        return;
    }

    if (letterSlot) {
        var letter = trimLetter(letterSlot.value);
        
        if (letter===null) {
            speechOutput = "I'm not sure what your letter is.";
            sessionAttributes.unrecognizedLetterCount++;
            if (sessionAttributes.unrecognizedLetterCount==2) 
                speechOutput += " Please try again, or say a word from the NATO alphabet like Alpha or Bravo.";
            else
                speechOutput += " Please try again.";
            callback(sessionAttributes,
                 buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
             return;
        }
        else {
            console.log('Human played: ' + letter + ' | current word: ' + sessionAttributes.word);
            sessionAttributes.unrecognizedLetterCount = 0;
            //sessionAttributes = createLetterAttributes(letter);
            sessionAttributes.word = sessionAttributes.word + letter;

            // Human play - check for a completed word
            
            if (sessionAttributes.word.length >= 3) {
                httpGet('Home/ValidWord/' + sessionAttributes.word,  (res) => {
                    if (res=='true') {
                        sessionAttributes.humanGhost++;
                        speechOutput = sessionAttributes.word + ". That\'s a word! I win! You\'re a " + score(sessionAttributes.humanGhost) + ". Let's start a new game. You go first.";
                         sessionAttributes.startPlayer = "1"; // 0: computer goes first, 1: human goes first
                        sessionAttributes.word = '';
                        callback(sessionAttributes,
                            buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
                        return;
                    }
                });
            }
    
            // Computer play
    
            httpGet('Home/Play/' + sessionAttributes.word + '?sp=' + sessionAttributes.startPlayer,  (res) => {
                
                res = trimLetter(res);
                     
                if (res==='?') {   // challenge
                    speechOutput = 'I challenge you! What\'s your word?';
                }
                else {
                    console.log('Computer played: ' + letter + ' | current word: ' + sessionAttributes.word);
                    speechOutput = speechOutput + '"' + res + '".';
                    sessionAttributes.word = sessionAttributes.word + res;
                }
    
                // If the user either does not reply to the welcome message or says something that is not
                // understood, they will be prompted again with this text.
    
                callback(sessionAttributes,
                    buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
                return;
             });
        }
    } else {
        speechOutput = "There is a data problem in the skill: letter slot not found.";

        callback(sessionAttributes,
             buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
    }
}

// Return human or computer loss score as a phrase in the form of G-H-O...

function score(ghostScore) {
    switch(ghostScore) {
        case 0:
            return "";
        case 1:
            return "G";
        case 2:
            return "G-H";
        case 3:
            return "G-H-O";
        case 4:
            return "G-H-O-S";
        default:
        case 5:
            return "G-H-O-S-T";
    }
}


// --------------- Start New Game -----------------------

function newGame(intent, session, callback) {
    // If we wanted to initialize the session to have some attributes we could add those here.
    let sessionAttributes = session.attributes;
    const cardTitle = 'Welcome';
    sessionAttributes.startPlayer = "0"; // 0: computer goes first, 1: human goes first
    sessionAttributes.word = '';
    var speechOutput = 'New game. I\'ll start. ';
    var repromptText = 'Say a letter';
    var shouldEndSession = false;
    
     httpGet('Home/Play/' + sessionAttributes.word + '?sp=' + sessionAttributes.startPlayer,  (letter) => { 
         
         letter = trimLetter(letter);
         
         if (letter != null) {
             speechOutput = speechOutput + '"' + letter + '".';
             sessionAttributes.word = sessionAttributes.word + letter;
        }

        callback(sessionAttributes,
            buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
     });
}


function handleSessionEndRequest(callback) {
    const cardTitle = 'Session Ended';
    const speechOutput = 'Thank you for playng Ghost. Be seeing you!';
    // Setting this to true ends the session and exits the skill.
    const shouldEndSession = true;

    callback({}, buildSpeechletResponse(cardTitle, speechOutput, null, shouldEndSession));
}

// -------------------- Human Asks for Word --------------------
// Tell the user the current word, by spelling it out. e.g. "The current word is S-T-R-E".

function whatIsWord(intent, session, callback) {
    const cardTitle = intent.name;
    let sessionAttributes = session.attributes;
    let repromptText = 'Please say a letter to continue making a word.';
    let shouldEndSession = false;
    let speechOutput = '';

    speechOutput = "The current word is: ";
    
    if (sessionAttributes.word === '') {
        speechOutput = speechOutput + ' empty';
    }
    else {
        for (var w = 0; w < sessionAttributes.word.length; w++) {
            speechOutput = speechOutput + sessionAttributes.word.substring(w, w+1) + '. ';
        }
    }

    callback(sessionAttributes,
         buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
}

// Input not understood. Say something.

function handleFallbackIntent(intent, session, callback) {
    const cardTitle = intent.name;
    let sessionAttributes = session.attributes;
    let repromptText = 'Please say a letter to continue making a word.';
    let shouldEndSession = false;
    let speechOutput = '';

    speechOutput = "It\'s your turn to say a letter.";
    
    callback(sessionAttributes,
         buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
}

// -------------------- Human Says Computer Made a Word --------------------
// Accept human's word for it, and accept a loss. TOOD: add new word to vocabulary.

function thatsAWord(intent, session, callback) {
    const cardTitle = intent.name;
    let sessionAttributes = session.attributes;
    let repromptText = 'Please say a letter to continue making a word.';
    let shouldEndSession = false;
    let speechOutput = '';

    sessionAttributes.computerGhost++;
    speechOutput = "Congratulations, you beat me. Now I'm a " + score(sessionAttributes.computerGhost) + ". Why don't you start a new word?";
    
    sessionAttributes.word = '';
    sessionAttributes.startPlayer = "1"; // 0: computer goes first, 1: human goes first

    callback(sessionAttributes,
         buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
}

// -------------------- Human Challenges Computer --------------------
// Human challenges computer. Tell them our word, or admit we were bluffing.

function humanChallenge(intent, session, callback) {
    const cardTitle = intent.name;
    let sessionAttributes = session.attributes;
    let repromptText = 'Please say a letter to continue making a word.';
    let shouldEndSession = false;
    let speechOutput = '';

    httpGet('Home/Challenge/' + sessionAttributes.word,  (res) => {
        if (res==='^' || res==='"^"') {
            sessionAttributes.computerGhost++;
            speechOutput ="You got me - I was bluffing. That makes me a " + score(sessionAttributes.computerGhost) + ". Let's start a new game. You go first.";
            sessionAttributes.startPlayer = "1"; // 0: computer goes first, 1: human goes first
            sessionAttributes.word = '';
            callback(sessionAttributes,
                buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
            return;
        }
        else {
            sessionAttributes.humanGhost++;
            speechOutput = "My word was " + res + ". " + spellWord(res) + ". I win! You\'re a " + score(sessionAttributes.humanGhost) + ". Let's start a new game. You go first.";
            sessionAttributes.startPlayer = "1";
            sessionAttributes.word = '';
            callback(sessionAttributes,
                buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
            return;
        }
    });

}


// -------------------- Human Challenges Computer --------------------
// Human admis they were bluffing after being challenged by computer. 

function humanBluffing(intent, session, callback) {
    const cardTitle = intent.name;
    let sessionAttributes = session.attributes;
    let repromptText = 'Please say a letter to continue making a word.';
    let shouldEndSession = false;
    let speechOutput = '';

    sessionAttributes.humanGhost++;
    speechOutput = "Sorry, you lose. You're now a " + score(sessionAttributes.humanGhost) + " Let's start a new game. You go first.";
    sessionAttributes.startPlayer = "1"; // 0: computer goes first, 1: human goes first
    sessionAttributes.word = '';
    callback(sessionAttributes,
        buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
}


// -------------------- Human Suppies Word upon Challenge --------------------
// Human claims they had a valid word after being challenged.

function humanMyWordIs(intent, session, callback) {
    const cardTitle = intent.name;
    var claimedWordSlot = intent.slots.ClaimedWord;
    let sessionAttributes = session.attributes;
    let repromptText = 'Please say a letter to continue making a word.';
    let shouldEndSession = false;
    let speechOutput = '';
    
    if (claimedWordSlot && claimedWordSlot.value) {
        var claimedWord = claimedWordSlot.value;

        sessionAttributes.newWord = claimedWordSlot.value;
        
        if (sessionAttributes.newWord && sessionAttributes.word) {
            sessionAttributes.newWord = sessionAttributes.newWord.toUpperCase();
            var existingWord = sessionAttributes.word.toUpperCase();

            var index = sessionAttributes.newWord.indexOf(existingWord);
            if (index != 0) {
                speechOutput = "I'm sorry, but that word doesn't match the word in play. The word in play is " + spellWord(sessionAttributes.word) + ".";
            }
            else {
                if (claimedWord.toLowerCase()===sessionAttributes.word.toLowerCase()) {
                    sessionAttributes.humanGhost++;
                    speechOutput = sessionAttributes.word + "You just made a complete word so I win! You\'re a " + score(sessionAttributes.humanGhost) + ". Let's start a new game. You go first.";
                    sessionAttributes.word = '';
                    sessionAttributes.startPlayer = "1"; // 0: computer goes first, 1: human goes first
                }
                else {
                    sessionAttributes.computerGhost++;
                    speechOutput = "I see. You win, and I'm a " + score(sessionAttributes.computerGhost) + ". Congratulations. Should I add this word to my dictionary? " + claimedWord + '. ' + spellWord(claimedWordSlot.value) + " Say 'Add Word' to confirm.";
                }
            }
        }
    }
    else {
        speechOutput = "Can you say your word again please?";
    }
    
    callback(sessionAttributes,
        buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
}

// Human confirms, Yes add new word.

function addNewWord(intent, session, callback) {
    const cardTitle = intent.name;
    let sessionAttributes = session.attributes;
    let repromptText = 'Please say a letter to continue making a word.';
    let shouldEndSession = false;
    let speechOutput = '';
    
    if (sessionAttributes.newWord != '') {
        speechOutput = "Thanks. I have learned the word '" + sessionAttributes.newWord + "'. Let's start a new word. You can start.";
        sessionAttributes.startPlayer = "1";
        httpGet('Home/AddWord/' + sessionAttributes.newWord,  (letter) => { 
            sessionAttributes.newWord = '';
            sessionAttributes.word = '';
            sessionAttributes.startPlayer = "1"; // 0: computer goes first, 1: human goes first
            callback(sessionAttributes,
                buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
     });
    }
    else {
        speechOutput = "Sorry, I don\'t understand. Please say a letter.";
        callback(sessionAttributes,
            buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
    }
}


// Undo last turn (Alexa may have misunderstood letter)

function undoIntent(intent, session, callback) {
    
    const cardTitle = intent.name;
    let sessionAttributes = session.attributes;
    let repromptText = 'Please say a letter to continue making a word.';
    let shouldEndSession = false;
    let speechOutput = '';
    
    if (sessionAttributes.word==='') {
        speechOutput = "There\'s nothing to undo: the current word is empty.";
    }
    else {
        sessionAttributes.word = sessionAttributes.saveWord;

        speechOutput = "I undid your last turn. The current word is: " + spellWord(sessionAttributes.word);
    
    }
    callback(sessionAttributes,
            buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
}

// Help - explain the rules of Ghost and available commands.

function helpIntent(intent, session, callback) {
    
    const cardTitle = intent.name;
    let sessionAttributes = session.attributes;
    let repromptText = 'Please say a letter to continue making a word.';
    let shouldEndSession = false;
    let speechOutput = '';
    
    speechOutput = "Let's play Ghost! Each player takes a turn adding a letter. If you make a real word of 3 letters or more, you're a G. Next time that happens, you're a G-H. When you get to G-H-O-S-T you're out. If you don't think a valid word can be made, you can challenge the other player.";
    
    callback(sessionAttributes,
            buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
}


// Render speech phrase to spell out a word a letter a time.

function spellWord(word) {
    let speechOutput = '';
    if (word === '') {
        speechOutput = speechOutput + ' empty';
    }
    else {
        for (var w = 0; w < word.length; w++) {
            speechOutput = speechOutput + word.substring(w, w+1) + '. ';
        }
    }
    return speechOutput;
}

// output key variables to log

function debugIntent(intent, session, callback) {
    const cardTitle = intent.name;
    let sessionAttributes = session.attributes;
    let repromptText = 'Please say a letter to continue making a word.';
    let shouldEndSession = false;
    let speechOutput = '';
    
    console.log('sessionAttributes.word: ' + sessionAttributes.word + ', startPlayer: ' + sessionAttributes.startPlayer);

    speechOutput = "Variables have been logged";

    callback(sessionAttributes,
            buildSpeechletResponse(cardTitle, speechOutput, repromptText, shouldEndSession));
}

// Determine the letter from input that may be quoted or may be a NATO alphabetic code like Alpha / Bravo / etc. Return just the letter.
// NOTE: ? is recognized as a letter, as that response is given by the back end for a challenge during computer play.

function trimLetter(letter) {
    if (!letter) return null;
    letter = letter.toUpperCase();
    if (letter.indexOf('?') != -1) return '?';
    if (letter.indexOf('A') != -1) return 'A';
    if (letter.indexOf('B') != -1) return 'B';
    if (letter.indexOf('C') != -1) return 'C';
    if (letter.indexOf('D') != -1) return 'D';
    if (letter.indexOf('E') != -1) return 'E';
    if (letter.indexOf('F') != -1) return 'F';
    if (letter.indexOf('G') != -1) return 'G';
    if (letter.indexOf('H') != -1) return 'H';
    if (letter.indexOf('I') != -1) return 'I';
    if (letter.indexOf('J') != -1) return 'J';
    if (letter.indexOf('K') != -1) return 'K';
    if (letter.indexOf('L') != -1) return 'L';
    if (letter.indexOf('M') != -1) return 'M';
    if (letter.indexOf('N') != -1) return 'N';
    if (letter.indexOf('O') != -1) return 'O';
    if (letter.indexOf('P') != -1) return 'P';
    if (letter.indexOf('Q') != -1) return 'Q';
    if (letter.indexOf('R') != -1) return 'R';
    if (letter.indexOf('S') != -1) return 'S';
    if (letter.indexOf('T') != -1) return 'T';
    if (letter.indexOf('U') != -1) return 'U';
    if (letter.indexOf('V') != -1) return 'V';
    if (letter.indexOf('W') != -1) return 'W';
    if (letter.indexOf('X') != -1) return 'X';
    if (letter.indexOf('Y') != -1) return 'Y';
    if (letter.indexOf('Z') != -1) return 'Z';
    return null;
}

// --------------- Events -----------------------

/**
 * Called when the session starts.
 */
function onSessionStarted(sessionStartedRequest, session) {
    console.log(`onSessionStarted requestId=${sessionStartedRequest.requestId}, sessionId=${session.sessionId}`);
}

/**
 * Called when the user launches the skill without specifying what they want.
 */
function onLaunch(launchRequest, session, callback) {
    console.log(`onLaunch requestId=${launchRequest.requestId}, sessionId=${session.sessionId}`);

    // Dispatch to your skill's launch.
    getWelcomeResponse(callback);
}

/**
 * Called when the user specifies an intent for this skill.
 */
function onIntent(intentRequest, session, callback) {
    console.log(`onIntent requestId=${intentRequest.requestId}, sessionId=${session.sessionId}`);

    const intent = intentRequest.intent;
    const intentName = intentRequest.intent.name;

    // Dispatch to your skill's intent handlers
    if (intentName === 'MyLetterIsIntent') {
        humanPlay(intent, session, callback);
    } else if (intentName === 'WhatIsWordIntent') {
        whatIsWord(intent, session, callback);
    } else if (intentName === 'ThatsAWordIntent') {
        thatsAWord(intent, session, callback);
    } else if (intentName==='NewGameIntent') {
        newGame(intent, session, callback);
    } else if (intentName==='IChallengeYouIntent') {
        humanChallenge(intent, session, callback);
    } else if (intentName==='MyWordIsIntent') {
        humanMyWordIs(intent, session, callback);
    } else if (intentName==='UndoIntent') {
        undoIntent(intent, session, callback);
    } else if (intentName==='BluffingIntent') {
        humanBluffing(intent, session, callback);
    } else if (intentName==='AddNewWordIntent') {
        addNewWord(intent, session, callback);
    } else if (intentName==='HelpIntent') {
        helpIntent(intent, session, callback);
    } else if (intentName==='DebugIntent') {
        debugIntent(intent, session, callback);
    } else if (intentName === 'AMAZON.HelpIntent') {
        getWelcomeResponse(callback);
    } else if (intentName === 'AMAZON.StopIntent' || intentName === 'AMAZON.CancelIntent') {
        handleSessionEndRequest(callback);
    } else if (intentName === 'AMAZON.FallbackIntent') {
         handleFallbackIntent(intent, session, callback)
    } else {
        console.log('intentName: ' + intentName);
        throw new Error('Invalid intent');
    }
}

/**
 * Called when the user ends the session.
 * Is not called when the skill returns shouldEndSession=true.
 */
function onSessionEnded(sessionEndedRequest, session) {
    console.log(`onSessionEnded requestId=${sessionEndedRequest.requestId}, sessionId=${session.sessionId}`);
    // Add cleanup logic here
}

// Retrieve a web page from the server. Query passed in as a parameter.

function httpGet(query, callback) {

    var options = {
        host: 'ec2-18-224-78-19.us-east-2.compute.amazonaws.com',
        path: '/' + query,
        method: 'GET',
    };
    
    var req = http.request(options, res => {
        res.setEncoding('utf8');
        var responseString = "";
        
        //accept incoming data asynchronously
        res.on('data', chunk => {
            responseString = responseString + chunk;
        });
        
        //return the data when streaming is complete
        res.on('end', () => {
            //console.log('httpGet 02');
            //console.log(responseString);
            //console.log('httpGet 03');
            callback(responseString);
        });

    });
    req.end();
}



// --------------- Main handler -----------------------

// Route the incoming request based on type (LaunchRequest, IntentRequest,
// etc.) The JSON body of the request is provided in the event parameter.
exports.handler = (event, context, callback) => {
    try {
        console.log(`event.session.application.applicationId=${event.session.application.applicationId}`);

        /**
         * Uncomment this if statement and populate with your skill's application ID to
         * prevent someone else from configuring a skill that sends requests to this function.
         */
        /*
        if (event.session.application.applicationId !== 'amzn1.echo-sdk-ams.app.[unique-value-here]') {
             callback('Invalid Application ID');
        }
        */

        if (event.session.new) {
            onSessionStarted({ requestId: event.request.requestId }, event.session);
        }

        if (event.request.type === 'LaunchRequest') {
            onLaunch(event.request,
                event.session,
                (sessionAttributes, speechletResponse) => {
                    callback(null, buildResponse(sessionAttributes, speechletResponse));
                });
        } else if (event.request.type === 'IntentRequest') {
            onIntent(event.request,
                event.session,
                (sessionAttributes, speechletResponse) => {
                    callback(null, buildResponse(sessionAttributes, speechletResponse));
                });
        } else if (event.request.type === 'SessionEndedRequest') {
            onSessionEnded(event.request, event.session);
            callback();
        }
    } catch (err) {
        callback(err);
    }
};


Friday, January 18, 2019

SaaS Essentials: #1 Ability to Scale

In this series I am discussing 10 essential characteristics of good Software-as-a-Service applications. In this first post I cover Essential #1: Ability to Scale, along with some thoughts on how to achieve scale on Microsoft Azure or Amazon Web Services. I'll be discussing specific features and cloud services of both AWS and Azure in this series, as I've implemented SaaS solutions on both platforms.

#1 Ability to Scale

Since SaaS applications are multi-tenant, effortless scale is a must. Just because your solution is deployed to a cloud platform does not automatically make it scalable: you need to utilize scalable cloud services, and you need an architecture that doesn't inhibit scaling. If you have for example a legacy application that you are transitioning into a SaaS product and it can only work on a single web server, you're going to have scale limitations (and availability problems) until you change the architecture to support a web farm.

Once you know your SaaS handles one client well (giving you confirmation you've chosen adequate machine sizes), the next consideration is handling N clients: can you add more clients without affecting reliability or impacting the performance of existing clients? You can achieve this by ensuring you have a solid scalability story for each tier of your solution: web servers, data, services, worker processes, etc.

Scaling the Web Tier

The best way to scale your web servers is horizontally, meaning you have a load-balanced server farm where the number of instances changes as load changes. It's also possible to scale vertically, meaning you increase the size of your web server(s) to handle increased load, but this is ultimately limiting and not recommended.


A web server tier, as deployed to Azure CloudService or AWS Beanstalk / EC2, can be set up to auto-scale based on min/max instance settings and specific criteria for growing/shrinking the instance count. For example, an additional instance could be automatically deployed when VMs are at 60% load or more. You want a minimum of two instances for high-availability, and you want to set the maximum based on budget. With this configuration in place, your web farm will grow or shrink all on its own. This is horizontal scaling.

Scaling the Data Tier

Your data tier also needs to scale, and scaling databases is more complicated than scaling web servers. Data scaling is particularly important if your client data all sits in a single database. You have multiple options to consider. A relational database service like AWS RDS or Azure SQL Database can leverage vertical or horizontal scaling. Vertical scaling means allocating a larger database VM size, increasing capacity. 


Horizontal scaling uses multiple read replicas to gain increased capacity for database reads. Horizontal scaling with read replicas won't help you scaling database writes, however, so you might need to combine both horizontal and vertical scaling.


You can also consider sharding, in which data is partitioned across a collection of databases with the same schema. Azure offers Elastic Database Tools for working with sharding, and on AWS RDS read replicas can be used for sharding. This doesn't mean you should be leveraging all of these options; you want to arrive at a scalable story that is reliable and not over-complicated (I can't stress that enough: a complicated architecture is a sign that you need to further refine things or change your approach.)

Personally, I favor having a separate database per client which reduces the scalability question to database instance size or service level. Doing this avoids the need to do complicated things like sharding and keeps clients' data isolated from each other. It also gives you the freedom to individually control things like database size or region for each client. In fact, clients' databases don't even have to all be on the same cloud platform. Lastly, it simplifies operations like backing up or restoring a single client's data.

Cloud Storage

So far we've been discussing databases, but your data tier may well include basic cloud storage (AWS S3 or Azure Blob / Queue / Table storage). These services are highly scalable out-of-the-box, and you don't generally need to be concerned about putting scalability in place unles your solution spans multiple data centers around the world (which I won't go into here—check your cloud providers' options for replicating storage across regions). If your users are widely distributed geographically, consider leveraging a Content Delivery Network (CDN) for accessing your cloud storage.

NoSQL Databases

Lastly, there are non-relational cloud databases. Newer kinds of NoSQL databases such as Azure Cosmos DB or AWS DynamoDB are built for auto-scale and automatically replicate data across regions. If you develop (or update) your solution to leverage these databases, in full or in part, you can take advantage of this automatic scale.

Scaling the Services Tier

You can scale services in several ways, depending on how separate your services are. If your services are provided by your web servers, they're part of the web tier and you've already addressed matters. If you have a separate services tier, you can choose between hosting them similary to your web servers (Azure Cloud Service or AWS Beanstalk / EC2) or you can consider server-less computing which has become very popular.

Formally structuring your services used to be the domain of Service-Oriented Architecture (SOA), but today there is the newer Microservices Architecture to consider. With microservices your business services are small components that have the characteristics of being single-purpose, testable, separately deployable, and loosely-coupled (independent). You can read about the differences here. Serverless computing is particularly well-suited to microservices, because functions (services) can be easily deployed individually.

Serverless computing, provided by services like AWS Lambda or Azure Functions, allows you to deploy just your function code without worrying about scale: there are no instances to allocate, the scaling is automatic, and you are only charged when your functions are invoked (in other words, pay-for-use). A function you deploy can be hooked to an API Gateway (AWS) or HTTP trigger (Azure) and voila, it's a service—one that will auto-scale. You can also connect functions to other triggering events besides HTTP traffic. If you use the microservices approach, be sure to address security fully across your microservices tier.


Scaling a Worker Farm

If you have a "worker" compute instance whose job is to service tasks from a queue, that lends itself to a farm approach the same way used for web servers: instead of a load balancer distributing traffic to N web server instances, a common queue distributes traffic to N worker instances. If your cloud platform doesn't have a way to automatically scale a worker farm, you'll need to monitor appropriate and manually increase or decrease the number of instances.


Other Components

If there are other parts of your solution, you'll need to figure out their scalabity story as well. Some elements of your solution may not require scale. For example, you might have a single-instance compute instance such as a nightly background service that perhaps isn't affected by the number of clients.

If you use a distributed memory cache such as Redis, you'll want to monitor the amount of cache being used and adjust it larger or smaller to stay in line. I don't think there's currently an automated way to do this, so it's up to you to monitor the level of usage.

Finishing Touches

Test ScalabilityOr Your Customers Will!

Once you have your software tiers scaling well, you'll want to test that it's all working by experimenting with different loads and verifying the cloud platform has increased or decreased instances as expected. Failure to do this means your customers will be doing the testing.

Put Monitoring In Place

You'll need to monitor the load on your SaaS and keep watch over it. For scaling activites that require you to manually make configuration changes in the cloud portal, you'll only know to do it if you're monitoring activity and load. Even for cloud services that provide automatic scaling, you'll want to be regularly verifying that the automatic behavors you're expecting are actually happening.

Get familiar with the metrics available in your cloud management portal. Also consider tracking your own metrics, such as number of business transactions per day / hour.

In Conclusion

Scale: it's a first-class consideration for any SaaS. Scaling up is necessary to accomodate growth, and scaling down is just as important to keep your costs down. Take advantage of the scaling features in your cloud platform. Cloud platforms provide many tools for scaling, but you have to be aware of them and put them to useand they aren't all automatic. It's important to consider the scaling story for every tier of your solution. Thankfully, newer cloud services now often provide automatic scalability. The more of that, the better.

Next: 10 SaaS Essentials: #2 High Availability

Monday, January 14, 2019

10 Years In The Cloud: A Retrospective

I am celebrating 10 years of cloud computing work. This post looks back on a decade of cloud activity and where it has led.


2008-2009: Cloud Computing, the New Thing 

In late 2008, working at Microsoft Partner Neudesic, our CTO Tim Marshall and I were invited to a Microsoft feedback session in Redmond about "Project Red Dog". Red Dog, it turns out, was about this new thing called Cloud Computing. Amazon had been doing this for a few years, and Microsoft was going to also enter the market. This "cloud computing" was a new idea and a new way of doing things—but it sounded exciting. A few months later, "Windows Azure" was released. As Neudesic is a consulting company, we started learning it and looking for early prospects.

When Microsoft introduces a new product or service, a lot of work goes into evangelism and education and finding early adopters. As a Microsoft partner, we did a lot of joint work with Microsoft: visits to prospects, proof-of-concept projects, training sessions, code camps.

Tim had his own ideas about developing the market, and one of those was starting Azure user groups in the ten or so locations we had across the United States. I and other colleagues (including Mickey Williams and Chris Rolon) started sponsoring monthly meetings, sometimes held at Microsoft field locations. Since this was all new, meeting attendance could just as easily be 5 or 20 or 50 people, depending. But we kept at it, and we got the word out there, and interest started growing. At meetings we would cover new cloud services that had just become available, or show off things we had built, or discussed useful patterns for applications. It was fun, and there was pizza.

We learned things about the cloud: the infrastructure was really advanced, but the individual hardware components could fail: you had to plan for redundancy and recovery. The economics of the cloud were different: you had to consider lifetime of the data and resources you allocated, else you would "leave the faucet running". Almost everyone who was an early adopter had an Unexpectedly Large Cloud Bill story. Developers giggled with pleasure at the ease of self-deployment; but sometimes you'd hear a horror tale where someone lost important data all because they weren't careful enough when clicking in the management portal. We started reinforcing the importance of separating Production accounts from Development accounts.

2010-2014 : Azure Evangelism and Early Adopters

As Windows Azure was evangelized, prospects started to line up. I participated in a great deal of proof-of-concept project work, sometimes arranged by and paid for by Microsoft. One that stands out was going to Coca Cola headquarters in Atlanta to show how readily existing web sites could be migrated to Windows Azure. The first web site we migrated was in ASP.NET/SQL Server, which was a slam-dunk and just took a handful of days. The second site used Java Server Pages and Oracle—definitely not in my wheelhouse—but in two weeks' time we had migrated it as well.

I wrote The Windows Azure Handbook in 2010, which I believe was the first book out for Azure. The book contained Microsoft messaging from the time: Platform-as-a-Service (PaaS) is better than Infrastructure-as-a-Service (IaaS) and so on. Today Azure is equally well-suited for PaaS and IaaS and the message has changed. We've learned that there are those who value the cloud for innovative new ways of doing things (the PaaS people); but also those who value the ability to leverage existing skills and don't want their world rocked (the IaaS people).


I also released through Neudesic an Azure ROI calculator, long before there was a comprehensive one available from Microsoft. You can see from this screenshot how few cloud services there were in those early years. The number of cloud services available today is vast and ever-expanding.


There were real cloud projects happening too by this time. At first, there had been a lot of interest but prospects seemed hesitant to actually take the plunge. There was for example a great fear of vendor lock-in. Eventually, and with increasing rapidity, adoption started happening. The vast majority of these projects were web site & database migration for established companies; but start-ups had a different mentality, they wanted to do everything in the cloud from Day L.

As head of the Custom App Dev practice at Neudesic, I made sure we had Azure-trained consultants in every region. As new cloud services appeared, this interested our other practices. SQL Azure database and (later on) Power BI interested the SQL / Business Intelligence practice. Service Bus interested the Connected Systems practice.


Badges Awarded to Consulants Who Completed Cloud Training

Microsoft started a Windows Azure category of their Most Valuable Professional program, and I was honored to be a Microsoft MVP from 2010-2014. I met some great MVPs on my visits to Microsoft (and hired one, Michael Collier), along with the Windows Azure product team.

Although activity was intense, Windows Azure wasn't perfect. For three years in a row, Azure went down during the annual MVP summit, usually for reasons like somone having forgotten to renew a security certificate. We MVPs were initially amused, but in later years it meant customers were affected. AWS also seemed to have a hiccup as well once or twice a year. We started educating customers about what dependency on a cloud platform meant for reliability, and fallback plans for when the a region or entire platform was unavailable. Both platforms have improved in reliability since then.

In 2011 Microsoft asked me to teach Azure training sessions in Amsterdam and Germany. This was a fun trip—except for the blistering winter snowstorm—and I met some MVPs including Kris van der Mast and Christian Weyer. This helped me realize that cloud computing was a worldwide phenomenon, and also that different regions had different problems to address: in Europe, for example, there were laws about where clients' data had to be stored, and that didn't always align well with existing data centers.

My Azure class in Munich, Germany

As the years went by, Azure added more and more services and would occasionally drop support for a service (never popular). New data centers were continually added around the world.

Azure Storage Explorer

I created a free storage tool named Azure Storage Explorer and placed it on CodePlex, which turned out to be a hit. Over the next few years, Azure Storage Explorer had over 280,000 downloads! I would do a handful of updates a year to ASE, usually because Microsoft had added a new feature or because the Storage API had changed.


Eventually, there was one breaking API change too many and I stopped maintaining it--but made the source available on CodePlex. A second reason for not working on it is simply how busy I was on cloud projects.

A few years later, Microsoft finally came out with their own tool, with nearly the same name: Microsoft Azure Storage Explorer. You can also now manage storage through the Azure Portal. It's about time!

Recently I've had some thoughts about creating some new, updated cloud tools. See the end of this post for more.

2015-2019: The Maturing Cloud Becomes Essential

Cloud has exploded and is no longer something reserved for brazen early adopters or just a few specialists. At Neudesic, we consult widely on multiple cloud platforms: Microsoft Azure, Amazon Web Services, and now Google Cloud Platform.

New cloud services continue to arrive. There are services for Mobile and APIs and Non-Relational Databases and Distributed Memory Cache and Machine Learning. We now have Serverless Computing (AWS Lambda or Azure Functions), where you don't even have to allocate a server: just upload your function code and the platform takes it from there.

Names were changed. Windows Azure became Microsoft Azure, so the branding wouldn't be focused on one operating system. SQL Azure became SQL Database. Azure Web Sites became Azure App Services. Even Visual Studio Team Services / TFS Online was rebranded as Azure DevOps.

Software-as-a-Service (SaaS)

About 4 years ago I joined a product team to work on creating a Software-as-a-Service offering out of a legacy HR product named HRadvocate. It was a major amount of work to update the architecture and user interface, but eventually we had something deployed to Windows Azure with a reliable SaaS architecture that kept clients' data isolated from each other in separate databases.

SaaS Architecture on Azure

Authentication was initially through Azure Active Directory, with the idea that enterpises could use Microsoft's ADConnect to link their enterprise AD to AAD. It turned out that clients were demanding Active Directory Federation Services (ADFS) integration, so we added support for that. Later we added SAML support so products like PingFederate can be used to authenticate. Now our SaaS product could authenticate each client differently.

An Azure customer required a hybrid architecture, where Azure-hosted HRadvocate needed to integrate with multiple other systems--all of which were local to the enterprise. These systems connected to the former HR system via database integration, a structure that had to be maintained. To fit into this arrangement, I developed SQL Connector, a set of SQL Server functions written in C# that allow enterprise databases to query data in the cloud. This allowed the cloud data to be synced locally. Now, the local systems could continue to use their existing database integration, even though our SaaS was now part of the mix.

Amazon Web Services

I'd obviously been very focused on Microsoft Azure up until now, but that was about to change. Client requirements for HRadvocate led to a decision that we had to be able to run on Amazon Web Services as well as Azure. This led to several years of work on AWS and I am now proficient in it. Getting our solution to work on both Azure and AWS—while keeping a common source code base—was a lot of work but was also very educational. Azure's Cloud Service, SQL Database, Blob Storage, and Redis Cache mapped in a straightforward way to AWS's Elastic Beanstalk/EC2, RDS SQL Server, S3, and ElastiCache. About the only thing we couldn't transition was Azure Active Directory, but that's fine since we offer multiple ways of authentication.

SaaS Architecture on AWS

We also targeted Amazon's Commercial Cloud Services (C2S). To support this we added to the product the ability to run air-gapped (without Internet); this required locating and replacing any code (including from open source libraries) that was taking availability of the web for granted. Chart libraries like Google Charts had to replaced with Highcharts which could be local to the application. We added support for the FIPS 140-2 standard, using only algorithms and code for encryption that been certified to be compliant.

During this time, we continued supporting our product on Azure as well. Being able to run on two cloud platforms provided a lot of insight about what is the same and what is different between leading cloud platforms. There certainly seems to be a lot of copying going on between mainstream cloud platforms: when one provider comes out with a useful cloud service, it's not long before the competition has a very similar service. For example, Amazon has AWS Lambda for serverless-computing while Azure has Azure Functions. For those still worried about vendor lock-in, this keeping-up-with-the-Joneses activity should be comforting. The principles for building a good solution in the cloud transcend any one platform.

The Cloud in 2019

Ten years have gone by, and Cloud has certainly come into the mainstream. Just about all of us now use cloud computing every day, whether we realize it or not. Doing a web search? Streaming a movie? Using a social network? Making an online purchase? Cloud computing is an integral part of that.

Ten years ago, some big tech companies had cloud infrastructure but no one was providing cloud computing services to the public except Amazon. Now, there are clouds by Microsoft, Google, IBM, Oracle, SalesForce, SAP, VMWare, ...the list goes on and on. As for Microsoft, Azure is now also a leading cloud platform: it does PaaS and IaaS; half its VMs are reportedly running Linux; and there are a whopping 54 data centers worldwide. The growth has been phenomenal.

Cloud computing is no longer considered a speculative idea or a novelty for organizations: now, it's a common assumption that you'll be leveraging a cloud in anything new you develop. Ten years ago there was a lot of indecision about whether to go cloud or not; today, going to the cloud is a given, and the discussion is about which platform and which services to use. It's no longer a discussion of IaaS vs. PaaS; the debate now is about whether to leverage the newer cloud native architectures, NoSQL databasesserverless functions, and microservices vs. more traditional architectures. Serverless in particular is a major phenomenon that has opened cloud development to a broader number of people.

Some of my Neudesic colleagues from the early days have gone on to work at Microsoft or Amazon.

Cloud platforms seem to have improved uptime from 10 years ago, but there are still those moments when something goes wrong and a substantial number of clients are affected. You can still be in for a long wait when a cloud platform is recovering from an issue and each customer account has to be restored.

It's been a really interesting decade of cloud work, and there is plenty more to come. The do-it-yourself nature of the cloud is inherently satisfying, as is being able to change your mind and alter your deployment as will. Services that handle the details and let you focus on your application are a joy to use. You still need to know what you're doing architecturally and keep the cloud's different economic model in mind, but things like auto-scale and recovery are increasingly included in new cloud services. New services like Machine Learning are opening up new vistas for developers, and there's never been a more fun time to experiment—for just pennies.


Sunday, January 6, 2019

Consultant Tips for Air Travel, Revisited

Back in 2012 I posted a series on How to be a Consultant, which included a segment on Air Travel. After several years of not having to travel at all, I resumed a grueling travel schedule in 2018. So, here's an updated review of what to expect in US air travel and some tips for making the best of it.

To set some context, my travel involved flying from Southern California to Dulles Airport in Washington DC and back, for a week at a time. I took approximately 46 air flights in 2018.

I knew when this started I was in for an adjustment: as bad as my memories of air travel were, several years had gone by and it was surely even worse now.

Tip #1: Choose Your Airline Carefully

All of my flights were on United Airlines, which brings me to Tip #1: choose your airline carefully.

While air travel is far less comfortable than it used to be across the board, that doesn't mean all of the airlines are exactly the same. For example, Southwest Airlines won't charge you for checked bags, even though nearly every other airline does. True, when one airline gets yet another nasty idea--like charging you for bags, or charging more for the better seats--most of the other airlines start doing the same thing. It's not 100% uniform, though, and some airlines have been known to back off from some of their more evil tactics when there is enough passenger backlash.

Now I know as I write this that you may have little to no choice in which airline you fly on: for some flights, one carrier is simpy dominant. That was the case for me, United Airlines was clearly the airline I would be using for my particular route. Still, if you're flying out of or into major airports you may find you do have a choice, and in those cases you should do some careful thinking about which airline to use. This is the age of Internet reviews, after all, so there is a great deal of online information to be found about airline experiences and rankings. You can even find reviews of particular seats on particular aircraft.

Tip #2: Leverage Airline Loyalty Programs

Now that you've determined the airline you'll be using, it's time to get the most out of them, which brings me to Tip #2: leverage airline loyalty programs. The constant reduction in service and new fees imposed by airlines is all about keeping their fares low so that you choose them when searching for a flight. Because of this environment, airlines tremendously value passenger loyalty, and they reward frequent flyers. Sign up for your airline's loyalty program, and be sure to specify your loyalty ID whenever you book a flight. You'll start accruing air miles, which will start paying off in benefits.

What kind of benefits can you expect? The specific benefits you get and what you have to do to qualify for them varies from one airline to another but can also be found online (here's United's MileagePlus).

In my earlier period of travel, I flew American Airlines, accrued air miles, and earned some status--but that was all ancient history now, and I was starting fresh with United.

Here's what I initially experienced, starting in early 2018 and having never flow United before. I was persona non grata:

  • When boarding, there were 5 boarding groups. I was almost always assigned Boarding Group 5, which meant I was one of the last few passengers on the plane.
  • Checking bags cost $30 for the first bag and $40 for the second

...and here's what things were like near the end of 2018, after I had been on 40+ United flights:

  • I had been awarded Premium Gold status
  • I was in Boarding Group 1 every time, first on board (well, first after some special groups like elite status passengers, military servicemen, and passengers with infants).
  • My checked bags were free
  • Special offers were extended to me when I made reservations
  • I was automatically added to upgrade lists in case there was a business class or first class seat available

That's quite a difference. The airlines may be mercilessly charging more and taking away comforts, but it feels amazing to get some special treatment and recognition for all that travel you're doing.

I should mention that another ingredient was using United's credit card, which accelerated my benefits. That's covered in the next tip.

Tip #3: Use the Airlines' Credit Card to Buy Your Tickets

Early on in my year of travel, I noticed that every United flight included an unwelcome push to sign up for their MileagePlus Explorer Credit Card. As much as I disklike aggressive sales to a captive audience, I had to admit the benefits sounded good given how frequently I was traveling. In the case of United's card, this included 50,000 air miles, Boarding Group 2, a free checked bag, and 2 day passes to the United Club lounge.


These benefits were real, and represented a way to "buy" my way into higher status simply by using the airline's credit card to reserve my flights. To be sure, these cards don't have a very good interest rate, but that didn't concern me in the least, since I expensed my flights promptly and always paid my bills in full.

After signing up for my card, I went from Boarding Group 5 to Boarding Group 2 on my very next flight and one of my checked bags was now free. I had jumpstarted my loyalty program!

After several months, the 50,000 miles were applied to my account--not to mention the miles I was getting for taking all those flights. I used these miles over the last year to buy quite a few flights for my daughter in Kentucky to come home to visit us in California in the summer and over the holidays.

As the loyalty program did it's thing, successive flights moved me to Silver and then Gold status. Now 2 checked bags were free, and I was in Boarding Group 1.

The United Club passes were also great (see Tip #4: Utilize Airline Lounges).

Note that the specific benefits change often with airline credit cards, so if you're planning to use a specific card, check what's currently being offered.

Tip #4: Utilize Airline Lounges

In my earlier years as a traveling consultant, American Airlines had the Admiral's Club. I saw signs for this in the airport but didn't know what it was. One time, when I was traveling with an executive, I was brought into the lounge as a guest--and what an eye opening experience! Really comfortable chairs. Work tables with outlets. Internet. Free drinks. Snacks. Newspapers. Magazines. Most of all, pleasant and safe surroundings. Quite the difference from sitting at the gate.

In the original version of these airport lounges, frequent flyers with the means would pay hundreds of dollars for an annual membership. In these leaner times, fewer passengers are able or willing to do that, so the lounges also offer day passes. for example, at United's United Club lounge, I can buy a Day Pass for $59.


Tip #4 is to utilize airline lounges when warranted. You might not think you spend enough time in an airport to warrant the cost of an airline lounge, but there are times when you will: that delayed flight; that cancelled flight that strands you in the airport overnight; that bad weather that causes mass cancellations and disrupts the airline schedules across the board, packing the gates with too many people. These are the unpleasant times when you may spend quite a few extra hours in the airport. In times like that, I don't hesitate to buy a day pass for the nearest airline lounge.

I had received two United Club day passes with my United credit card. On one particularly bad trip where I had to spend a rough night in Denver International Airport, I used a club pass once the lounge opened at 5 am to get into a better place where I could clean-up, enjoy the free refreshments, and nap until my mid-day flight in peace and safety. I used my second pass on what I knew would be my final flight, deliberately getting to the airport early to enjoy a few hours of comfort before boarding.

Tip #5: Use Direct Flights

For most of my travel history, I've had connections on my flights--but this year I came to change my mind and now insist on direct flights. In my case, a direct flight would mean driving all the way to Los Angeles International (unthinkable, could be 3 hours on California freeways) or San Diego (90 miles away). So, I would drive the 40-50 miles to a nearby airport and settle for a connecting flight, usually in Denver.

At first I didn't think connecting flights were all that bad. After all, it gave you a chance to use the restroom and perhaps buy a meal or a snack (I'm a diabetic, so keeping my energy up is important). But I learned through sad experience that connecting flights can be really, really problematic.

The first issue with connecting flights is how little time you may have between flights. Since the airline system is really busy, it's not unusual for flights to be delayed along with the ripple effect that can have on the system. If your connection has a really short layover time, such as less than an hour, you are really taking your chances. I found frequent delays in my connections at DIA, and sometimes missed the last flight of the day back home. That meant going to customer service, trying to find a different flight to a different airport, and arranging for my wife to meet me. That could also mean your checked bags are on their way to the original airport. A missed connection is simply a mess.

Missing a connection, as bad as it can be, is nothing compared to what a systematic airline system failure can mean to a connection. On one of my flights home, which had a connection in Denver, there were a lot of thunderstorms wreaking havoc over Colorado. About half an hour before we were supposed to land at DIA, our pilot announced what was happening with the weather and that DIA was temporarily no longer accepting planes to land. He announced that we would be diverting to Colorado Springs, where we would wait until DIA re-opened and then fly back there. Well, okay, what can you do. Clearly I wouldn't be making my connecting flight. A few mnutes later, another announcement: Colorado Springs was now overwhelmed with planes and no longer accepting planes to land. Oh, and we were extremely low on fuel. The new plan was to land at a nearby airport named Grand Junction that none of us had ever heard of--possibly the smallest airport I have ever been to. An airport so small, there wasn't even a jetway to deplane passengers from a plane our size.


The captain did buy pizza for the entire 170 passengers while we were waiting at Grand Junction, which was a nice gesture. We did eventually get back to Denver hours later, where a line to United Customer Service literally stretched the entire length of Terminal B. There was no flight out. While I did receive a hotel voucher from the airline, I was also assured that there weren't any hotel rooms available. That meant spending the night in the airport. I tried to make a "bed" out of several chairs near a cafe. It was an extremely uncomfortable evening, trying to sleep this way in the brightly lit airport that remained noisy at all hours. Finally at 5 am the United Club opened and soon after some airport stores opened. I quickly purchased shaving items and a hairbrush, made myself more presentable, and headed to the lounge where the wait for my mid-day replacement flight was much more comfortable.




It took me 27 hours all-told to get home. It is for the above reasons that I decided to avoid connecting flights, and I have ever since. My wife has graciously driven the 90 miles to San Diego International to drop me off and pick me up where I can get a direct flight to Dulles airport. Direct flights are a far less worrisome way to fly.

Tip #6: Buy Premium Economy Seating

If you're traveling on business, you're no doubt subject to an expense policy. More likely than not, you're required to fly in Economy class in the main cabin. Today, however, there are multiple leves of Economy: your airline may offer a "Premium Economy" that offers a far better experience. In United's case "Economy Plus" is the name given to a subset of the main cabin with seats that have extra legroom, are closer to the front of the plane, and include power outlets. If it's permitted by your expense policy, these are the seats you want to be sitting in.

The airlines keep finding ways to stuff more seats on planes. That means we have been steadily losing seat width and leg room for years. Airlines may wedge you in tighter and tighter from side-to-side, but at least the extra legroom in a Premium Economy seat allows you to fully extend your legs. It also makes it easier to get in and out of your seat, possibly without having to ask other passengers to get up.

Power in the seat means you can charge your phone or tablet. However, set your expectations accordingly. In a row of 3 seats, there will only be 2 power outlets, so there's no guarantee you will have access to one. Also, there's a big difference between Boeing and Airbus planes: on Boeing planes, the outlets are between the seats, somewhere underneath where you're sitting. Your chances of actually seeing the outlets are zero, so you are left trying to plug your power cord into an outlet you can't even see--awkward and frustrating at best. Airbus, on the other hand, puts their outlets betweeen the seat backs in front of you at a height you can easily reach, which makes a world of difference. It's actually possible to use the outlets on an Airbus.

I was hoping in-seat power would also let me plug in a laptop, but I have yet to see 3-prong grounded outlets on planes. Still, you can work on a laptop off battery pretty effectively in a Premium Economy seat. In regular economy, you might not have enough room to fully open the laptop.

If Premium Economy seating isn't an option for you, consider an Exit Row which will also give you more legroom.

Under no circumstances should you consider a sub-Economy class such as "Basic Economy" which (depending on the airline) may not even allow carry-on bags.

Tip #7: Choose Your Seat Carefully

After so much travel, I became an expert seat-selector. My critieria:

  • The seat needed to be Economy Plus or Exit Row. I wanted/needed the extra legroom, and the in-seat power would be valuable.
  • The seat should ideally be near the front of the plane, so I wouldn't have to wait as long to get off. This was particularly crucial if the flight was the first leg of a connection. 
  • Never book the very first row of the main cabin, because there are no seatback pockets or storage in front of you.
  • Consider proximity to the restrooms. They might be near the front or near the back, depending on the model plane. 
  • Unless very familiar with the location, check online seat reviews to discover particularly good or bad seats. Although I never recline my airline seat, those who do would probably care if the seat is able to recline or not.

And then, there's the question of which seat to select:

  • Window seat: The thrill of looking out airplane windows died for me some 25 years ago, but the window seat is valuable for another reason: you only have one human being pressed up against you. The window side provides a place of refuge. On the other hand, it's more work to get out of your seat. If you're hesitant to bother the people between you and the aisle to get up and you have to go, you may have some uncomfortable waiting time ahad of you. If you're using armrest controls for viewing movies, be forewarned that the passenger in the middle seat may (will) inadvertently block your access and even accidentally change your volume or channel.
  • Middle seat: This is of course where you don't want to be, with people uncomfortably pressed up against you on both sides. Avoid at all costs.
  • Aisle seat: Like the window seat, you only have one person pressed up against you--but you have flight attendants and passengers on the way to/from the restrooms constantly bumping you. And, depending on model plane, you may have less than half the under-seat storage room that middle and window have. On the plus side, if you're using armrest controls for watching movies you probably won't have another passenger's elbow in the way. You also have unfettered access to get to the restroom.

So which is better, window or aisle? That's a tough call. When I started flying in 2018 I always opted for the window seat, but by the time my year of flying ended I had switched to the aisle seat.

Despite the above criteria, seat selection is sometimes very limited. I've been forced to book the dreaded middle seat on a handful of flights simply because nothing else was available. Even when this happens, don't lose hope. When checking in for your flight, take another look at the seat map and change your seat selection if something has opened up. This has even happened for me at the very last minute, when checking in at the airport kiosk.

Tip #8: Choose Your Plane Carefully

When you reserve your flight, the specific kind of plane is usually mentioned--and you should think about that just as carefully as you do the fare price or arrival time. Some planes are simply more uncomfortable than others.

I already mentioned the poor arrangement of in-seat power on Boeing planes earlier under Tip #6. If using the power outlets is not important, then I don't find much reason to differentiate between Airbus and Boeing planes. A lot of the comfort factors such as legroom / row-spacing is airline-dictated.

Figure out what is most important to you about plane model, and then check online flight reviews to determine what will make you most comfortable.

On Boeing planes, the model I would avoid whenever possible is the 757. Although I've never been able to find anything online to substantiate that seat width is narrower on 757s, every time I fly these planes the seats are noticably narrow and uncomfortable.

Tip #9: Plan Your Entertainment

You're going to spend hours and hours on an airplane: what will you do to pass the time? One option is to watch in-flight movies or TV. Airlines offer quite a few choices for this now, which could involve a screen on the seat in front of you, or streaming to your phone or other device. Just what options are available will vary by airline and model plane.

On United, for instance, you can watch TV and movies free using your phone, tablet, or laptop. No charge is nice, but will it actually work? I found in actual experience that neither my phone or laptop browser would work, apparently because of app version, browser version, or missing plug-ins. Despite attempting to get things configured in advance of my flight, I never did get streaming to a device of mine working--but others on the flight clearly did.

What I did end up using quite a bit on United, when available, was DirectTV via the seatback screen, with armrest controls (however, see my warning about being able to access the armrest control under Tip #7). This included live DirectTV, but also 8 or so featured movies on dedicated channels. This I found really was the best way to pass 5-6 hours of flying and helped the hours speed by.




When I didn't have personal entertainment, I would read or nap. Reading I would do on my phone via Amazon Kindle.

If you're going to use in-flight video entertainment, be sure to invest in some ear-buds.

Tip #10: Make a Conscientious Choice About Bags

I've heard it a million times from business travelers: don't check your bags, so you don't have to wait endlessly for them at Baggage Claim. This used to be a slam-dunk decision, but now it's not so clear-cut.

There simply isn't as much overhead space for carry-ons as there used to be on planes. Indeed, some of the lower-levels of economy don't even allow you to use the overhead bins. As the airline industry has stuffed flights fuller and fuller, the war for bags and the ensuing rage have only gotten worse. It's typical while waiting for boarding to start to hear an appeal asking for volunteers to check your carry-ons because there won't be enough room for everyone's bags. If this does happen, you'll pick up your bag at Baggage Claim but there won't be a charge.

Personally, I decided this madness simply isn't worth it: I always check my bags, and my status means no baggage fees. Yes, it means waiting at the carousel which is extra time. But you might well end up there anyway given how insufficent space there is on planes nowadays. Now, I just relax in my seat while the other passengers and flight attendants war over space in the overhead bins. I do carry on a laptop, which fits under the seat in front of me.

If you are going to carry on your bags, then ensure you are in a low boarding group (see tips #1-3) and choose a bag size that will fit comfortably in the overhead space. Or better yet, something that will fit under your seat.

If you are checking a bag, make sure your bag looks unique. You'll often see signs at baggage claim warning you that many bags look alike, and it's true. After one flight, I waited for my black wardrobe bag but it never showed up. Instead, someone else's black wardrobe bag was left on the carousel. Clearly, someone had mistakenly made off with my bag. I brought the other bag and my bag claim check to the airline baggage office, and they were able to call the other party and get them to come back to the airport to exchange bags. But, you won't have to worry about this if your bag is unique-enough looking to stand out.

Tip #11: Buy Bundles to Simpify Expenses

As previously mentioned, airlines keep adding new kinds of fees. Fees for bags. Fees for seats. If you're purchasing tickets that you will be expensing, this can cause wrinkles. For me, it was a lot easier getting approval and reimbursement of my air travel costs if I had a single charge rather than multiple on my credit card. When making reservations, I took advantage of bundles.

A bundle is an offer you can take when booking your flight that combines those extra fees for a particular cabin class or bag check fees. Using a bundle not only simplifies your expensing, it also prevents your accounting department from over-scrutinizing multiple individual charges.

Tip #12: Get TSA Pre✓

TSA Pre✓ is like a fast lane for air travel. It's a faster and easier path through airport security.

What TSA Pre✓ does for you is let you go through security on a different security line that is a world of difference from the regular security line. What do you usually encounter at the airport security line? Grim-faced security officers who order you around. Take off your belt. Take off your shoes. Remove your electronics. Reassemble yourself on the other side. It's a hassle and it's a pain. TSA Pre✓ is a parallel universe where the TSA actually likes you and treats you like a well-known friend. You're greeted with a friendly smile, and you don't need to remove anything.

TSA Pre✓ costs $85 a year at the time of this writing and is well worth it. If you're traveling internationally, go for the slightly more expensive Global Entry program. Signing up will require an online submission, then visiting an office at a nearby airport to get interviewed and fingerprinted. Then you'll wait for a backgound check, approval, and assignment of a Known Traveler Number. Once you have your KTN, make sure you specify it when making reservations. The TSA Pre✓ logo will appear on your boarding pass and you'll be able to go through the fast lane.

Tip #13: Stay Positive

All sorts of things can go wrong in air travel, and even under the best circumstances it's rarely comfortable. A huge factor in whether a flight is pleasant or unpleasant is the attitude of the passengers and crew. You can't control other peoples' attititudes, but you can maintain a positive attitide yourself--and that will influence others. Be the positive person on your flight who overlooks the shortcomings of others and helps make the experience better.