¿Podríamos crear un juego al estilo Among Us con Alexa?

Clara Jiménez Recio

¿Podríamos crear un juego al estilo Among Us con Alexa?

Clara Jiménez Recio - FullStack Developer

20-21-22 abril, 2021

¡Hola! 👋🏻

Free-time Alexa Skills Developer 🤓

FullStack JavaScript Developer & Lover 👩🏻‍💻❤️

Teleco Engineer 👩🏻‍🎓

Alexa Beyond Voice Challenge 🏆

Free-time Alexa Skills Developer 🤓

Alexa Games Hackathon 🏆

FullStack JavaScript Developer & Lover 👩🏻‍💻❤️

¡Hola! 👋🏻

Teleco Engineer 👩🏻‍🎓

Alexa Beyond Voice Challenge 🏆

2

3

Amazon API Gateway

Web Application

1

Comunicación a tiempo real

5

Los Hombres Lobo

4

Alexa Skill

6

Among us

CÓDIGO

Comunicación a tiempo real

❌ Esperar a una interactuación del usuario para informarle del estado del juego

✅ Mantener a los jugadores informados a tiempo real

Comunicación a tiempo real

Alexa no puede recibir información a tiempo real... 👎🏻

Pero sí podemos hacer que los usuarios la reciban 👍🏻

Comunicación a tiempo real

❌ Polling HTTP unidireccional

http://

GET
GET

Comunicación a tiempo real

✅ WebSocket bidireccional

ws://

CONNECT

Comunicación a tiempo real

joinRoom
WS

...

HTTP
getRoom
HTTP
startGame
HTTP

⚠️

WGFTPH

1

2

3

Comunicación a tiempo real

         Launch

+

         Login

         Start

2

3

1

getRoom

HTTP
WS

joinRoom

HTTP

startGame

joinRoom

HTTP

joinRoomSuccess / newPlayer

WS

startGame

WS

startGame

WS

Amazon API Gateway: REST

# serverless.yml

service: my-api-rest

custom:
  ROOMS_TABLE: "playing_rooms"

provider:
  name: aws
  runtime: nodejs12.x
  stage: dev
  region: eu-west-1
  iamRoleStatements:
    - Effect: Allow
      Action:
        - dynamodb:Query
        - dynamodb:Scan
        - dynamodb:GetItem
        - dynamodb:PutItem
        - dynamodb:UpdateItem
        - dynamodb:DeleteItem
      Resource: "*"
  environment:
    ROOMS_TABLE: ${self:custom.ROOMS_TABLE}

functions:
  app:
    handler: app.handler
    events:
      - http: ANY /
      - http: 'ANY {proxy+}'
      
resources:
  Resources:
    RoomsDynamoDBTable:
      Type: 'AWS::DynamoDB::Table'
      Properties:
        AttributeDefinitions:
          -
            AttributeName: room
            AttributeType: S
        KeySchema:
          -
            AttributeName: room
            KeyType: HASH
        BillingMode: PAY_PER_REQUEST
        TableName: ${self:custom.ROOMS_TABLE}
ROOMS_TABLE
{
  room: "WGFTPH",
  players: [
    {
      name: "Pablo",
      role: "impostor"
    }
    ...
  ]
}
// app.js

const serverless = require('serverless-http');
const express = require('express');
const routes = require('./routes/index');
const app = express();

app.use(express.json({ strict: false }));

app.use('/', routes);

module.exports.handler = serverless(app);

Amazon API Gateway: REST

// routes/index.js

const express = require('express');
const router = express.Router();

const alexaController = require('../controllers/alexa_controller');
const webController = require('../controllers/web_controller');

router.route('/room')
  .post(alexaController.getRoom);

router.route('/join/:room')
  .put(webController.joinRoom);

router.route('/start/:room')
  .post(alexaController.startGame);
getRoom
startGame

Alexa:

joinRoom

Web App:

Amazon API Gateway: REST

// controllers/alexa_controller.js

const AWS = require('aws-sdk');
const db = process.env.ROOMS_TABLE;
const dynamoDb = new AWS.DynamoDB.DocumentClient();

