Matt Kruse
Published © GPL3+

Speed Tap

Speed Tap is a game of fast reactions and concentration for Amazon Alexa, using Echo Buttons, Lambda, and other AWS Services.

AdvancedShowcase (no instructions)Over 1 day1,088

Things used in this project

Story

Read more

Schematics

Architecture

This is an architecture diagram of the AWS services used and how they are connected.

Code

Skill Code

JavaScript
This is the core source code for the skill. It doesn't include dependencies or publishing information. The source code here is the full code for the skill itself.
// Alexa Speed Tap
// Copyright 2018 Matt Kruse
//
// This code is in development and not representative of all best practices!
//
const alexa = require("alexa-app");
const https = require('https');
const wrapper = require('./aws-dynamo-wrapper.js');
const AWS = require('aws-sdk');

const outputSynonyms = {
  "Okay, ": ["Okay, ","Alright, ",""]
};
const content = {
  quick_intro: "Your echo button will cycle through colors, and you press it when it's green. As you play, the speed will increase, and it will get much more difficult. I will remember your personal high score, and I also keep track of the world record for all players. Can you get to the top of the leaderboard? Let's see. Press your echo button to start."
  ,help: "The game is simple. Just press the button when it's green, and keep doing that as the speed increases. Try to get as many in a row as you can, to beat your own personal record, or even the world record."
  ,help_web_site: "Go to alexa speed tap dot com in your web browser to check out the leaderboard and see how far you are from the world record. I've sent a card about it to your alexa app."
};
const speedtap = '<prosody pitch="+15%" rate="110%">speed</prosody> <prosody rate="130%" pitch="-30%">tap</prosody>';

// Extend Request/Reponse
// ----------------------
alexa.request.prototype = {
  getState:function() {
    if (this.hasSession()) {
      return this.getSession().get("state");
    }
    return null;
  }
  ,setState:function(state) {
    if (this.hasSession()) {
      this.getSession().set("last_state",this.getSession().get("state"));
      this.getSession().set("state",state);
    }
    return this;
  }
  ,pushState:function(state) {
    if (this.hasSession()) {
      var cstate = this.getState() || "";
      this.getSession().set("last_state",cstate);
      if (cstate) { cstate+='~'; }
      this.getSession().set("state", cstate+state);
    }
    return this;
  }
  ,popState:function(state) {
    if (this.hasSession()) {
      var cstate = this.getState() || "";
      if (state) {
        // Pop up to given state if it exists
        var states = cstate.split('~');
        while (states && states.length && states[states.length-1]!==state) {
          states.pop();
        }
        if (states.length===0) {
          throw "Couldn't find parent state in popState("+state+")";
        }
        this.getSession().set("state", states.join('~'));
      }
      else {
        cstate = cstate.replace(/~[^~]+$/, '');
        this.getSession().set("state", cstate);
      }
    }
    return this;
  }
  ,clearState:function() {
    if (this.hasSession()) {
      this.getSession().set("last_state",this.getSession().get("state"));
      this.getSession().set("state", null);
    }
    return this;
  }
  ,restorePreviousState:function() {
    if (this.hasSession()) {
      var prev = this.getSession().get("last_state");
      this.getSession().set("state", prev);
    }
    return this;
  }
  // Redirect to a specific intent
  ,setIntent: function(intent) {
    this.data.request.type = "IntentRequest";
    this.data.request.intent = {};
    this.data.request.intent.name = intent;
    this.setState(intent);
  }
  // Interact with the user's experience
  ,experience:function(exp,bump) {
    if (typeof bump!=="boolean") { bump=true; }
    var u = user;
    if (!u) { return; }
    if (!u.experience) {
      u.experience={};
    }
    var x = u.experience[exp];
    if (bump) {
      if (typeof x==="boolean" || typeof x==="undefined") {
        u.experience[exp] = false;
      }
      else if (typeof x==="number") {
        u.experience[exp]++;
      }
    }
    return x;
  }
};
alexa.response.prototype = {
  "polly": function(voice) {
    try {
      var ssml = this.response.response.outputSpeech.ssml;
      this.clear();
      this.say(`<voice name="${voice}">${ssml}</voice>`);
    } catch(e) { }
  }
  ,"noprompt": function() {
    this.prompted = true;
    return this;
  }
  ,"prompt": function() {
    var ends_in_question = false;
    try {
      // If the output speech ends in a question mark, don't prompt
      var t = this.response.response.outputSpeech.ssml;
      t = t.replace(/\<[^\>]+\>/g,'');
      if (/\?\s*$/.test(t)) {
        ends_in_question = true;
      }
    }
    catch(e) { }
    if (!this.prompted && !ends_in_question) {
      this.sayRandom([
        ". What do you want to do? ",
        ". What next? ",
        ". What now? ",
        ". What would you like to do? "
      ]);
      this.prompted = true;
    }
    return this;
  }
  ,"sayRandom": function(values) {
    this.say(values[Math.floor(Math.random() * values.length)]);
  }
  ,"randomizeSynonyms": function(synonyms) {
    try {
      let ssml = this.response.response.outputSpeech.ssml;
      ssml = ssml.replace(/\{([^\}]+)\}/g, function (m, m1) {
        if (synonyms && synonyms[m1]) {
          let s = synonyms[m1];
          if (s.length) {
            // simple array of synonyms
            return s[Math.floor(Math.random() * s.length)];
          }
        }
        return m1;
      });
      this.response.response.outputSpeech.ssml = ssml;
    } catch(e) { }
  }
};

