CastleKnight - Using Macrometa's Edge Database & Streaming Engine to Build Low Latency, Realtime Multiplayer Online Games *with bonus in-game chat

Let’s say you want to build an online multiplayer game that takes advantage of edge computing to provide low latency and a fast and real time user experience.  These kinds of games are hard to build because you need to understand low level networking inside game engines (like unity or unreal), and deal with lots of complexity when it comes to sending and receiving state data as well as storing it on a server for persistence. Now what if I told you that you could do all of this in a few hours without needing to deal with anything except a few API functions callable via JavaScript and the end game will be edge native and use Macrometa's global network of edge data centers to provide real time data streaming and data storage. I assume naturally you are excited!

In this post and tutorial, I'm going to show you how to use Macrometa to create a multi player, side scrolling platform game that uses Macrometa's streams to send messages to each player about the game state, controls (up, down, left, right and jump), and the database for storing persistent game state (thing like who is on what level, how many points collected, how many treasures like diamonds and keys collected etc).

Try the game out online

It's probably good to play the game for few minutes to better understand the functionalities. The game is a single page app served off a CDN. You can start and play this game directly using below link and you can invite your friends to join by sharing the link with them):
 
IMPORTANT - Credits & Attribution
CastleKnight is based on a fork of  https://github.com/pubnub-dsn/Ninja-Multiplayer-Platformer and originally published by pubnub.  The pubnub example tutorial can be found here - https://www.pubnub.com/developers/tutorials/javascript/multiplayer-game/

 

castleknight-2 

Why you need Macrometa for your games

First, how do you achieve realtime interaction among players? You could try and use pubnub or socket.io which both offer useful capabilities but thats not going to help when you need low latency because both of their pub/sub architectures are centralized.  That means that sending any data from the game client to each other will have to transit all the way to their central servers before being recieved by the other game clients.  That just adds latency.  Additionally - neither pubnub or socket.io let you persist game state so if you want to collect player stats in real time - well you're going to have to use a database of your own or a cloud database - but guess what - those are centralized too and have high levels of latency to access. 
 
While, there are a lot of tools you can use to get something out of when writing games, most of these tools will likely create more problems for you along the way, when you start handling realtime multiplayer interactions & state over tens and hundred of locations globally. 
 
This is where the Macrometa's serverless edge computing data platform comes in. It provides you a ready made APIs, enabling you and your teams to easily build, deploy and run cross-region or global, data driven, realtime apps and games.
 
 
Given my involvement in building Macrometa Serverless Edge cloud, I am obviously biased. :-) So don’t take my word as fact. In rest of this post, let’s build a global realtime online multi-player game called CastleKnight. After that, you can decide for yourself if the Macrometa Fast Data platform saves you and your team lots of time to focus on developing your game. 
 
CastleKnight uses Macrometa Fast Data Platform to manage the state and events traffic between players across the world in realtime. This game is a collaborative puzzle game that encourages you to work with your friends to collect the keys in clever ways.
 

Macrometa as a geo distributed backend for Games - Capabilities

Following are some useful (and critical) Macrometa  capabilities we will use as we build this game :
 
  • Realtime Multi-player Game State i.e., reliable realtime game state and events and inputs across all connected players. This we will do utilizing the realtime database feature of the Macrometa fast data platform.
  • Multiplayer In-game Chat i.e., incorporate interactive social features like chat across all connected players utilizing the streams feature of Macrometa platform.
  • Player Lists i.e., realtime roster of players utilizing the streams feature.
  • Live Statistics and Scores i.e., realtime score updates and game statistics to dashboards utilizing the database and streams features in the Macrometa platform.
  • Levels & Occupancy Counters i.e., various levels in the game and player stats in each of those levels.
 
Obviously one can build more functionalities in the game using the building blocks in Macrometa's Fast Data platform, but I think for this post the above is sufficient. You are welcome to extend it and let me know.
 
The rest of the post is all about how we build the Game GUI and leverage Macrometa SDK in the game. As a game developer,  this is the part you should actually be focusing your precious time and efforts on.

 

Building the Game

Step1: Get a Macrometa account

You will need an account with Macrometa's Fast Data platform service to build this game. It is free and you do not have to provide any credit card, etc. Please go ahead and sign up here.
 
After signup, you will receive a tenant account along with credentials. Please go ahead and login.  After login, you should see a dashboard something like the image below.
 
 