module.exports.getRoom = async (req, res) => {
  const room = roomGenerator();
  const params = {
    TableName: db,
    Item: {
      room: room,
      players: [],
    },
  };
  try {
    await dynamoDb.put(params).promise();
    res.status(200).json({ room });
  } catch (error) {
    res.status(500).json({ error });
  }
}
getRoom

Alexa:

{
  room: "WGFTPH",
  players: []
}

Amazon API Gateway: REST

// controllers/web_controller.js

const AWS = require('aws-sdk');
const db = process.env.ROOMS_TABLE;
const dynamoDb = new AWS.DynamoDB.DocumentClient();

module.exports.joinRoom = async (req, res) => {
  const { room } = req.params;
  const { name } = req.body;
  const params = {
    TableName: db,
    Key: {
      room: room,
    },
    UpdateExpression: 'SET #players = list_append(#players, :attrValue)',
    ExpressionAttributeNames: {
      '#players': 'players',
    },
    ExpressionAttributeValues: {
    ':attrValue': [
       {
         name: name,
         role: '',
       },
     ],
   },
  };
  try {
    await dynamoDb.update(params).promise();
    res.status(200).json({ room, name, players });
  } catch (error) {
    res.status(500).json({ error });
  }
}
joinRoom

Web App:

{
  room: "WGFTPH",
  players: [
    {
      name: "Pablo",
      role: ""
    }
    ...
  ]
}

Amazon API Gateway: REST

// controllers/alexa_controller.js

const AWS = require('aws-sdk');
const db = process.env.ROOMS_TABLE;
const dynamoDb = new AWS.DynamoDB.DocumentClient();

const WebSocket = require('ws');
const WS_URL = 'wss://6a412fvzju.execute-api.eu-west-1.amazonaws.com/dev';
let ws = new WebSocket(WS_URL);

module.exports.startGame = async (req, res) => {
  const { room } = req.params;
  let params = {
    TableName: db,
    Key: {
      room: room,
    },
  };
  try {
    const result = await dynamoDb.get(params).promise();
    let { players } = result.Item;
    players = rolesGenerator(players);
    params = {
      TableName: db,
      Key: {
        room: room,
      },
      UpdateExpression: 'SET #players=:players',
      ExpressionAttributeNames: {
        '#players': 'players',
      },
      ExpressionAttributeValues: {
        ':players': players,
      },
    };
    await dynamoDb.update(params).promise();
    await ws.send(
      JSON.stringify({
        action: 'startGame',
        room,
        players,
      })
    );
    res.status(200).json({ players });
  } catch (error) {
    res.status(500).json({ error });
  }
}
startGame

Alexa:

{
  room: "WGFTPH",
  players: [
    {
      name: "Pablo",
      role: "impostor"
    }
    ...
  ]
}
➡️ WSS startGame

Amazon API Gateway: WebSocket

# serverless.yml

service: my-api-ws

custom:
  CONNECTIONS_TABLE: "connections"

provider:
  name: aws
  runtime: nodejs12.x
  stage: dev
  region: eu-west-1
  websocketsApiName: my-api-ws
  websocketApiRouteSelectionExpression: $request.body.action
  iamRoleStatements:
    - Effect: Allow
      Action:
        - "execute-api:ManageConnections"
      Resource:
        - "arn:aws:execute-api:*:*:**/@connections/*"
    - Effect: Allow
      Action:
        - dynamodb:Query
        - dynamodb:Scan
        - dynamodb:GetItem
        - dynamodb:PutItem
        - dynamodb:UpdateItem
        - dynamodb:DeleteItem
      Resource: "*"
  environment:
    CONNECTIONS_TABLE: ${self:custom.CONNECTIONS_TABLE}

functions:
  connectionHandler:
    handler: app.connection
    events:
      - websocket:
          route: $connect
      - websocket:
          route: $disconnect
  joinRoomHandler:
    handler: app.joinRoom
    events:
      - websocket:
          route: joinRoom
  startGameHandler:
    handler: app.startGame
    events:
      - websocket:
          route: startGame
  defaultHandler:
    handler: app.default
    events:
      - websocket:
          route: $default

