Artefact Hunt: Building multiplayer game with VIVERSE Play SDK

Explore our open source example multiplayer project built with PlayCanvas and VIVERSE Play SDK, and learn practical tips on developing a simple multiplayer game yourself


Introduction

Welcome to the wild world of multiplayer game development! Hope you'll enjoy your stay, and eventually become a skilled adventurer yourself!

In this document, we'll share practical experience of developing a multiplayer project using PlayCanvas and VIVERSE Play SDK. We'll briefly go through the project architecture, networking implementation and decisions made along the process.

If you're new to VIVERSE Networking SDK, we strongly recommend revisiting our dedicated tutorial first — PlayCanvas Networking Example Part 01 and Part 02 respectively. Part 02 is particularly useful since it provides gradual introduction to advanced concepts used in this project — like application state, client, snapshot, messages and so on.

circle-info

You can find full project source and published build below:

circle-exclamation

Game Concept

Artefact Hunt is competitive multiplayer game inspired by a family of Tag games, Capture the Flag, and variety of similar game modes — like Mario Kart: Shine Thief, Halo: Oddball, Fortnite: The Getaway, and so forth.

Each players assumes the role of a Hunter — whose goal is to grab an Artefact as quick as possible, and bring it to the Portal before other Hunters steal it from him by collision. When Artefact is picked up on the map or stolen from another Hunter, a powerful Blastwave appears, which knocks away all Hunters in proximity, also destroying the current Artefact's carrier in the process. When Blastwave appears, it produces spectacular slow motion / bullet time effect, affecting all players on the map equally. The Artefact take and retake continues until some Hunter finally brings it to the Portal and thus wins the round. After that the map is reset and the the new round begins.

The original game concept doc — the scope was reduced due to time constraints

Project Overview

Scenes

The project consists of 2 scenes:

  • Main: loaded by default and contains core functionality like App, Client, Loader, Input, Physics, View, as well as Main Screen UI

  • Content: holds together everything related to multiplayer map and session — like graphics, lighting, colliders, Game and Player representations, Container for instantiating networked entities, and game-specific UI. It's loaded additively when user joins multiplayer game by pressing Start in the Main Screen UI (see Asset Loading for more details)

circle-exclamation

Application

app.mjs state.mjs

  • App is the entry point of our application. Its main purpose is to emit update-specific events (like input:update, game:update, physics:update and so on) — so other scripts would update their internal states in particular order. Unlike traditional PlayCanvas approach, where each script uses built-in update method — our implementation helps to enforce execution order, which is crucial for eliminating all sorts of possible issues down the road

  • State is convenient global store which helps to pass data between scripts in immutable way where it's needed. It's based on similar implementation from our Networking Example, and is instantiated by the App during its initialization. In a nutshell, one script may write some value under specific key as a result of its update, and another script will read that value and use it for updating its own internal state, like so:

Asset Loading

loader.mjs

  • As outlined previously, the Loader script performs asset loading for the Content scene, and then instantiates that scene when the user enters multiplayer game. All assets used in Content scene have their preload attribute turned off, and also have game tag applied — so they're ignored during initial PlayCanvas preloading and then loaded on demand when needed

User Input

input.mjs

  • Input is responsible for handling raw inputs like keyboard, mouse and touch, and converting them into virtual controls like movepad and pointer, which are then used for player movement and UI elements. For mobile devices, we're using internal heuristics to discern between touch moves and taps to allocate them to movepad and pointer respectively

Networking

client.mjs snapshot.mjs

  • Client encapsulates all functionality related to networking by implementing VIVERSE Matchmaking and Multiplayer SDKs. It keeps internal representation of networked game state in a form of Snapshot object, and exposes it to other scripts to read and write to, via globally accessible this.app.snapshot variable

  • Snapshot is a data structure representing networked game state, which is constantly updated by incoming and outgoing messages. For more details, please see Multiplayer Implementation below

Entities