var app = new alexa.app();

// For ASK CLI
app.invocationName = "speed tap";

// Config
app.user_persistence_table = "speed-tap-users";
app.game_table = "speed-tap";

// Convenience
app.simpleintent = function(intent, utterances, say) {
  utterances = utterances || [];
  if (typeof utterances=="string") { utterances = [utterances]; }
  app.intent(intent,{"utterances":utterances},
    async function(request,response) {
      var template = eval('`'+say+'`');
      response.say(template);
    }
  );
};

// Remap the askcli() output to ignore intentMaps
app.schemas.askcli = function(invocationName) {
  var model = JSON.parse(app.schemas.skillBuilder());
  model.invocationName = invocationName || app.invocationName || app.name;
  var schema = {
    interactionModel: {
      languageModel: model
    }
  };
  schema.interactionModel.languageModel.intents = schema.interactionModel.languageModel.intents.filter( (intent)=> {
    return (/^AMAZON\./.test(intent.name) || (intent.samples && intent.samples.length>0));
  });
  return JSON.stringify(schema, null, 3);
};

// Make Alexa API calls
// API calls
app.api = async function(endpoint) {
  return new Promise(function(resolve,reject) {
    // Retrieve the product info
    let api = request.data.context.System.apiEndpoint.replace('https://','');
    let token = "bearer " + request.data.context.System.apiAccessToken;
    let options = {
      host: api,
      path: endpoint,
      method: 'GET',
      headers: {
        "Content-Type": 'application/json',
        "Authorization": token
      }
    };
    let locale = request.data.request.locale;
    if (locale) {
      options.headers["Accept-Language"] = locale;
    }
    let json="";
    // Call the API
    const req = https.get(options, (res) => {
      res.setEncoding("utf8");
      if (res.statusCode === 403) { reject("Permission Denied"); }
      res.on('data', (chunk) => { json += chunk; });
      res.on('end', () => { resolve(JSON.parse(json)); });
    });
    req.on('error', (e) => {
      reject(e);
    });
  });
};
// Get the list of ISP's
app.list_isp = async ()=> {
  try {
    let inSkillProductInfo = await app.api("/v1/users/~current/skills/~current/inSkillProducts");
    var products = {};
    if (Array.isArray(inSkillProductInfo.inSkillProducts)) {
      var isps = inSkillProductInfo.inSkillProducts;
      for (var i=0; i<isps.length; i++) {
        var isp = isps[i];
        products[isp.referenceName] = isp;
      }
    }
    user.InSkillProducts = products;
    return products;
  }
  catch(e) {
    say `There was an error loading available products. Please try again.`;
  }
};

// Persistence
var ddb = new wrapper('us-east-1');
app.ddb = ddb;
//ddb.logging(app.ENABLE_LOGGING);
//app.ENABLE_LOGGING = true;
var log = function() { if (app.ENABLE_LOGGING) { console.log.apply(console,arguments); } };

// Template for a new player
// Stored in app so other scripts (like create_schema) can use it
app.new_player_template = {
  userid: ""
  ,high_score:0
  ,high_score_lives_used:0
  ,world_record:0
  ,world_record_lives_used:0

  ,round:0
  ,lives_used:0

  ,achievements: {}
  ,experience: {
    session_count:0
    ,explain_leaderboard:true
    ,prompt_name:true

    ,intro_1:true
    ,intro_5:true
    ,intro_10:true
    ,intro_20:true
  }
  ,extra: {
    lives:5
    ,used_lives:0
    ,purchased_lives:0
  }
  ,game:null
  ,music:true
  ,sound:true
};
// When a new player starts, use this template
var new_player = function(user_id) {
  var p = JSON.parse(JSON.stringify(app.new_player_template));
  p.userid = user_id;
  p.first_play_time = now();
  return p;
};