Step2: Clone the source code

You can get the source code for the game here to change it. Go ahead and clone the repository to a directory on your local system by executing this link - https://github.com/Macrometacorp/tutorial-castleknight-game.git
 
After cloning, you should see something like this in your directory:
 

 

 

Step3: Configure the game to use your account

Open js/Config.js file and edit the following parameters to reflect your account:
 
  var cluster = "try.macrometa.io";
  var tenant = "yourtenantname";
  var fabric_name = "_system";
  var username = "root";
  var password = "yourpassword";

 

Step4: Deploy the game

Running Locally:
CastleKnight game is a single page app. So to run the game locally,  you have to launch your local web server. If you have Mac OS or Linux (or have Python installed), open up your Terminal Application in your game folder and type in:
 
python -m SimpleHTTPServer 8000
 
If you are using Windows download XAMPP. There are some great tutorials out there on how to setup XAMPP on your machine. 
 
Once you have your web server up and running, go to http://localhost:8000/ on your machine. Then navigate to the index.html file in your web browser and click on the link. You should now see a blank screen.
 
Running from S3:
If you want to deploy and run the game from S3 then do the following. Go outside the current working directory i.e., tutorial-castleknight-game in this case. If you are using the AWS cli then run following command to recursively copy all files and folders inside the tutorial-castleknight-game folder to the S3 bucket.
 
 aws s3 cp tutorial-castleknight-game s3://<your-s3-bucket-name> --recursive 
 
  • Note: The bucket needs to be public in order for the website to be visible. 
 