game.mjs player.mjs container.mjs hunter.mjs artefact.mjs portal.mjs blastwave.mjs etc

  • Game is a static Entity responsible for administrative part of the experience — i.e. managing meta state, when and where the Artefact or Portal are spawned, updating Blastwave internal timer (that affects Physics time dilation), and a few other things. Even though Game entity exists for all clients locally, only Master client is responsible for updating it — other clients just receive those updates

  • Player is another static Entity which represents the user and its associated data - i.e. the username and the current score. It's responsible for respawning a Hunter when user clicks a button, and incrementing user score if associated Hunter brings an Artefact to a Portal. Each client updates its own Player entity locally, and those updates are broadcasted to other clients via snapshot.update(...) call. So if everything runs correctly, the amount of player entries in the Snapshot should correspond to amount of users currently connected to the Room

  • Container acts as a manager for dynamic networked Entities, trying to sync their presence with the Snapshot data. With each update, it goes through the list of current Snapshot entries, and compares it with the list of its children (i.e. already instantiated Entities):

    • If new Snapshot entry exists, but no corresponding Entity can be found — that Entity is created and linked to that entry

    • If Entity is still present, but its corresponding Snapshot entry no longer exists (i.e. was deleted) — that Entity is destroyed

  • Hunter is dynamic Entity encapsulating a physical embodiment of player on the map. Hunter can be spawned or destroyed multiple times during the game, it has position and velocity which are updated locally from the Input, and also health which is reduced to zero in the event of the Artefact steal. Hunter's implementation is the most extensive in this project, so for convenience it's divided into multiple parts:

    • hunter.mjs : main script responsible for snapshot handling and updating submodules

    • hunter.move.mjs : a submodule containing everything related to movement, both local and remote, with physics and client-side prediction

    • hunter.int.mjs : responsible for handling interactions with the Artefact, Portal, and other Hunters, and sending corresponding actions to VIVERSE servers for competition resolution

    • hunter.visual.mjs : handles various visual aspects like blob shadow, carrier's red glow, and so on

  • Artefact is another important dynamic Entity in our game. Similar to the Hunter, it can be spawned and destroyed, has position on the map, but also a carrier attribute that refers to id of a Hunter that is currently carrying it. And similar to the Game, it's replicated for all clients locally, but only Master client is responsible for creating and updating it

  • Portal is one more dynamic Entity, but a much simpler one. It has only position, and mostly provides just a trigger area for current Carrier to collide with, in order to win the round. And similar to the Artefact, only Master client can spawn it

  • Blastwave is the last dynamic Entity controllable by Master client. It's created during Artefact pickup or steal, and exposes dynamically changing slowmo attribute that affects local Physics timescale for all clients in the game. See Physics section below for more details

Physics

physics.mjs collider.mjs

  • Physics script discontinues default PlayCanvas physics update and updates simulation in particular order instead — after game:update but before game:postupdate event. But its main side feature is to implement slow motion / bullet time effect during Artefact pickup or steal, by dynamically dilating update time

  • Collider provides special collision groups and masks for all colliders in the game. This is mostly necessary due to Local and Remote Hunters behaving differently, and how local movement prediction for Remote Hunters is implemented. See Hunter section above for more details

UI

ui.start.mjs ui.input.mjs ui.overlay.mjs

  • Start Screen is the first thing the user sees once our application is loaded. It contains project's title, status message and the Play button. It's prepared in 2 instances - one for desktops, and another one for mobile devices — but only one instance is displayed at any time, depending on current platform. This screen is only visible at the start of the experience and then gets hidden once user joins a multiplayer session

  • Input Screen is container where we display virtual D-Pad on mobile devices. It's a part of the Content Scene, so it's active only during multiplayer game, and only on touch-enabled platforms

  • Overlay Screen is convenient place that contains all game-related UI for the current user — scoreboard, status message, ping and invisible fullscreen button for Hunter respawn. It's a good example of how states of different systems can be aggregated in a single script for visual representation. Similar to the Start Screen, it's prepared both in desktop and mobile versions, with only one version being active depending on user platform

Multiplayer Implementation

Client

It all starts with a Client, which is an improved version of the one used in our original Networking Example:

  • It initializes matchmaking and multiplayer systems via corresponding Viverse SDKs

  • Creates an Actor which is a container for relevant user data, in particular session_id

  • Joins available Room or creates one if no rooms are available