// ==========================================================================
// GLOBAL UTILITIES
// ==========================================================================
// Store the request and response on each each request, for easy access
var request = null;
var response = null;
var user = null;
var game = {};
function setContext(req,res) {
  request = req;
  response = res;
  user = null;
}
function _say_concat(strings,values) {
  var str = "";
  for (var i=0; i<Math.max(strings.length,values.length); i++) {
    if (i<strings.length) { str+=strings[i]; }
    if (i<values.length) { str+=values[i]; }
  }
  return str;
}
function say(strings,...values) {
  response.say(_say_concat(strings,values));
  return response;
}
function ask(strings,...values) {
  response.say(_say_concat(strings,values));
  response.shouldEndSession(false);
  return response;
}
function sayif(strings,...values) {
  if (!values.length) { return; }
  let v= values[0];
  if (v==="" || v==="null" || v==="false" || v==="0" || v==="undefined") { return; }
  response.say(_say_concat(strings,values));
  return response;
}
function switch_less_than(val, o) {
  let keys = Object.keys(o||{}).sort();
  keys.forEach((k,i)=> {
    if (i===keys.length-1 || val<=k) {
      return (typeof o[i]==="function") ? o[i]() : response.say(o[i]);
    }
  });
}
const now = function() {
  return (new Date()).getTime();
};
const postprocess = function(str) {
  // Conditional text {?iftrue:then output this repacing $_ with condition?}
  str = str.replace( /\{\?([^:]+):([^?]+)\?\}/g, (m,m1,m2)=> {
    return (m1==="false"||m1===""||m1==="0"||m==="null"||m==="undefined"||+m1===0) ? "" : m2.replace(/\$_/g,m1);
  });
  // word{s} that should be pluralized
  str = str.replace( /(\b)(\d+)(\b.*?)\{s\}/g, (m,m1,m2,m3)=> {
    let str = m1+m2+m3;
    if (+m2===1) { return str; }
    return str+"s";
  });
  // there {are} 5 dogs
  str = str.replace( /\{are\}(.*?\b)(\d+)/g, (m,m1,m2)=> {
    let str = m1+m2;
    if (+m2===1) { return "is"+str; }
    return "are"+str;
  });
  // there are 5 {puppy,puppies}
  str = str.replace( /(\b)(\d+)(\s+)\{(.*?),(.*?)\}/g, (m,m1,m2,m3,m4,m5)=> {
    let str = m1+m2+m3;
    if (+m2===1) { return str+m4; }
    return str+m5;
  });
  // Easy lists {list 4 dogs|2 cats|0 birds}
  str = str.replace( /\{list\s+([^}]+)\}/g, (m,m1)=> {
    let str = "";
    let list = m1.split(/\s*\|\s*/), keep=[];
    list.forEach((i)=>{
      if (parseInt(i,10)>0) { keep.push(i); }
    });
    if (keep.length===0) { return ""; }
    if (keep.length===1) { return keep[0]; }
    if (keep.length===2) { return keep[0]+" and "+keep[1]; }
    keep.push(" and "+keep.pop());
    return keep.join(", ");
  });
  // Output {value | alternative if first value is falsey}
  str = str.replace( /\{([^|}]+)\|([^}]+)\}/g, (m,m1,m2)=> {
    return (m1==="false"||m1===""||m1==="0"||m==="null"||m==="undefined"||+m1===0) ? m2 : m1;
  });
  // Remove multiple spaces
  str = str.replace(/\s+/g,' ');
  return str;
}

// GAME-SPECIFIC UTILS
function sound_button_connected() {
  if (user.sound) say `<audio src='soundbank://soundlibrary/ui/gameshow/amzn_ui_sfx_gameshow_player1_01'/>`;
}
function sound_positive_response() {
  if (user.sound) say `<audio src='soundbank://soundlibrary/ui/gameshow/amzn_ui_sfx_gameshow_positive_response_01'/>`;
}
function sound_negative_response() {
  if (user.sound) say `<audio src='soundbank://soundlibrary/ui/gameshow/amzn_ui_sfx_gameshow_negative_response_02'/>`;
}
function sound_waiting() {
  if (user.music) say `<audio src='soundbank://soundlibrary/ui/gameshow/amzn_ui_sfx_gameshow_countdown_loop_32s_full_01'/>`;
}
function sound_high_score() {
  if (user.sound) say `<audio src='soundbank://soundlibrary/ui/gameshow/amzn_ui_sfx_gameshow_positive_response_02'/>`;
}
async function persist_user(persist_game_state) {
  // Persist user session back to db if it has changed
  if (user) {
    let u = JSON.parse(JSON.stringify(user));
    if (!persist_game_state) {
      delete u.round;
      delete u.lives_used;
      delete u.state;
      delete u.buttonConnected;
    }
    delete u.game;
    delete u.listenerRequestId;
    await ddb.put(app.user_persistence_table, u);
  }
}