resources:
  Resources:
    ConnectionsDynamoDBTable:
      Type: 'AWS::DynamoDB::Table'
      Properties:
        AttributeDefinitions:
          -
            AttributeName: connectionId
            AttributeType: S
        KeySchema:
          -
            AttributeName: connectionId
            KeyType: HASH
        BillingMode: PAY_PER_REQUEST
        TableName: ${self:custom.CONNECTIONS_TABLE}
websocketApiRouteSelectionExpression
CONNECTIONS_TABLE
{
  connectionId: "YfYBXeeqiGYCEXg=",
  room: "WGFTPH"
}

Amazon API Gateway: WebSocket

# serverless.yml

service: my-api-ws

custom:
  CONNECTIONS_TABLE: "connections"

provider:
  name: aws
  runtime: nodejs12.x
  stage: dev
  region: eu-west-1
  websocketsApiName: my-api-ws
  websocketApiRouteSelectionExpression: $request.body.action
  iamRoleStatements:
    - Effect: Allow
      Action:
        - "execute-api:ManageConnections"
      Resource:
        - "arn:aws:execute-api:*:*:**/@connections/*"
    - Effect: Allow
      Action:
        - dynamodb:Query
        - dynamodb:Scan
        - dynamodb:GetItem
        - dynamodb:PutItem
        - dynamodb:UpdateItem
        - dynamodb:DeleteItem
      Resource: "*"
  environment:
    CONNECTIONS_TABLE: ${self:custom.CONNECTIONS_TABLE}

functions:
  connectionHandler:
    handler: app.connection
    events:
      - websocket:
          route: $connect
      - websocket:
          route: $disconnect
  joinRoomHandler:
    handler: app.joinRoom
    events:
      - websocket:
          route: joinRoom
  startGameHandler:
    handler: app.startGame
    events:
      - websocket:
          route: startGame
  defaultHandler:
    handler: app.default
    events:
      - websocket:
          route: $default

resources:
  Resources:
    ConnectionsDynamoDBTable:
      Type: 'AWS::DynamoDB::Table'
      Properties:
        AttributeDefinitions:
          -
            AttributeName: connectionId
            AttributeType: S
        KeySchema:
          -
            AttributeName: connectionId
            KeyType: HASH
        BillingMode: PAY_PER_REQUEST
        TableName: ${self:custom.CONNECTIONS_TABLE}
websocketApiRouteSelectionExpression
$connect
$disconnect
$default
CONNECTIONS_TABLE
{
  connectionId: "YfYBXeeqiGYCEXg=",
  room: "WGFTPH"
}

Amazon API Gateway: WebSocket

// app.js

const dynamo = require('db_utils.js');

module.exports.connection = async (event, context, callback) => {
  const { connectionId } = event.requestContext;
  if (event.requestContext.eventType === 'CONNECT') {
    try {
      await dynamo.addConnection(connectionId);
      callback(null, { statusCode: 200, body: 'OK' });
    } catch(error) {
      callback(null, { statusCode: 500, body: JSON.stringify(error) });
    }
  } else if (event.requestContext.eventType === 'DISCONNECT') {
    try {
      await dynamo.deleteConnection(connectionId);
      callback(null, { statusCode: 200, body: 'OK' });
    } catch(error) {
      callback(null, { statusCode: 500, body: JSON.stringify(error) });
    }
  }
}
$connect

Web App:

{
  connectionId: "YfYBXeeqiGYCEXg=",
  room: ""
}
$disconnect

Amazon API Gateway: WebSocket

// app.js

const dynamo = require('db_utils.js');
const ws = require('ws_utils.js');
const axios = require('axios');
const HTTP_URL = 'https://wjky5nj124.execute-api.eu-west-1.amazonaws.com/dev';

module.exports.joinRoom = async (event, context, callback) => {
  const { connectionId } = event.requestContext;
  const body = JSON.parse(event.body);
  const { room, name } = body;
  try {
    await dynamo.join(connectionId, room);
    await axios.put(`${HTTP_URL}/join/${room}`, { name });
    await ws.sendMessageToSocket(connectionId, 'joinRoomSuccess', event);
    await ws.sendMessageToRoom(room, 'newPlayer', event);
  } catch(error) {
    callback({ statusCode: 500, body: JSON.stringify(error) });
  }
}
joinRoom

