¿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

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

By Clara

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

Aprende a crear un juego al estilo Among Us con Alexa utilizando computación serverless, WebSocket y Node.js

  • 695