DEV Community

Cover image for How I made Infinite Craft a multiplayer game with a few lines of code
Vitor Norton
Vitor Norton

Posted on • Updated on

How I made Infinite Craft a multiplayer game with a few lines of code

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
      ]
    }
  ],
Enter fullscreen mode Exit fullscreen mode

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()
})()

Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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',
    },
})
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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.

List of participants in a room

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
}
Enter fullscreen mode Exit fullscreen mode

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 })
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
    },
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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>`
}
Enter fullscreen mode Exit fullscreen mode

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 be intance-11. The problema with this is that for every other participant in a room (Participant-B, Participant-C), the extension will create a instance-10 element, but this is not game generated so if any of these participants create a new thing, guess what the id will be? Another instance-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)

Collapse
 
sanarielsen profile image
Samuel 'Sanarielsen' Henrique

Every time I want to know more things about IT. And it is good to know. Good article, Norton

Collapse
 
vtnorton profile image
Vitor Norton

Thanks!

Collapse
 
morgannadev profile image
Morganna

Awesome! And look, this is not "Gambiarra." It's fantastic technological magic! Thanks for sharing with the community!

Collapse
 
vtnorton profile image
Vitor Norton

Brazilian does the best tech magic ever! 💜

Collapse
 
pachicodes profile image
Pachi 🥑

Great article!

Collapse
 
vtnorton profile image
Vitor Norton

Thanks

Collapse
 
carlossantos74 profile image
Carlos Santos

Awesome <3

Collapse
 
vtnorton profile image
Vitor Norton

Thanks