Web App:

{
  connectionId: "YfYBXeeqiGYCEXg=",
  room: "WGFTPH"
}
➡️ WSS joinRoomSuccess
➡️ WSS newPlayer
➡️ HTTP /join/:room

Amazon API Gateway: WebSocket

// app.js

const ws = require('ws_utils.js');

module.exports.startGame = async (event, context, callback) => {
  const body = JSON.parse(event.body);
  const { room } = body.room;
  try {
    await ws.sendMessageToRoom(room, 'startGame', event);
  } catch(error) {
    callback({ statusCode: 500, body: JSON.stringify(error) });
  }
}
startGame

Alexa:

➡️ WSS startGame

Amazon API Gateway: WebSocket

// ws_utils.js

const dynamo = require('db_utils.js');
const AWS = require('aws-sdk');

const send = async (connectionId, action, event) => {
  const body = JSON.parse(event.body);
  body.action = action;
  const { domainName, stage } = event.requestContext;
  const ws = new AWS.ApiGatewayManagementApi({
    apiVersion: '2018-11-29',
    endpoint: `${domainName}/${stage}`;
  });
  const params = {
    ConnectionId: connectionId,
    Data: JSON.stringify(body)
  };
  return await ws.postToConnection(params).promise();
}

module.exports.sendMessageToSocket = async (connectionId, action, event) => {
  return await send(connectionId, action, event);
}

module.exports.sendMessageToRoom = async (room, action, event) => {
  const players = await dynamo.getPlayersInRoom(room);
  return await Promise.all(players.Items.map(async (item) => {
    return await send(item.connectionId, action, event);
  }));
}

sendMessageToSocket

sendMessageToRoom

send

Amazon API Gateway: WebSocket

// db_utils.js

const AWS = require('aws-sdk');
const db = process.env.CONNECTIONS_TABLE;
const dynamoDb = new AWS.DynamoDB.DocumentClient();

module.exports.addConnection = async (connectionId) => {
  const params = {
    TableName: db,
    Item : {
      connectionId: connectionId,
      room: ''
    }
  };
  return await dynamoDb.put(params).promise();
}

module.exports.deleteConnection = async (connectionId) => {
  const params = {
    TableName: db,
    Key : {
      connectionId: connectionId
    }
  };
  return await dynamoDb.delete(params).promise();
}

module.exports.join = async (connectionId, room) => {
  const params = {
    TableName: db,
    Key : {
      connectionId: connectionId
    },
    UpdateExpression: 'SET room=:room',
    ExpressionAttributeValues: {
      ':room': room
    },
    ReturnValues: 'ALL_NEW'
  };
  return await dynamoDb.update(params).promise();
}

module.exports.getPlayersInRoom = async (room) => {
  const params = {
    TableName: db,
    FilterExpression: 'room=:room',
    ExpressionAttributeValues: {
      ':room': room
    },
    ProjectionExpression: 'connectionId'
  };
  return await dynamoDb.scan(params).promise();
}

addConnection

deleteConnection

join

getPlayersInRoom

Web App: HTML5

const WS_URL = 'wss://6a412fvzju.execute-api.eu-west-1.amazonaws.com/dev';
const ws = new WebSocket(WS_URL);

HTML5 ➡️ WebSocket API

ws.onopen = () => {
  ws.send(JSON.stringify(
    {
      action: 'joinRoom',
      room: [ROOM],
      name: [PLAYER_NAME]
    }
  ));
}

Envío de mensajes 📨 

onopen + send

Web App: HTML5

Recepción de mensajes 📨

ws.onmessage = (event) => {
  const data = JSON.parse(event.data);
  switch (data.action) {
    case 'joinRoomSuccess':
      localStorage.setItem('room', data.room);
      localStorage.setItem('name', data.name);
      console.log(`You've been successfully registered in room: ${data.room}`);
      break;
    case 'newPlayer':
      console.log(`New player: ${data.name} in room: ${data.room}`);
      break;
    case 'startGame':
      const name = localStorage.getItem('name');
      const { role } = data.players.find((player) => player.name === name);
      localStorage.setItem('role', role);
      console.log(`Ready to play! You are a ${role}`);
      break;
    default:
      break;
  }
};
onmessage