// ==========================================================================
// PRE
// ==========================================================================
app.pre = async function (req, res) {
  setContext(req,res);
  
  // Log all requests
  console.log(JSON.stringify(request.data));

  // By default, leave session open for button input
  response.shouldEndSession(null);

  // If session already exists, use it.
  // Otherwise, load it
  if (request.hasSession() && request.getSession().get("user") != null) {
    user = request.getSession().get("user");
  }
  if (user===null) {
    try {
      var user_id = request.data.session.userId;
      user = await ddb.get(app.user_persistence_table, "userid", user_id);
    }
    catch (e) {log(e);}
    if (user === null) {
      // A new player!!!
      user = new_player(user_id);
    }
  }
  // Grab the known game from the user record
  game = user.game || {};

  // Cancel the previous button listener, if it exists
  if (user.listenerRequestId && request.type() && "IntentRequest"===request.type()) {
    response.directive(
      {
        "type": "GameEngine.StopInputHandler",
        "originatingRequestId": user.listenerRequestId
      }
    );
  }
  delete user.listenerRequestId;

  // Update latest play time
  user.latest_play_time = now();

  // Use STATE to define the intent handler
  if (request.type()==="LaunchRequest" && typeof app.intents["launch"] !== "undefined" && typeof app.intents["launch"].handler === "function") {
    request.setIntent("launch");
  }
  if (request.type()==="IntentRequest") {
    var state = request.getState();
    log("Current state: "+state);
    if (state) {
      var potential_intent = state+'~'+request.data.request.intent.name;
      log("Potential state: "+potential_intent);
      if (typeof app.intents[potential_intent] !== "undefined" && typeof app.intents[potential_intent].handler === "function") {
        log("Switching to nested intent");
        request.pushState(request.data.request.intent.name);
        request.data.request.intent.name = potential_intent;
      }
      else {
        request.setState(request.data.request.intent.name);
        log("No matching nested state, switching to intent "+request.data.request.intent.name)
      }
    }
    else {
      request.setState(request.data.request.intent.name);
    }
  }
  // If returning from an ISP, set the state and push on the purchase result
  if (request.type()==="Connections.Response") {
    log("Connections.Response");
    let intent = request.data.request.token+"~"+request.data.request.payload.purchaseResult;
    log(intent);
    request.setIntent(intent);
  }

  // Store the intent in the user record for debugging
  user.intent = request.getState();

  log( request.getState() );
};

// ==========================================================================
// POST
// ==========================================================================
app.post = async function () {
  // Post-process for pluralization, etc
  try {
    let ssml = response.response.response.outputSpeech.ssml;
    if (ssml) {
      response.response.response.outputSpeech.ssml = postprocess(ssml);
    }
  } catch(e) { }

  // Randomize synonyms in the output
  response.randomizeSynonyms(outputSynonyms);

  // Store the user back into the session
  if (user) {
    request.getSession().set("user", user);
  }
  else {
    request.getSession().set("user", null);
  }

  // Re-construct the session
  response.prepare();

  // Log all responses
  console.log(JSON.stringify(response.response,null,3));
};

// Util
var has_display = function(request) {
  try {
    return !!request.data.context.System.device.supportedInterfaces.Display;
  }
  catch(e) { return false; }
};

// DISPLAY Functions
// =================
function display_splash_screen(request,response) {
  if (has_display(request)) {
    response.directive({
      "type" : "Display.RenderTemplate",
      "template" : {
        "type" : "BodyTemplate1",
        "backButton" : "HIDDEN",
        "backgroundImage" : {
          "contentDescription" : "",
          "sources" : [{
            "url" : "https://alexaspeedtap.com/splash.jpg",
            "size" : "MEDIUM"
          },{
            "url" : "https://alexaspeedtap.com/splash-square.jpg",
            "widthPixels":640,
            "heightPixels":640
          }
          ]
        }
      }
    });
  }
}

