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. Afterward, you'd have an edge native game with real time-data streaming and data storage.
It's okay to be excited.
In this tutorial, I'm going to show you how to use Macrometa to create a multiplayer, 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).
Play CastleKnight 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 Ninja Multiplayer Platformer originally published by pubnub. The pubnub example tutorial can be found here.
Why online games need real-time
If you want real-time interaction among players, pubnub or socket.io won't work 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 received 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 out there for writing games, most of them create more problems when you start handling real-time multiplayer interactions & state over tens and hundred of locations globally.
This is where something like Macrometa comes in. It provides ready made APIs, enabling you and your teams to build, deploy and run cross-region or globally – in real-time.
As Macrometa's Chief Architect, I am obviously biased. :-) So don’t take my word as fact. The point of this post is for you to try things yourself to determine what's easy or not. So let’s build a global, real-time, online multiplayer game called CastleKnight together!
After that, you can decide for yourself if Macrometa saves you and your team lots of time to focus on developing your game.
This game is a collaborative puzzle game that encourages you to work with your friends to collect the keys in clever ways.
Multiplayer game requirements
Following are some useful capabilities we want incorporate into this game :
- Real-time Multi-player Game State i.e., reliable real-time game state and events and inputs across all connected players.
- 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., real-time roster of players
- Live Statistics and Scores i.e., real-time score updates and game statistics to dashboards
- 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, but I think for this post the above is sufficient. You are welcome to add more functionality.
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.
Tutorial: Building a Multiplayer Game
Step1: Get a Macrometa account
Obviously you'll need a Macrometa account for this tutorial, but don't worry, it's free to try. Go ahead and sign up here.
After signup, you will receive a tenant account along with credentials. 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 machine.
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 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>/*"</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!
Step 5. 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;
}
Step 6. 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
}
};
Step 7. 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 = [];
Step 8. 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>
<div class="content"></div>
<div class="chat-log" id="chatLog"></div>
<div class="chat-input"></div>
id="chatInput"
placeholder="message..."
maxlength="20000"
>
`;
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.
Final Step: 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 multiplayer gaming tutorials in the future so stay tuned.
Check out other Sample Apps we've built including how to build your very own crypto arbitrage bot.