Cerrar la conexión

ws.close()

Alexa Skill: Node.js

const Alexa = require('ask-sdk-core');
const axios = require('axios');
const HTTP_URL = 'https://wjky5nj124.execute-api.eu-west-1.amazonaws.com/dev';

const LaunchRequestHandler = {
  canHandle(handlerInput) {
    return Alexa.getRequestType(handlerInput.requestEnvelope) === 'LaunchRequest'
  },
  async handle(handlerInput) {
    const requestAttributes = handlerInput.attributesManager.getRequestAttributes();
    
    const result = await axios.post(`${HTTP_URL}/room`, null);
    const { room } = result;
    
    const speechText = requestAttributes.t('WELCOME_MSG', { room });
    const repromptText = requestAttributes.t('WELCOME_REPROMPT_MSG');
    
    handlerInput.attributesManager.setSessionAttributes({ room });
    
    return handlerInput.responseBuilder
      .speak(speechText)
      .reprompt(repromptText)
      .getResponse()
  }
}

¿En qué sala entramos?

Respuesta por voz

Guardar la sala

Alexa Skill: Node.js

const Alexa = require('ask-sdk-core');
const axios = require('axios');
const HTTP_URL = 'https://wjky5nj124.execute-api.eu-west-1.amazonaws.com/dev';

const StartIntentHandler = {
  canHandle(handlerInput) {
    return (
      Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' &&
      Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.StartIntent'
    )
  },
  async handle(handlerInput) {
    const requestAttributes = handlerInput.attributesManager.getRequestAttributes();
    const sessionAttributes = handlerInput.attributesManager.getSessionAttributes();
    
    const speechText = requestAttributes.t('START_MSG');
    const repromptText = requestAttributes.t('START_REPROMPT_MSG');
    
    const { room } = sessionAttributes
    const result = await axios.post(`${HTTP_URL}/start/${room}`, null);
    
    // Add Dynamic Entities
    const { players } = result;
    let updateEntitiesDirective = {
      type: 'Dialog.UpdateDynamicEntities',
      updateBehavior: 'REPLACE',
      types: [
        {
          name: 'PlayerName',
          values: [] // we fill this array with the entities below
        }
      ]
    };
    players.forEach((player) => updateEntitiesDirective.types[0].values.push(
      {
        id: player.name.replace(/\s/gi, "_"),
        name: {
          value: player.name
        }
      }
    ));
    handlerInput.responseBuilder.addDirective(updateEntitiesDirective);
    
    return handlerInput.responseBuilder
      .speak(speechText)
      .reprompt(repromptText)
      .getResponse()
  }
}

Respuesta por voz

Empezar la partida

Dynamic Entities

Los Hombres Lobo

✅ Entrar en la sala

Matar a alguien

Desvelar víctima

Votar

Fin del juego

 ➡️ "Alexa, ha sido Pablo"
 ➡️ "Alexa, ¿quién ha muerto?"
 ➡️ "Alexa, ya estamos dentro"
 ➡️ "Alexa, mato a Cristina"

🤫

Los Hombres Lobo

Among Us

✅ Entrar en la sala

⚠️ Hacer misiones

❌ Matar a alguien

❌ Sabotear

Reportar cuerpo

Pulsar botón de alarma

Votar

❌ Fin del juego

 ➡️ "Alexa, acabo de escanearme", "Alexa, ¿cómo van las misiones?"
 ➡️ "Alexa, ha sido el verde"
 ➡️ "Alexa, llamada de emergencia"
 ➡️ "Alexa, he visto al amarillo muerto"
 ➡️ "Alexa, sabotea el oxígeno"
 ➡️ "Alexa, he matado al amarillo en armería"
 ➡️ "Alexa, ya estamos dentro"

🤫

🤫

¡Gracias! 😻

Hacer misiones

Matar

Entrar en la nave

Sabotear

Reportar

Reportar

Alarma

Votar

Made with Slides.com