// SPEED TAP UTILS
// ===============
function shuffleArray(array) {
  for (let i = array.length - 1; i > 0; i--) {
    let j = Math.floor(Math.random() * (i + 1));
    [array[i], array[j]] = [array[j], array[i]];
  }
}
var colors = {
  "FF0000":"red",
  "0000FF":"blue",
  "FFFFFF":"white",
  "00FFFF":"aqua",
  "FFFF00":"yellow",
  "800080":"purple",
  "FFC0CB":"pink",
  "FFA500":"orange",
  "FF00FF":"magenta",
  "808080":"gray"
};
var button_listener = function(event_name) {
  return JSON.parse(`{
	  "type": "GameEngine.StartInputHandler",
	  "timeout": 25000,
	  "proxies":["button"],
	  "recognizers": {
		"button_down_recognizer": {
			"type":"match",
			"anchor":"end",
			"fuzzy":false,
			"pattern":[{"action":"down"}]
		}
	  },
	  "events": {
		  "${event_name}": {
			"meets": [ "button_down_recognizer" ],
			"reports": "matches",
			"shouldEndInputHandler": true
		  }
    ,"timeout": {
     "meets": ["timed out"],
     "reports": "history",
     "shouldEndInputHandler": true
      }
    }
  }`);
};
var animate =  function($duration,round) {
  if ($duration<100) { $duration=100; }

  var hex_colors = [];
  for (c in colors) {
    hex_colors.push(c);
  }

  var max = Math.floor(Math.random()*hex_colors.length);
  if (max<4) { max=4; }
  shuffleArray(hex_colors);
  hex_colors.length = max;

  var sequence = [];
  for (var i=0; i<hex_colors.length; i++) {
    let $d = $duration;
    // In later rounds, vary the light times a little
    if (round>20) {
      let x = ((Math.random()-0.5)*2*(round-20))/100;
      $d = Math.floor( $d+($d * x) );
    }
    sequence.push( {"durationMs":$d, "blend":false, "color":hex_colors[i] } );
  }
  sequence.push( {"durationMs":$duration, "blend":false, "color":"00FF00"} );

  return {
    "type": "GadgetController.SetLight",
    "version": 1,
    "targetGadgets": [],
    "parameters": {
      "triggerEvent": "none",
      "triggerEventTimeMs": 0,
      "animations": [
        {
          "repeat": 255,
          "targetLights": ["1"],
          "sequence": sequence
        }
      ]
    }
  };
};
var set_light = function($event,$start,$duration, $color) {
  return {
    "type": "GadgetController.SetLight",
    "version": 1,
    "targetGadgets": [],
    "parameters": {
      "triggerEvent": ($event||'none'),
      "triggerEventTimeMs": ($start||0),
      "animations": [
        {
          "repeat": 1,
          "targetLights": ["1"],
          "sequence": [
            {"durationMs": $duration,"blend": false,"color": $color}
          ]
        }
      ]
    }
  };
};
var light_off_on_button_down =  set_light("buttonDown",0,2000,"000000");
var light_off_on_button_up =  set_light("buttonUp",0,2000,"000000");

var light_off =  set_light("none",0,10,"000000");
var reset_buttons = function() {
  response.directive(light_off);
  response.directive( set_light("buttonUp",0,100,"000000") );
  // Blue pulse, Amazon standard reset
  response.directive(
    {
      "type": "GadgetController.SetLight",
      "version": 1,
      "targetGadgets": [],
      "parameters": {
        "triggerEvent": "buttonDown",
        "triggerEventTimeMs": 0,
        "animations": [
          {
            "repeat": 1,
            "targetLights" : [ "1" ],
            "sequence": [
              {
                "durationMs": 200,
                "color": "0000FF",
                "blend": false
              },
              {
                "durationMs": 500,
                "color": "000000",
                "blend": true
              }
            ]
          }
        ]
      }
    }
  );
};
var score_template = function(text) {
  if (has_display(request)) {
  response.directive({
      "type" : "Display.RenderTemplate",
      "template" : {
      "type" : "BodyTemplate1",
        "backButton" : "HIDDEN",
        "title" : "Speed Tap",
        "textContent" : {
          "primaryText": {
            "text": text,
            "type": "RichText"
          }
        }
      }
    });
  }
};
var start = function (round) {
  // Start a button input handler
  user.listenerRequestId = request.data.request.requestId;
  response.directive(button_listener('button_down_' + round));
  // Start cycling the lights
  response.directive(light_off_on_button_down);
  response.directive(light_off_on_button_up);
  var speed = 1000;
  if (round <= 10) {
    speed = 1000 - (round * 50); // 1000 - 500
  } else if (round <= 50) {
    speed = 500 - ((round - 10) * 10); // 485 - 100
  } else {
    speed = 100;
  }
  log("speed", speed);
  response.directive(animate(speed,round));

  // Listen for buttons but don't turn on mic
  response.shouldEndSession(null);
};
var stop = function () {
  reset_buttons();
};

