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
Why you need Macrometa for your games

Macrometa as a geo distributed backend for Games - Capabilities
- 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.
Building the Game
Step1: Get a Macrometa account

Step2: Clone the source code

Step3: Configure the game to use 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:
python -m SimpleHTTPServer 8000
Running from S3:
- Note: The bucket needs to be public in order for the website to be visible.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::<your-s3-bucket-name>/*"
}
]
}
Understanding the Code
Initialize the game
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);
}
}
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}`;
// 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
}
};
//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);
}
}
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
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
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
....
....
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);
};