Sample Bucket Policy :
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "PublicReadGetObject",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::<your-s3-bucket-name>/*"
        }
    ]
}
 
Now go to the Properties tab in the AWS console for this bucket and open Static website hosting option. Then select the option Use this bucket to host a website and provide index.html for both Index document and Error document text fields. Click on save and the game is now live!
 

Understanding the Code

Initialize the game

To begin, open up your main.js file. Following code fragment reads your credentials from Config.js and opens the connection to Macrometa data platform. Also it creates a geo-replicated document collection called occupancy to record the player occupancy in each level.
 
const TYPE_MESSAGE = 1;
const TYPE_PRESENCE = 2;
 
const DB_NAME = window.DB_NAME = fabric_name;
const BASE_URL = window.BASE_URL = cluster;
const TENANT = window.TENANT = tenant;
 
const fabric = window.jsC8(`https://${BASE_URL}`);
async function login() {
  await fabric.login(tenant, username, password);
  fabric.useTenant(tenant);
  fabric.useFabric(fabric_name);
}
 
async function collection() {
  await fabric.login(tenant, username, password);
  fabric.useTenant(tenant);
  fabric.useFabric(fabric_name);
  const collection = fabric.collection('occupancy');
  const result = await collection.exists();
  if (result === false) {
    await collection.create()
    console.log("Collection Creation")
    const data = { _key: "123", one: 0, two: 0, three: 0 };
    const info = await collection.save(data);
  }
}
 
The following code fragment creates geo-replicated streams for each level to publish and subscribe players position and state of the game globally in realtime. These streams at each level are also used for player presence detection.
 
async function init(currentLevel) {
 ....
 ....
 
  var producerURL = `wss://${BASE_URL}/_ws/ws/v2/producer/persistent/${tenant}/c8global.${fabric_name}/stream-level-${currentLevel}/${window.UniqueID}`;
 
  var consumerURL = `wss://${BASE_URL}/_ws/ws/v2/consumer/persistent/${tenant}/c8global.${fabric_name}/stream-level-${currentLevel}/${window.UniqueID}`;
 
 
The following code fragment covers the subscriber part of the streams to process various game events and player positions in realtime.
 
// Streams
  var consumer = window.macrometaConsumer = new WebSocket(consumerURL);
  consumer.onopen = () => {
    console.log("WebSocket consumer is open");
  }
  consumer.onerror = () => {
    console.log('Failed to establish WS connection for level');
  }
  consumer.onclose = (event) => {
    console.log('Closing WS connection for level');
  }
  consumer.onmessage = (message) => {
    console.log("==========");
    const receiveMsg = JSON.parse(message.data);
    const ackMsg = { "messageId": receiveMsg.messageId };
    consumer.send(JSON.stringify(ackMsg));
    message = JSON.parse(message.data);
    message.properties.position = {
      'x': message.properties.x,
      'y': message.properties.y
    };
    var messageEvent = {
      'message': message.properties,
      'sendByPost': false, // true to send via posts
      'timeToken': message.properties.timeToken || 0
    };
    if (message.payload !== 'noop') {
      if (messageEvent.message.macrometaType == TYPE_MESSAGE) {
        if (messageEvent.message.uuid === window.UniqueID) {
          return; // this blocks drawing a new character set by the server for ourselve, to lower latency
        }
        window.globalLastTime = messageEvent.timetoken; // Set the timestamp for when you send fire messages to the block
        if (messageEvent.message.int == 'true' && messageEvent.message.sendToRightPlayer === window.UniqueID) { // If you get a message and it matches with your UUID
          window.globalLevelState = getLevelState(messageEvent.message.currentLevel);
          window.StartLoading(); // Call the game state start function in onLoad
        }
        if (window.globalOtherHeros) { // If player exists
          if (!window.globalOtherHeros.has(messageEvent.message.uuid)) { // If the message isn't equal to your uuid
            window.globalGameState._addOtherCharacter(messageEvent.message.uuid); // Add another player to the game that is not yourself
 
            let numOthers = (window.globalOtherHeros) ? window.globalOtherHeros.size : 0;
            window.sendKeyMessage({}); // Send publish to all clients about user information
            const otherplayer = window.globalOtherHeros.get(messageEvent.message.uuid);
            otherplayer.position.set(parseInt(messageEvent.message.x), parseInt(messageEvent.message.y));
            otherplayer.initialRemoteFrame = parseInt(messageEvent.message.frameCounter);
            otherplayer.initialLocalFrame = window.frameCounter;
            otherplayer.totalRecvedFrameDelay = 0;
            otherplayer.totalRecvedFrames = 0;
            console.log("added other player to (main.js)", otherplayer);
          }
          if (messageEvent.message.x && window.globalOtherHeros.has(messageEvent.message.uuid)) { // If the message contains the position of the player and the player has a uuid that matches with one in the level
            console.dir("receiving another position", messageEvent);
            window.keyMessages.push(messageEvent);
          }
        }
      } // --- end message
      else if (messageEvent.message.macrometaType == TYPE_PRESENCE) {
        console.log("got a presence event");
        if (messageEvent.message.action === window.PRESENCE_ACTION_JOIN) { // If we recieve a presence event that says a player joined the channel 
          if (messageEvent.uuid !== window.UniqueID) {
            window.sendKeyMessage({}); // Send message of players location on screen
          }
        }
        else if (messageEvent.message.action === window.PRESENCE_ACTION_LEAVE || messageEvent.message.action === window.PRESENCE_ACTION_TIMEOUT) {
          try {
            window.globalGameState._removeOtherCharacter(messageEvent.message.uuid); // Remove character on leave events if the individual exists
            console.log("removed other character");
          } catch (err) {
            console.log(err)
          }
        }
      } // --- end presence
    }
  };
 
The following code fragment covers the publisher part of the game at each level.
 
  //producer
  const prodMsg = JSON.stringify({
    'payload': 'realData',
    'properties': {
      'channel': 'realtimephaserFire2',
      'level': currentLevel,
      'macrometaType': TYPE_MESSAGE,
      'int': true,
      'sendToRightPlayer': window.UniqueID,
      'timeToken': Date.now()
    }
  });
  var producer = window.macrometaProducer = new WebSocket(producerURL);
  producer.onclose = (event) => {
    console.log("Document producer closed", event);
  };
  producer.onopen = () => {
    console.log("producer open");
    console.log("attemptSendAnIntMessage, which when received by consumers, starts loading");
    window.macrometaProducer.send(prodMsg);
  }
}
 
The following code fragment is used to get or update the player occupancy  at each level.
 
const QUERY_READ = "FOR doc IN occupancy RETURN doc";
const QUERY_UPDATE = "UPDATE";//`FOR doc IN occupancy REPLACE doc WITH ${JSON.stringify(allOccupancyObj)} IN occupancy`;
async function makeOccupancyQuery(queryToMake, isNegative) {
  if (queryToMake === QUERY_UPDATE) {
    let levelWord = "one";
    switch (myCurrentLevel) {
      case 0: levelWord = "one"; break
      case 1: levelWord = "two"; break
      case 2: levelWord = "three"; break
    }
 
    if (!isNegative || isNegative == null || isNegative == undefined) queryToMake = "FOR " + `doc IN occupancy UPDATE doc WITH {${levelWord}: doc.${levelWord} + 1} IN occupancy RETURN doc`;
    else {
      queryToMake = "FOR " + `doc IN occupancy UPDATE doc WITH {${levelWord}: doc.${levelWord} - 1} IN occupancy RETURN doc`;
    }
  }
 
  const cursor = await fabric.query(queryToMake);
  const obj = await cursor.next();
  allOccupancyObj[0] = obj.one;
  allOccupancyObj[1] = obj.two;
  allOccupancyObj[2] = obj.three;
  updateOccupancyText();
  return queryToMake;
}
 

Loading State

The following code fragment in loadingState.js loads the assets into the scene. We created an object called window.LoadingState with all of the loading state information inside of it. In the init() function, we made the sprite objects in the game look smoother by using the Phaser API this.game.renderer.renderSession.roundPixels = true;
 
In the preload function, we load the JSON level information from the data folder. This information is used to generate the various levels of the game. Then every asset that we will use in the game needs to be preloaded into cache. 
 
Lastly we run the create() function that starts the game and loads whatever the window.globalCurrentLevel is. 
 
window.LoadingState = { // Create an object with all of the loading information inside of it
  init() {
    // keep crispy-looking pixels
    this.game.renderer.renderSession.roundPixels = true; // Make the phaser sprites look smoother
  },
 
  preload() {
    this.game.stage.disableVisibilityChange = true;
 
    // Load JSON levels
    this.game.load.json('level:0', 'data/level00.json');
    this.game.load.json('level:1', 'data/level01.json');
    this.game.load.json('level:2', 'data/level02.json');
 
    this.game.load.image('font:numbers', 'images/numbers.png');
    this.game.load.image('icon:coin', 'images/coin_icon.png');
    this.game.load.image('background', 'images/bg.png');
    this.game.load.image('invisible-wall', 'images/invisible_wall.png');
    this.game.load.image('ground', 'images/ground.png');
    this.game.load.image('grass:8x1', 'images/grass_8x1.png');
    this.game.load.image('grass:6x1', 'images/grass_6x1.png');
    this.game.load.image('grass:4x1', 'images/grass_4x1.png');
    this.game.load.image('grass:2x1', 'images/grass_2x1.png');
    this.game.load.image('grass:1x1', 'images/grass_1x1.png');
    this.game.load.image('key', 'images/key.png');
 
    this.game.load.spritesheet('decoration', 'images/decor.png', 42, 42);
    this.game.load.spritesheet('herodude', 'images/hero.png', 36, 42);
    this.game.load.spritesheet('hero', 'images/gameSmall.png', 36, 42);
    this.game.load.spritesheet('coin', 'images/coin_animated.png', 22, 22);
    this.game.load.spritesheet('door', 'images/door.png', 42, 66);
    this.game.load.spritesheet('icon:key', 'images/key_icon.png', 34, 30);
 
  },
 
  create() {
    this.game.state.start('play', true, false, { level: window.globalCurrentLevel }); // Start Game
  }
};
 

Display Assets on the Screen

Open again your main.js file. The following code fragment creates necessary global variables for the game.
 
window.syncOtherPlayerFrameDelay = 0; //30 frames allows for 500ms of network jitter, to prevent late frames
window.currentChannelName; // Global variable for the current channel that your player character is on
window.currentFireChannelName; // Global variable that checks the current stage you are on window.globalCurrentLevel = 0; // Global variable for the current level (index starts at 0)
window.UniqueID = generateName();
window.globalLevelState = null; // Sets the globalLevelState to null if you aren't connected to the network. Once connected, the level will generate to the info that was on the block.
window.globalWasHeroMoving = true;
window.text1 = 'Level 1 Occupancy: 0'; // Global text objects for occupancy count
window.text2 = 'Level 2 Occupancy: 0';
window.text3 = 'Level 3 Occupancy: 0';
let textResponse1;
let textResponse2;
let textResponse3;
let myCurrentLevel = 0;
let allOccupancyObj = [
  0,
  0,
  0
];
window.updateOccupancyCounter = false; // Occupancy Counter variable to check if the timer has already been called in that scene
window.keyMessages = [];
 

Chat between players

Open chatEngine.js. The following code fragment initializes the chat engine and creates a geo-replicated stream between players to publish and receive chat messages in realtime around the world. The last part of the code adds the chat panel to the game GUI at the bottom.
 
....
....
window.initChatEngine = function () {
    // Don't draw the Chat UI more than once
    if (document.getElementById('chatLog')) return;
    var domChatContent = `
    <div class="chat-container">
        <div class="content">
            <div class="chat-log" id="chatLog"></div>
            <div class="chat-input">
                <textarea
                  id="chatInput"
                  placeholder="message..."
                  maxlength="20000"
                ></textarea>
            </div>
        </div>
    </div>
    `;
 
    let producerURL = `wss://${window.BASE_URL}/_ws/ws/v2/producer/persistent/${window.TENANT}/c8global.${window.DB_NAME}/stream-chat/${window.UniqueID}`;
    let consumerURL = `wss://${window.BASE_URL}/_ws/ws/v2/consumer/persistent/${window.TENANT}/c8global.${window.DB_NAME}/stream-chat/${window.UniqueID}`;
 
    var consumer = new WebSocket(consumerURL);
    consumer.onopen = () => {
        console.log("chatEngine consumer is open");
    }
    consumer.onerror = () => {
        console.log('Failed to establish WS connection for chatEngine');
    }
    consumer.onclose = (event) => {
        console.log('Closing WS connection for chatEngine');
    }
    consumer.onmessage = (message) => {
        message = JSON.parse(message.data);
        if (message.payload !== 'noop' && message.properties && message.properties.text) {
            console.log("payload", message.payload);
            var uuid = message.properties.uuid;
            var text = message.properties.text;
 
            // add the message to the chat UI
            var domContent = `<div class="chat-message"><b>${uuid}:</b> ${text}</div>`;
            chatLog.insertAdjacentHTML('beforeend', domContent);
            scrollBottom();
 
            // add the message to the top of the player's head in game
            var notMe = window.globalOtherHeros.get(uuid);
            if (uuid === window.UniqueID) {
                window.globalMyHero.children[0].text = text.substring(0, 10);
            } else if (notMe) {
                notMe.children[0].text = text.substring(0, 10);
            }
        }
        else {
            //console.log("chat engine gibberish data");
        }
    }
 
    var producer = this.producer = new WebSocket(producerURL);
    producer.onclose = (event) => {
        console.log("chat producer closed");
    };
    producer.onopen = () => {
        console.log("chat producer opened");
    };
 
    setInterval(() => {
        if (producer) producer.send(JSON.stringify({ 'payload': 'noop' }));
    }, 30000);
 
    function sendMessage(e) {
        if (e.keyCode === 13 && !e.shiftKey) e.preventDefault();
        var focussed = chatInput.matches(':focus');
        if (focussed && e.keyCode === 13 && chatInput.value.length > 0) {
            var text = chatInput.value;
            /*ChatEngine.global.emit('message', {
                text: text,
                uuid: window.UniqueID
            });*/
            var obj = {
                'uuid': window.UniqueID,
                'text': text
            };
            var jsonString = JSON.stringify({
                'payload': 'rD',
                'properties': obj
            });
            producer.send(jsonString);
            chatInput.value = '';
        }
    }
 
    function scrollBottom() {
        chatLog.scrollTo(0, chatLog.scrollHeight);
    }
 
    // Add Chat UI to the DOM
    var gameContainer = document.getElementById('game');
    gameContainer.insertAdjacentHTML('beforeend', domChatContent);
 
    // Chat log element
    var chatLog = document.getElementById('chatLog');
 
    // Textarea of the chat UI
    var chatInput = document.getElementById('chatInput');
 
    // Add event listener for the textarea of the chat UI
    chatInput.addEventListener('keypress', sendMessage);
};
 
This post became a lot longer than I originally anticipated, so I'll skip the code explanation for display of player interactions and elements in the game. Savvy javascript developers should be able to understand by looking at the code directly. 
 

Wrapping Up

I hope you enjoyed this tutorial and it helps with your gaming project. If you notice, most of the code is about the game GUI i.e., display of assets, player interactions, etc. This frees the game developer to focus on what matters most. We’re looking forward to launching more multi-player gaming tutorials in the future so stay tuned.
 
 
LOVE IT, HATE IT? LET US KNOW
Durga