Friday, 14h, I saw this game. The premisse is simple, combine the four initial elements “water”, “fire”, “earth” and “wind” to create basically everything. Put “Water” and “Earth” together you get a “Plant”, “Plant” and “Water” you get a “Tree”, “Tree” + “Tree” you get a “Forest” and so on. Somehow by the end of one hour playing I was able to recreate all Greek goods, and Michael Jackson. The funny part is when you mix Michael Jackson with Zeus: ow hey, I discovered something new that no one ever did, the feeling was incredible!
It’s a browser game, which uses AI to mix stuff up and choose an emoji to represent it. It’s well trained and beautifully crafted: simple, responsive. But something was missing for me. You know when you are into something, and you can't stop seeing it anywhere? Well, turns out a few months ago I started working as Dev Advocate at SuperViz, and I really had to go deep in our products and stuff... and I'm telling you this because I was playing the game and all I could imagine was adding this product I was documenting to it. In other words: creating a multiplayer version of Infinite Craft.
So, I did it. Mainly because I needed a way to justify me playing a game all Friday afternoon to my boss, but I did it. How? TL/DR: I created a Chrome Extension that injects some script into the game and used SuperViz’s Real Time Data Engine event broker to do the rest.
First, of course, I tried to see if the code was open source. It was not, ok. Should I create a website with an iFrame of the game and inject something over there? I did this once in the past and it shouldn’t be that hard. Wait: a better idea! A browser extension. Sure, on mobile it would be a problem, but I just need to know if what I was trying to accomplish would work.
There’re a few years that I wanted to write an extension for Microsoft Edge again, to see how it evolved since last time in 2015. Not much. Even though my first Edge Extension was for its legacy, it was always based and fully compatible with Google Chrome.
Developing an extension is easy, it’s all about the manifest.json
file. You set the name, description, an action for the default popup (when you click on the extension icon), the icon and a few bits I will tell you later.
{
"manifest_version": 3,
"name": "Infinite Craft - Multiplayer extension",
"description": "This extension allows you to play Infinite Craft with your friends!",
"version": "1.0",
"action": {
"default_popup": "popup.html"
}
}
I put an html
file to load when you click on the popup, but the content is irrelevant to this article. The real magic happens on the content_scripts
, that is the scripts that I will inject into the page. I have a matches
and the JS files, the first one I can filter to only inject into the Infinite Craft game, the second one I can create an alert(”Hello World”) to check if it’s working.
"content_scripts": [
{
"matches": [
"<all_urls>" // I allowed every page because I like to live dangerously
],
"js": [
"js/vendor.js",
"js/content_script.js"
]
}
],
So, in the first moment I was trying to do it all in JavaScript Vanilla, with no npm packages whatsoever. SuperViz has a CDN way to use in HTML files so I thought that would be easier. I was wrong: it needed some permissions on the manifest. I could have figured it out, but I was pissed off that I had no TypeScript to help me, no .eslint
, no familiar environment for me. This was already a challenge to write a multiplayer game, so why would I put myself a challenge that made it hard for myself? No way.
So, you can do this with simple JS, but for me, I had to add TypeScript and React. Moving on…
The idea was to create a Header for the game where it would have the options for the multiplayer, so I need to inject one element (or in this case, a React component) inside the page, and in this case before their container. My content script was something that:
const initInfiteCraftMultiplayer = () => {
const container = document.querySelector('.container')
if (container && container.parentNode) {
const multiplayerHeader = document.createElement('div')
const root = createRoot(multiplayerHeader)
root.render(<Extension />)
container.parentNode.insertBefore(multiplayerHeader, container)
}
}
;(() => {
initInfiteCraftMultiplayer()
})()
Awesome! Now I have a whole React Interface to play along with.
On the header (and after a few CSS injection as well), I had a button to join a room. When clicked it would start the room (I will show it later). After the room started, I was time to create a link with a unique ID for everyone to join it.
export default function ShareRoom({ roomId }: { roomId: string }) {
const [buttonText, setButtonText] = React.useState('Share room')
const urlToShare = `https://neal.fun/infinite-craft/?roomId=${roomId}`
const copyToClipboard = () => {
setButtonText('Copied!')
navigator.clipboard.writeText(urlToShare)
setTimeout(() => {
setButtonText('Share room')
}, 1000)
}
return <button onClick={() => copyToClipboard()}>{buttonText}</button>
}
I would use this roomId
from the URL later to define who was playing with who.
So now I need to make it happen. To start a room for people to play. I knew that capturing the movements and elements would not be trivial, so I needed to make something first, to make sure everything was working well. The first component I did was a presence indicator: Who-Is-Online.
It shows avatars of people that had joined the room with you, and it’s quite simple to add, but first I need a room.
const room = await SuperVizRoom(DEVELOPER_KEY, {
roomId: roomId,
group: {
id: 'vtn-multiplayer',
name: 'Vitor Norton Multiplayer Group',
},
participant: {
id: participantId,
name: 'Vitor Norton',
},
})
The roomId
is something that was generated automatically when clicking on the share button, but if you entered a page with this parameter it will prefer to use the parameter (like seen above) and will not wait until clicking on the button “Create a room”.
As this is a simple test for me, I didn’t create an interface or something that would allow the user to change its name. I’ve could do it on the index.html
file that I mentioned early or when entering a room popup, a modal asking the name or even generate random names like Google Docs does.
I also needed a SuperViz key, as it's an SDK that made everything easier for this project. For testing and developing is free. Nice!
Now I have a room, let’s add the component I mentioned early:
const whoisonline = new WhoIsOnline('room-list')
room.addComponent(whoisonline)
That’s it! The room-list
value is the ID of one HTML element where I want it to render. Now when other people join the room, I will know that I’m not alone.
Ok, so now, all I have is the hardest part. I grabbed some coffee, broke it into small tasks until we had a playable game and then I was ready to go.
The magic will be made, like I said before, with the Real-time Data Engine. It's an event broker, so I can subscribe to events and publish new ones. That means that if I drag something on the canvas, I can publish an event for every participant in the room to have the same particle in the same position. This is the easy step, later we will add the merger functions and if something is created on one screen it should be created for every person in the room.
So, first thing to know is to identify what is the particle, and since I want to use the same name as the original developer Neal, I used Instance for naming it.
export interface Instance {
id: string
name: string
emoji: string
position: Position
}
export interface Position {
x: number
y: number
z: number
}
Done. It has more attributes to it, but this is what we need. Now our mission is to capture when we drag something around. For this I used the MutationObserver
. It observes the mutations of the DOM elements. So if anything changes inside an HTML node I will know. I started observing the instances
area where the particles were dragged, and of course I was listening for its children’s because that is the point.
const instances = document.querySelector('.instances div') as Element
const observer = new MutationObserver(function (mutations: MutationRecord[]) {
// SOME CODE HERE
})
observer.observe(instances, { childList: true })
After this I have created some validations to make sure it is the element I want. I would be dishonest to say this is not a workaround (or Brazilian “Gambiarra”). It’s ugly and perhaps there are better ways to do it, but for me, it worked.
const lastMutation = mutations[mutations.length - 1]
const lastInstance = lastMutation.previousSibling as HTMLElement
if (!lastInstance) return
const style = window.getComputedStyle(lastInstance)
const translate = style.translate
if (!translate) return
const parts = translate.split(' ')
if (parts.length !== 2) return
What this code does is pick the last item on the mutation list, then get its HTMLElement
. If it’s not, then do nothing. Then I would get the style
(the element position is located on the inline style definition under the translate
property). If it doesn’t contain a thing, then return, because it would not be what we want.
But sometimes the translate
would have something and it was still not our element, so I would make sure it has two information (the top and left: x and y position).
After this it is easy to create our instance that we defined early.
const instance: Instance = {
id: lastInstance.id,
name: lastInstance.textContent?.trim().split('\n')[1].trim() || '',
emoji: lastInstance.querySelector('.instance-emoji')?.textContent || '',
position: {
x: x,
y: y,
z: z,
},
}
Done, we have our instance. So, let’s just publish it to everyone in the room. To do it, I used the Real-time Data Engine, by using this code:
const [realtime] = React.useState<Realtime>(new Realtime())
// When starting the room
room.addComponent(realtime)
realtime.subscribe('item-added', newItemAdded)
// When publishing a event
if (lastInstance.getAttribute('data-multiplayed')) return
realtime.publish('item-added', instance)
Easy. But what is this data-multiplayed
? I created it to make sure that it would not publish something that is already onto someone’s board. How did you create it? Well, when adding the new item.
When received a new event with the name item-added
it uses the callback function newItemAdded
to do the trick to us.
const newItemAdded = (item: any) => {
const itemToAdd = getLastElementIfIsList(item)
if (itemToAdd.participantId === participantId) return
InsertInstance(itemToAdd.data as Instance)
}
I only make some validations, like knowing that the participant that added this item is not the same participant on the screen. This would create a infinite loop and will crash your browser, trust me. Don’t ask how I know this.
The InsertInstance
is the easy bit. I use the InstanceGenerator
(shown below) to insert a new adjacent HTML before the end for the .instances div
selector. This will generate the exact HTML of the original user.
const InstanceGenerator = (instance: Instance) => {
return `
<div data-v-32430ce5="" data-multiplayed="true" id="${instance.id}" class="item instance" style="translate: ${instance.position.x}px ${instance.position.y}px; z-index: ${instance.position.z};">
<span data-v-32430ce5="" class="instance-emoji">${instance.emoji}</span>
${instance.name}
</div>`
}
Two things to note here: first, here is the place I add the data-multiplayed="true"
, and last, it uses this data-v-32430ce5
that changes on every new build/version that the game developer makes. This is one point of code improvement here, when loading a room, to store the new value of this property name and uses this new value instead of this hard coded solution.
Now we can see how is online on the page, when one participant creates a new instance, it creates to every other participant in the room. Nice!
It’s perfect? No. Here’s why:
- Neal, who is the original developer, can make changes to the game and it will break my extension. I can keep the code working by trying to make changes at the same time, but a partnership with the developer would be awesome (Hey Neal, DM me!)
-
The IDs of the instances can be duplicated because the way the game generates is a sequential one. This means that if someone creates an instance on the Participant-A it will receive an
instance-10
id. If a new element by Participant-A is created it will beintance-11
. The problema with this is that for every other participant in a room (Participant-B, Participant-C), the extension will create ainstance-10
element, but this is not game generated so if any of these participants create a new thing, guess what the id will be? Anotherinstance-10
.There are ways to fix this but having a partnership with the developer would be incredible.
The time required for me to continuously refine and update the extension to keep up with changes made by the original developer is significant. This includes understanding new changes, implementing corresponding updates in the extension, and thoroughly testing to ensure compatibility.
In conclusion, this project was an exciting journey and challenge, but it was really fun to do it. It showcases the endless possibilities when combining creative gaming concepts with powerful tools like SuperViz's Real-Time Data Engine.
As with any open-source project, contributions are welcome and greatly appreciated. The GitHub repository for Infinite Craft's multiplayer extension is open for anyone who wants to contribute, whether it's fixing bugs, adding new features, or improving the existing code, or just to play with it.
Top comments (8)
Every time I want to know more things about IT. And it is good to know. Good article, Norton
Thanks!
Awesome! And look, this is not "Gambiarra." It's fantastic technological magic! Thanks for sharing with the community!
Brazilian does the best tech magic ever! 💜
Great article!
Thanks
Awesome <3
Thanks