// Util: Intent Mapper
// ===================
app.createTextResponseFunction = function(str) {
  return (req,res)=>{
    str = str.replace(/<POPSTATE>/g, function(m) {
      req.popState();
      return "";
    });
    str = str.replace(/<SETSTATE\s+([^>]+)>/g, function(m,m1) {
      req.setState(m1);
      return "";
    });
    str = str.replace(/<CLEARSTATE>/g, function(m) {
      req.clearState();
      return "";
    });
    res.say(str);
    req.popState();
  };
};
app.intentMap = function(json,state) {
  // Handle a map of multiple intents/states
  if (typeof state!=="string") { state=""; }

  if (typeof json==="string") {
    log("Found a handler for " + state);
    app.intents[state] = new alexa.intent(state, {}, app.createTextResponseFunction( json ));
  }
  else if (typeof json==="function") {
    log("Found a handler for " + state);
    app.intents[state] = new alexa.intent(state, {}, json);
  }
  else if (typeof json==="object") {
    for (let key in json) {
      if ("default" === key) {
        log("Found a handler for " + state);
        let schema = json['schema'] || {};
        app.intents[state] = new alexa.intent(state, schema, typeof json[key]==="function" ? json[key] : app.createTextResponseFunction( json[key]+"" ) );
      }
      else if ("schema" === key) {
        // Ignore
      }
      else {
        let keys = key.split(',');
        keys.forEach((k)=>{
          app.intentMap(json[key], state ? state + '~' + k : k);
        });
      }
    }
  }
};

app.gotoIntent = async function(intent,setState) {
  if (typeof app.intents[intent] !== "undefined" && typeof app.intents[intent].handler === "function") {
    if (typeof setState==="undefined" || setState) {
      request.setState(intent);
    }
    return Promise.resolve(app.intents[intent].handler(request, response));
  }
  throw "NO_INTENT_FOUND";
};

// Add context map on to an existing intent definition format
app._intent = app.intent;
app.intent = function(name,schema,func,context) {
  if (arguments.length<4) { return app._intent(name,schema,func); }
  if (typeof context!=="object") {
    context={};
  }
  context[DEFAULT] = func;
  context[SCHEMA] = schema;
  app.intentMap( {[name]: context} );
};

var YES = "AMAZON.YesIntent";
var NO = "AMAZON.NoIntent";
var HELP = "AMAZON.HelpIntent";
var FALLBACK = "AMAZON.FallbackIntent"
var DEFAULT = "default";
var SCHEMA = "schema";
var ACCEPTED = "ACCEPTED";
var DECLINED = "DECLINED";
var POPSTATE = function() {return '<POPSTATE>';};
var NOSTATECHANGE = function() {return '<POPSTATE>';};
var SETSTATE = function(state) {return `<SETSTATE ${state}>`;};
var CLEARSTATE = function(state) {return `<CLEARSTATE>`;};
var GOTO = function(intent) {
  return async function(request,response) {
    return app.gotoIntent(intent,request,response);
  }
};

// LAUNCH
// ======
var start_rollcall = function() {
  user.listenerRequestId = request.data.request.requestId;
  // Start a button input handler
  response.directive(button_listener('button_down_launch'));
  // Reset button light
  response.directive(light_off);
  // Pulse green when connected
  response.directive(set_light("buttonDown", 0, 2000, "00FF00"));
};

app.intentMap({
  "launch": {
    [DEFAULT]: async (request,response)=> {
      display_splash_screen(request,response);
      game = null;
      try {
        game = await ddb.get(app.game_table, "data", "game");
      } catch(e) {}
      if (game==null) {
        game = {
          world_record:0
          ,world_record_lives_used:0
          ,high_scores: {

          }
        };
      }
      user.game = game;

      // Refresh the user's real name
      if (user.linked) {
        try {
          let name = await app.api("/v2/accounts/~current/settings/Profile.name");
          user.name = name;
        }
        catch(e) {
          user.name = null;
          response.card({
            "type": "AskForPermissionsConsent",
            "permissions": [ "alexa::profile:name:read" ]
          });
        }
      }

      const card = ()=> response.card({type: "Simple",title: "Speed Tap",content: "Welcome to Speed Tap! Go to AlexaSpeedTap.com to check the leaderboard!"});

      request.experience("session_count"); // Increment session count

      // Open with a more different intro depending on how often the user has played
      if (request.experience("session_count",false) <= 1) {
        card();
        say `Welcome to ${speedtap}. This is a game of quick reactions and concentration. Would you like to hear a quick explanation of how to play?`;
        request.pushState("want_quick_intro");
        response.shouldEndSession(false);

        if (game.world_record) {
          user.world_record = game.world_record;
        }
      }
      else {
        if (request.experience("session_count",false) < 4) {
          card();
          say `Welcome back to ${speedtap}.`;
        }
        else {
          response.say(`Welcome back.`);
        }

        sayif `Your high score is ${user.high_score} `;

        // Check to see if there is a new world record to inform the user about
        if (game.world_record) {
          if (user.world_record && user.world_record < game.world_record) {
            say `and there is a new world record. The highest score is now ${game.world_record}`;
          }
          else if (game.world_record_user && game.world_record_user===request.data.session.userId) {
            say `and you still hold the world record`;
          }
          else {
            say `and the world record is ${game.world_record}`;
          }
          user.world_record = game.world_record;
        }

        say `. Press your echo button to start.`;
        start_rollcall();
      }

      user.round = 0;
      user.buttonConnected = false;
    },
    "want_quick_intro": {
      [YES]: ()=> {
        say(content.quick_intro);
        request.setState('launch');
        start_rollcall();
      }
      ,[NO]: ()=>{
        say `I'll assume you know the rules. Press your echo button to start.`;
        request.setState('launch');
        start_rollcall();
      }
    }
  }
});

