multiplayer-vr-keith2

Multiplayer WebXR Readyplayer.me Avatars (part 2)

Detailing the Infrastructure and Back End changes that enable multiplayer WebXR readyplayer.me avatars

Intro

In my previous blog post, I explained the concepts behind creating a real-time multiplayer WebXR experience with readyplayer.me avatars (like in this example GIF).

In this post I’ll detail some core code samples behind this example.

Overview

These code samples are from the Wrapper.js library and are part of the WebXR template, which is now in its own repo for you to explore if you’re interested for the full codebase (Wrapper.js instructions for development / deployment are here).

This new readyplayer.me functionality is built ontop of the existing template, which I’ve already made a blog post series about here – to fully make the most of this blog post I encourage you to read that first.

So in this section, I will detail the new changes I’ve made that enable readyplayer.me integration – starting with Cloud Infrastructure and Back End.

Terraform

Starting off with the Terraform, which is responsible for creation of all cloud resources except for lambda functions, the logic changes are below.

devops/terraform/main.tf

				
					...
module "socket_connections_table" {
  source        = "./modules/dynamodb"
  db_table_name = "${var.service_name}-${var.stage}-socket-connections-table"
  hash_key      = "connectionId"
  hash_key_type = "S"
}


module "secrets_manager" {
  ...
  connections_table_id         = module.socket_connections_table.id
}
				
			

The above shows a new DynamoDB table being created by the DynamoDB module, with only a hash key of Connection ID.

This is to keep track of the Websocket connections that are being made by new clients (Front End web pages).

Serverless Framework

Building on the above, Serverless Framework is responsible deploying the business logic of the Back End and makes use of this new DynamoDB table.

backend/serverless/services/ws/positions/index.js

				
					"use strict";
const AWS = require("aws-sdk");
const dynamoDb = new AWS.DynamoDB.DocumentClient();
const {
  IS_OFFLINE,
  positions_table_id,
  domain_name,
  connections_table_id,
  api_local_ip_address,
  local_api_ws_port,
} = process.env;

module.exports.handler = async (event, context) => {
  const localUrl = `https://${api_local_ip_address}:${local_api_ws_port}`;
  const liveUrl = `https://ws.${domain_name}`;
  const socketUrl = IS_OFFLINE ? localUrl : liveUrl;

  const apiGatewayManagementApi = new AWS.ApiGatewayManagementApi({
    apiVersion: "2018-11-29",
    endpoint: socketUrl,
  });

  console.log(
    `https://${event.requestContext.domainName}/${event.requestContext.stage}`
  );

  const connectionId = event.requestContext.connectionId;
  console.log(`connectionid is the ${connectionId}`);

  const data = JSON.parse(event.body).data;

  const positionData = await returnPositionData(data, positions_table_id);

  await broadcastMessage(connectionId, positionData, apiGatewayManagementApi);

  return {
    statusCode: 200,
  };
};

async function broadcastMessage(
  senderConnectionId,
  positionData,
  apiGatewayManagementApi
) {
  const connections = await getAllConnections();

  await Promise.all(
    connections.Items.map(async (connection) => {
      const connectionId = connection.connectionId;

      if (connectionId !== senderConnectionId) {
        try {
          await apiGatewayManagementApi
            .postToConnection({
              ConnectionId: connectionId,
              Data: JSON.stringify(positionData),
            })
            .promise();
        } catch (error) {
          console.error(
            "Failed to send message to connection",
            connectionId,
            error
          );
        }
      }
    })
  );
}

const getAllConnections = async () =>
  await dynamoDb
    .scan({
      TableName: connections_table_id,
    })
    .promise();

const returnPositionData = async (posData, positions_table_id) => {
  const { type, uid, data } = posData;
  if (data != "") {
    const putParams = {
      Item: {
        type: type,
        uid: uid,
        data: data,
      },
      TableName: positions_table_id,
    };

    await dynamoDb.put(putParams).promise();
  }
  const getParams = {
    TableName: positions_table_id,
  };
  const result = await dynamoDb.scan(getParams).promise();
  return result.Items;
};

				
			

The above shows the following core functions:

  1. returnPositionData: updates the position data in DynamoDB that is submitted to the api, then returns all positions in preparation for the next step
  2. broadcastMessage: this will add the connectionId of the device that has submitted data to the websocket if it doesn’t already exist, then loop through each connectionId and post the positionData to all of them

The reason for this change, is that visualising animated avatars from other users will appear smoother the more frequent you receive positional updates from other users avatars – this is especially pronounced with the VR ‘Half-Body’ Avatars.

In order to help reduce AWS costs, this change means that you can receive other users positional updates as they occur, without having to submit any positional data yourself.

There is more that could be optimised, like the DynamoDB calls themselves and general code tidy up of the syntax – but I’ll get around to this at some later date (doesn’t stop you from using the main purpose of this example – which is prototyping)!

Conclusion

In this blog post, we’ve covered the core changes in the Cloud Infrastructure and Back End logic that enables the use of readyplayer.me avatars in a real-time WebXR experience.

In the next blog post, I’ll focus on the Front End changes – which is where most of the magic happens!

In the meantime, have fun making 😀

Share this post