In a context of a multiplayer, the Client's most important function is to exchange data with all other Clients currently the Room. For this, VIVERSE SDK provides two mostly used communication channels:

  • Messages — arbitrary JSON objects that can be broadcast to all other Clients in the Room

  • Actions — specialized objects designed to resolve a dispute between multiple Clients competing for the same outcome

Unlike dedicated solutions like Photonarrow-up-right or Colyseusarrow-up-right which rely on authoritative servers as a source of ground truth, VIVERSE Multiplayer is a P2P system — it receives events from a user and broadcasts them to other users. This implementation comes with a few important considerations:

  • There is no global store for the current game state, so each Client should keep it's own local copy, which we call a Snapshot

  • If a new Client joins the game — it should still be able to derive the current game state from somewhere. That means that each connected Client should constantly broadcast updates about Entities it owns, so other Clients could pick up those updates and reconstruct their local game states accordingly. In Artefact Hunt we do that on each frame tick — so 60 times per second

  • And finally, some Client should play a role of a Master on top of its regular Client's duties. Master Client is responsible for updating game-level logic and handling various administrative tasks — for example, when and where to spawn an Artefact or a Portal, how to handle abandoned Entities owned by a suddenly disconnected Client, and so on. In VIVERSE Multiplayer, is_master_client is assigned to Actor automatically, and in case of one's disconnect — reassigned to another one as well

Snapshot

Snapshot is essential concept placed at the heart of our networking implementation. It's a key-value map consisting of multiple Entries — plain JSON objects with id, type and additional attributes:

It's a minimum viable representation of our current game state — what entities currently exist in the world, what are their internal states, attribute values, and so on. Because our networking is essentially P2P, there is no single source of truth for that — each Client keeps its own local Snapshot, trying to meaningfully synchronize it with other Clients in the Room.

The synchronization mechanic is built around 2 atomic operations: update and delete. Please note that create is not used since it can be functionally replaced with updating a non-existent entry. Below is a stripped down code illustrating that with greatly simplified Snapshot and Client scripts:

Entity

Where each Snapshot Entry is just a plain JSON object, the Entity is a physical manifestation of that object in the game world. Each Entity has a corresponding script attached to it, which is responsible for processing the Snapshot Entry associated with this Entity.

All Entities could be divided into 2 types and 2 categories respectively:

Entities
Owner
Local
Remote

player hunter

Client

Updates internal state of an Entity and broadcasts modified Snapshot entry to other clients

Receives updated Snapshot entry, stores it locally, and updates Entity's internal state based on it

game artefact portal blastwave

Master

Updates internal state of an Entity and broadcasts modified Snapshot entry to other clients

Receives updated Snapshot entry, stores it locally, and updates Entity's internal state based on it

Types by ownership:

  • Client: these entities are handled by their respective owning Clients, and they have a distinctive owner attribute in their Snapshot entries. In practise it means that only associated Client can modify that Snapshot entry locally, and then broadcast those updates to all other clients for synchronization

  • Master: these entities can be handled only by the Master Client, so in that sense they're implicitly owned by it. Other than that, it's the same principle — the owner (Master Client) updates associated Snapshot entries, and these updates are propagated to all other clients across the network

From particular Client's perspective, each Snapshot Entry (and therefore associated Entity) can be categorized either as Local or Remote:

  • Local: practically means that this Entity should be updated by this Client, and result of that update should be broadcast to other Clients in the Room

  • Remote: means that this Entity is expected to be updated by some other Client remotely, so our Client should just receive an update event and merge it into local Snapshot Entry

Final words

This project is less about one specific game and more about exploring the best dev practices with a given set of constrains. If you’re building your own multiplayer game — the biggest takeaway here is to keep your game state simple, observable, and reproducible. A clear core loop, a shared Snapshot model, and a small set of message types (update / delete) can go a long way — especially when paired with a single Master Client responsibility for spawning and cleanup.

The project is open source and licensed under MIT. Please feel free to build on top of it and share your results back with the community. Hope it will be you who brings us the next Web Multiplayer Game of the Year — and VIVERSE is definitely the right place to assist you with that!

Last updated

Was this helpful?