app.on('System.ExceptionEncountered', function (request, response, request_json) {
  say `Sorry, an error occurred.`;
  log("System.ExceptionEncountered");
  log(request_json);
  stop();
  response.shouldEndSession(true);
});

app.start_game = function(prompt) {
  if (!user.buttonConnected) {
    request.setState("launch");
    say `Press your echo button to start.`;
    start_rollcall();
  }
  else {
    if (typeof prompt !== "boolean" && !prompt) {
      var text = prompt || "Press the button when it's green. You have 20 seconds. Go!";
      say`${text}`;
      score_template(text);
    }
    sound_waiting();
    user.round = 0;
    user.lives_used = 0;
    start(0);
    response.send();
  }
};
app.stop = async function() {
  say `Alright, see you again soon!`;
  response.shouldEndSession(true);
  await persist_user();
};

app.on('GameEngine.InputHandlerEvent',async function(request,response,request_json) {
  var round = +user.round || 0;
  log("connected",user.buttonConnected);
  log("round",round);

  var events = request_json.request.events;
  // In our case, let's only handle a single event, it will be a button down or timeout
...

This file has been truncated, please download it to see its full contents.

Dynamo DB Wrapper

JavaScript
This is a helper library wrapper around the aws-sdk to make interaction with DDB easier.
// A Wrapper for aws-sdk to provide a better API
//
// Not published, so not documented yet.
//
// Load the AWS SDK for Node.js
let AWS = require('aws-sdk');

module.exports = function(region) {
  // Set the region 
  AWS.config.update({region: region});

  let ENABLE_LOGGING = false;
  let log = function() {
    if (ENABLE_LOGGING) { console.log.apply(console,arguments); }
  };

  // Create DynamoDB document client
  let ddb = new AWS.DynamoDB();
  let docClient = new AWS.DynamoDB.DocumentClient();

  let wrapper = {
    'document':docClient
    ,'ddb':ddb

    ,'enable_logging':function() { ENABLE_LOGGING=true; }
    ,'disable_logging':function() { ENABLE_LOGGING=false; }
    ,'logging':function(bool) { ENABLE_LOGGING=bool; }
    ,'lock_table':'LOCK'
    ,'delay': async function(duration) { return new Promise(resolve => setTimeout(resolve, duration)); }

    // DELETE A TABLE
    // ==============
    ,'deleteTable': async function(tableName) {
      // Call DynamoDB to delete the specified table
      return ddb.deleteTable({"TableName":tableName}).promise();
    }
    
    // CREATE A TABLE
    // ==============
    ,'createSimpleTable': async function(tableName, key, keyType) {
      let params = {
        AttributeDefinitions: [
          {
            AttributeName: key, 
            AttributeType: (keyType || "S")
          }
        ], 
        KeySchema: [
          {
            AttributeName: key,
            KeyType: 'HASH'
          }
        ],
        ProvisionedThroughput: {
          ReadCapacityUnits: 5,
          WriteCapacityUnits: 5
        },
        TableName: tableName,
        StreamSpecification: {
          StreamEnabled: false
        }
      };

      // Call DynamoDB to create the table
      return ddb.createTable(params).promise();
    }
    
    ,'describe': async function(table) {
      return ddb.describeTable( {TableName:table} ).promise();
    }

    // PSEUDO-LOCK ON AN ARBITRARY KEY (OR TABLE NAME)
    // ===============================================
    ,'lock': async function(lock_key, retry_count, retry_delay_ms, func) {
      log("Trying to get lock");
      retry_count = retry_count || 25;
      retry_delay_ms = retry_delay_ms || 50;
      let attempt_put = async function() {
        try {
          let now = (new Date()).getTime();
          await wrapper.putUnique(wrapper.lock_table, {"key":lock_key, "time":now}, "key" );
          return true;
        }
        catch(e) {
          //console.log("Put fail");
          return false;
        }
      };
      let release = async function() {
        console.log("Releasing lock");
        return wrapper.delete(wrapper.lock_table, "key", lock_key);
      };
      while (retry_count-- >= 0) {
        console.log("retry_count: "+retry_count);
        let result = await attempt_put();
        if (result) {
          try {
            await func();
            await release();
            return true;
          }
          catch(e) {
            await release();
            throw e;
          }
        }
        await wrapper.delay(retry_delay_ms);
      }
      throw "lock_timeout";
    }
    
    // INSERT
    // ======
    ,'put': async function(table, item) {
      let params = {TableName:table, Item:item};
      log(params);
      return docClient.put( params ).promise();
    }
    ,'putUnique': async function(table, item, key_attribute) {
      let params = {TableName:table, Item:item, ConditionExpression:'attribute_not_exists(#keyAttribute)', ExpressionAttributeNames:{"#keyAttribute":key_attribute} };
      log(params);
      return docClient.put( params ).promise();
    }

    // DELETE
    // ======
    ,'delete': async function(table, keyAttribute, keyValue) {
      let params = {TableName:table, Key:{ [keyAttribute]:keyValue }};
      log(params);
      return docClient.delete(params).promise();
    }

    // UPDATE
    // ======
    ,'update': async function(table, keyAttribute, data, condition) {
      let i=0, k, name, val;
      let update_expression = [];
      let names={}, values = {};
      let extract_names = function(name) {
        let parts=[];
        if (typeof name==="string") {
          for (let part of name.split('.')) {
            let k = "#" + i++;
            parts.push(k);
            names[k] = part;
          }
          return parts.join(".");
        }
        return "";
      };
      if (typeof table==="string") {
        for (k in data) {
          if (k === keyAttribute) {
            names["#" + i++] = k;
          }
          else {
            val = data[k];
            if (/^\+/.test(k)) {
              // increment a value
              if (typeof val === "number") {
                k = extract_names(k.substring(1));
                update_expression.push(` ${k} = ${k} + :${i} `);
              }
            }
            else if (/^list_append /.test(k)) {
              // append to list
              k = extract_names(k.substring(12));
              update_expression.push(` ${k} = list_append(${k},:${i}) `);
            }
            else {
              k = extract_names(k);
              update_expression.push(` ${k} = :${i} `);
            }
            values[":" + i] = val;
            i++;
          }
        }
        update_expression = update_expression.join(",");
        let params = {
          TableName: table
          , Key: {}
          , UpdateExpression: 'set ' + update_expression
          , ExpressionAttributeNames: names
          , ExpressionAttributeValues: values
          , ConditionExpression: "attribute_exists(#0)"
        };
        if (condition && typeof condition === "string") {
          condition = condition.replace(/\{\{([^\}]+)\}\}/g, function (m, mm) {
            let val = mm;
            let key = ":" + (i++);
            if (+val == val) {
              val = +val;
            }
            values[key] = val;
            return key;
          });
          params.ConditionExpression += " AND " + condition;
        }
        params.Key[keyAttribute] = data[keyAttribute];
      }
      else {
        params = table;
      }
      
      log(params);
      return docClient.update(params).promise();
    }
    
    // RETRIEVE
    // ========
    ,'get': async function(table, keyAttribute, keyValue) {
      let params = {TableName:table, Key:{ [keyAttribute]:keyValue } };
      log(params);
      return docClient.get(params).promise()
        .then( (item)=> {
          if (!item || !item.Item) { return null; }
          return item.Item;
        });
    }
    ,'scan': async function(table, condition) {
      let params = {TableName:table};
      log(params);
      let lastKey = null;
      let results = [];
      let page = 1;
      return new Promise(async (resolve,reject)=>{
        do {
          if (lastKey) { params.ExclusiveStartKey = lastKey; }
          await docClient.scan(params).promise()
            .then( (items)=> {
              if (!items || !items.Items || !items.Items.length) {
                lastKey=null;
              }
              else {
                results = results.concat(items.Items);
                lastKey = items.LastEvaluatedKey;
              }
            }).catch( (err)=>{ reject(err); } );
        } while (lastKey);
        resolve(results);
      });
    }
  };
  
  return wrapper;
};

Credits

Matt Kruse

Matt Kruse

1 project • 0 followers

Comments