Tutorial: ๐Ÿ•น๏ธ Multiblaster

Copyright ยฉ 2024 Croquet Labs

This tutorial will guide you through building a complete multiplayer game:

Screencapture

๐Ÿ•น๏ธ CLICK HERE TO PLAY ๐Ÿ•น๏ธ โ€“ then scan the QR code or share the generated session URL to invite other players.

Each HTML file in this repository contains an increasingly complete multiplayer game built using Croquet.

It's a 2D game, and its visuals are intentionally kept simple so that the code is more understandable.

The game has asteroids and ships floating in space. If an objects goes beyond the screen edge, it comes back in on the other side. Players steer their ships by firing thrusters (left, right and forward). They can also shoot blasts which cause asteroids to break up and vanish. Successful blasts increase the player's score, while colliding with an asteroid causes a ship to be destroyed and lose all points.

Step 0: Asteroids floating without Croquet ๐Ÿชจโ‰ ๐Ÿชจ

(full source code) (run it)

This is a non-Croquet app. It shows a few asteroids floating through space. If you run this in two windows, the asteroids will float differently.

Each asteroid has an x and y position as well as an a angle. Additionally, it has dx, dy and da properties which represent the amount to change the position and angle in every time step to animate the object in its move() method:

move() {
    this.x = (this.x + this.dx + 1000) % 1000;
    this.y = (this.y + this.dy + 1000) % 1000;
    this.a = (this.a + this.da + Math.PI) % Math.PI;
    setTimeout(() => this.move(), 50);
}

The movement is restricted to the playfield's size (0 to 1000) using remainder math (the % operator). This causes the asteroid to come back in on the other side when it floats out.

The playfield is a 1000โจ‰1000 canvas scaled to fit in the window. Asteroids are drawn using plain white strokes in the update() function:

for (const asteroid of asteroids) {
    const {x, y, a} = asteroid;
    context.save();
    context.translate(x, y);
    context.rotate(a);
    context.beginPath();
    context.moveTo(+40,  0);
    context.lineTo( 0, +40);
    context.lineTo(-40,  0);
    context.lineTo( 0, -40);
    context.closePath();
    context.stroke();
    context.restore();
}

This file has about 80 lines of code total.

Step 1: Asteroids synchronized with Croquet ๐Ÿชจโ‰ก๐Ÿชจ

(full source code) (run it)

This is the same app, but using a Croquet Model for the asteroids.

A session name and password is automatically appended to the URL. If you open that session URL in another window or on another device, the asteroids will float exactly the same in both.

The app is devided into two parts: The "model" is the part that is synchronized by Croquet for all users. It is like a shared computer that all users directly interact with. The other part is the "view", which displays the model to the user by drawing the asteroids on a canvas. These parts are subclassed from Croquet.Model and Croquet.View, respectively.

The last few lines instruct Croquet to join a session for a particular model and view class via Croquet.Session.join(). The name and password for this session are taken from the current URL, or generated automatically using autoSession() and autoPassword. It also needs an API key. You should fetch your own key from croquet.io/keys.

This version has only 20 lines more than the non-Croquet one from step 0.

Notice that the computation looks exactly the same, no special data structures need to be used, all models are synchronized between machines without any special markup.

move() {
    this.x = (this.x + this.dx + 1000) % 1000;
    this.y = (this.y + this.dy + 1000) % 1000;
    this.a = (this.a + this.da + Math.PI) % Math.PI;
    this.future(50).move();
}

The only new construct is the line

this.future(50).move();

inside of the move() method. This causes move() to be called again 50 ms in the future, similarly to the timeout() call in step 0. Future messages are how you define an object's behavior over time in Croquet.

Drawing happens exactly the same as in the non-Croquet case:

for (const asteroid of this.model.asteroids) {
    const { x, y, a, size } = asteroid;
    this.context.save();
    this.context.translate(x, y);
    this.context.rotate(a);
    this.context.beginPath();
    this.context.moveTo(+size,  0);
    this.context.lineTo( 0, +size);
    this.context.lineTo(-size,  0);
    this.context.lineTo( 0, -size);
    this.context.closePath();
    this.context.stroke();
    this.context.restore();
}

Notice that the view's update() method can read the asteroid positions directly from the model for drawing. Unlike in server-client computing, these positions do not need to be transmitted via the network. They are already available locally.

However, you must take care to not accidentally modify any model properties directly, because that would break the synchronization. See the next step for how to interact with the model.

Step 2: Spaceships controlled by players ๐Ÿ•น๏ธโžก๐Ÿš€

(full source code) (run it)

This step adds interactive space ships.

For each player joining, another spaceship is created by subscribing to the session's view-join and view-exit events:

    init() {
        ...
        this.ships = new Map();
        this.subscribe(this.sessionId, "view-join", this.viewJoined);
        this.subscribe(this.sessionId, "view-exit", this.viewExited);
    }

    viewJoined(viewId) {
        const ship = Ship.create({ viewId });
        this.ships.set(viewId, ship);
    }

    viewExited(viewId) {
        const ship = this.ships.get(viewId);
        this.ships.delete(viewId);
        ship.destroy();
    }

Each ship subscribes to that player's input only, using the player's viewId as an event scope. This is how the shared model can distinguish events sent from different user's views:

init({ viewId }) {
    ...
    this.left = false;
    this.right = false;
    this.forward = false;
    this.subscribe(viewId, "left-thruster", this.leftThruster);
    this.subscribe(viewId, "right-thruster", this.rightThruster);
    this.subscribe(viewId, "forward-thruster", this.forwardThruster);
    this.move();
}

leftThruster(active) {
    this.left = active;
}

rightThruster(active) {
    this.right = active;
}

forwardThruster(active) {
    this.forward = active;
}

The ship's move() method uses the stored thruster values to accelerate or rotate the ship:

move() {
    if (this.forward) this.accelerate(0.5);
    if (this.left) this.a -= 0.2;
    if (this.right) this.a += 0.2;
    ...
}

In the local view, key up and down events of the arrow keys publish the events to enable and disable the thrusters:

document.onkeydown = (e) => {
    if (e.repeat) return;
    switch (e.key) {
        case "ArrowLeft":  this.publish(this.viewId, "left-thruster", true); break;
        case "ArrowRight": this.publish(this.viewId, "right-thruster", true); break;
        case "ArrowUp":    this.publish(this.viewId, "forward-thruster", true); break;
    }
};
document.onkeyup = (e) => {
    if (e.repeat) return;
    switch (e.key) {
        case "ArrowLeft":  this.publish(this.viewId, "left-thruster", false); break;
        case "ArrowRight": this.publish(this.viewId, "right-thruster", false); break;
        case "ArrowUp":    this.publish(this.viewId, "forward-thruster", false); break;
    }
};

In Croquet, publish and subscribe are used mainly to communicate events from the user's view to the shared model, typically derived from user input. Unlike in other pub/sub systems you may be familiar with, Croquet's pub/sub is not used to synchronize changed values or to communicate between different devices. All communication is only between the local view and the shared model.

Before joining the session, makeWidgetDock() enables a QR code widget in the lower left corner. This allows you to join the same session not only by copying the session URL but also by scanning this code with a mobile device.

Step 3: Firing a blaster ๐Ÿ•น๏ธโžกโ€ขโ€ขโ€ข

(full source code) (run it)

When pressing the space bar, a "fire-blaster" event is published. The ship subscribes to that event and creates a new blast that moves in the direction of the ship:

fireBlaster() {
    const dx = Math.cos(this.a) * 20;
    const dy = Math.sin(this.a) * 20;
    const x = this.x + dx;
    const y = this.y + dy;
    Blast.create({ x, y, dx, dy });
}

The blast registers itself with the game object when created, and removes itself when destroyed. This is accomplished by accessing the Game as the well-known modelRoot.

get game() { return this.wellKnownModel("modelRoot"); }

The blast destroys itself after a while by counting its t property up in every move step:

move() {
    this.t++;
    if (this.t > 30) {
        this.destroy();
        return;
    }
    ...
}

Step 4: Break up asteroids when hit by blasts ๐Ÿชจโžก๐Ÿ’ฅ

(full source code) (run it)

In this step we add collision detection between the blasts and the asteroids. When hit, Asteroids split into two smaller chunks, or are destroyed completely.

To make this simpler, the individual future messages are now replaced by a single mainLoop() method which calls all move() methods and then checks for collisions:

mainLoop() {
    for (const ship of this.ships.values()) ship.move();
    for (const asteroid of this.asteroids) asteroid.move();
    for (const blast of this.blasts) blast.move();
    this.checkCollisions();
    this.future(50).mainLoop();
}

When an asteroid was hit by a blast, it shrinks itself and changes direction perpendicular to the shot. Also it creates another asteroid that goes into the opposite direction. This makes it appear as if the asteroid broke into two pieces:

hitBy(blast) {
    if (this.size > 20) {
        this.size *= 0.7;
        this.da *= 1.5;
        this.dx = -blast.dy * 10 / this.size;
        this.dy = blast.dx * 10 / this.size;
        Asteroid.create({ size: this.size, x: this.x, y: this.y, a: this.a, dx: -this.dx, dy: -this.dy, da: this.da });
    } else {
        this.destroy();
    }
    blast.destroy();
}

Step 5: Turn ship into debris after colliding with asteroids ๐Ÿš€โžก๐Ÿ’ฅ

(full source code) (run it)

Now we add collision between ships and asteroids, and turn both into debris which is floating for a while.

We do this by adding a wasHit property that normally is 0, but gets set to 1 when hit. It then starts counting up for each move() step just like the t property in the blast, and after a certain number of steps destroys the asteroid and resets the ship:

move() {
    if (this.wasHit) {
        // keep drifting as debris for 3 seconds
        if (++this.wasHit > 60) this.reset();
    } else {
        // process thruster controls
        if (this.forward) this.accelerate(0.5);
        if (this.left) this.a -= 0.2;
        if (this.right) this.a += 0.2;
    }
    ...
}

Also, while the ship's wasHit is non-zero, its move() method ignores the thruster controls, and the blaster cannot be fired. This forces the player to wait until the ship is reset to the center of the screen.

The drawing code in the view's update() takes the wasHit property to show an exploded version of the asteroid or ship. Since wasHit is incremented in every move step, it determines the distance of each line segment to its original location:

if (!wasHit) {
    this.context.moveTo(+20,   0);
    this.context.lineTo(-20, +10);
    this.context.lineTo(-20, -10);
    this.context.closePath();
} else {
    const t = wasHit;
    this.context.moveTo(+20 + t,   0 + t);
    this.context.lineTo(-20 + t, +10 + t);
    this.context.moveTo(-20 - t * 1.4, +10);
    this.context.lineTo(-20 - t * 1.4, -10);
    this.context.moveTo(-20 + t, -10 - t);
    this.context.lineTo(+20 + t,   0 - t);
}

Step 6: Score points when hitting an asteroid with a blast ๐Ÿ’ฅโžก๐Ÿ†

(full source code) (run it)

Add scoring for ships hitting an asteroid. When a blast is fired, we store a reference to the ship in the blast.

fireBlaster() {
    ...
    Blast.create({ x, y, dx, dy, ship: this });
}

When the blast hits an asteroid, the ship's scored() method is called, which increments its score:

hitBy(blast) {
    blast.ship.scored();
    ...
}

The update() method displays each ship's score next to the ship. Also, to better distinguish our own ship, we draw it filled. We find our own ship by comparing its viewId to the local viewId:

update() {
    ...
    this.context.fillText(score, 30 - wasHit * 2, 0);
    ...
    if (viewId === this.viewId) this.context.fill();
    ...
}

Step 7: View-side animation smoothing ๐Ÿคฉ

(full source code) (run it)

Now we add render smoothing for 60 fps animation. The models move at 20 fps (because of the 50 ms future send in the main loop) but for smooth animation you typically want to animate at a higher fps. While we could increase the model update rate, that would make the timing depend very much on the steadiness of ticks from the reflector. Instead, we do automatic in-betweening in the view by decoupling the rendering position from the model position, and updating the render position "smoothly."

The view-side objects with the current rendering position and angle are held in a weak map:

this.smoothing = new WeakMap();

It maps from the model objects (asteroids, ships, blasts) to plain JS objects like {x, y, a} that are then used for rendering. Alternatively, we could create individual View classes for each Model class by subclassing Croquet.View, but for this simple game that seems unnecessary. With the WeakMap approach we avoid having to track creation and destruction of model objects.

The initial values of the view-side objects are copied from the model objects. In each step, the difference between the model value and view value is calculated. If the difference is too large, it means the model object jumped to a new position (e.g. when the ship is reset), and we snap the view object to that new position. Otherwise, we smoothly interpolate from the previous view position to the current model position. The "smooth" factor of 0.3 can be tweaked. It works well for a 20 fps simulation with 60 fps rendering, but works pretty well in other cases too:

smoothPos(obj) {
    if (!this.smoothing.has(obj)) {
        this.smoothing.set(obj, { x: obj.x, y: obj.y, a: obj.a });
    }
    const smoothed = this.smoothing.get(obj);
    const dx = obj.x - smoothed.x;
    const dy = obj.y - smoothed.y;
    // if distance is large, don't smooth but jump to new position
    if (Math.abs(dx) < 50) smoothed.x += dx * 0.3; else smoothed.x = obj.x;
    if (Math.abs(dy) < 50) smoothed.y += dy * 0.3; else smoothed.y = obj.y;
    return smoothed;
}

The rendering in the update() method uses the smoothed x, y and a values and fetches other properties directly from the model objects:

    for (const asteroid of this.model.asteroids) {
        const { x, y, a } = this.smoothPosAndAngle(asteroid);
        const { size } = asteroid;
        ...
    }

This step uses the exact same model code as in step 7, so you can actually run both side-by-side with the same session name and password to see the difference in animation quality.

Step 8: Persistent table of highscores ๐Ÿฅ‡๐Ÿฅˆ๐Ÿฅ‰

(full source code) (run it)

Note: As of Croquet 1.1 (March 2024), persistence is still an unofficial feature. We do not guarantee that the API won't change, or make any assurances as to how long the persisted data will be stored.

Now we add a persistent highscore. Croquet automatically snapshots the model data and keeps that session state even when everyone leaves the session. When you resume it later by joining the session, everything will continue just as before. That means a highscore table in the model would appear to be "persistent".

However, whenever we change the model code, a new session is created, even it has the same name (internally, Croquet takes a hash of the registered model class source code). The old session state becomes inaccessible, because for one we cannot know if the new code will work with the old state, but more importantly, every client in the session needs to execute exactly the same code to ensure determinism. Otherwise, different clients would compute different states, and the session would diverge.

To keep important data from a previous session of the same name, we need to use Croquet's explicit persistence. An app can call persistSession() with some JSON data to store that persistent state. When a new session is started (no snapshot exists) but there is some persisted data from the previous session of the same name, this will be passed into the root model's init() method as a second argument.

We add a text input field for players' initials (or an emoji). Its value is both published to the model, and kept in localStorage for the view, so players only have to type it once.

initials.onchange = () => {
    localStorage.setItem("io.croquet.multiblaster.initials", initials.value);
    this.publish(this.viewId, "set-initials", initials.value);
}

On startup we check localStorage to automatically re-use the stored initials.

if (localStorage.getItem("io.croquet.multiblaster.initials")) {
    initials.value = localStorage.getItem("io.croquet.multiblaster.initials");
    this.publish(this.viewId, "set-initials", initials.value);
}

In the model, we add a highscore table. It is initialized from previously persisted state, or set to an empty object:

init(_, persisted) {
    this.highscores = persisted?.highscores ?? {};
    ...
}

When a player sets their initials, we ensure that no two ships have the same initials. Also, if a player renames themselves, we rename their table entry (you could use different strategies here, depending on what makes most sense for your game):

setInitials(initials) {
    if (!initials) return;
    for (const ship of this.game.ships.values()) {
        if (ship.initials === initials) return;
    }
    const highscore = this.game.highscores[this.initials];
    if (highscore !== undefined) delete this.game.highscores[this.initials];
    this.initials = initials;
    this.game.setHighscore(this.initials, Math.max(this.score, highscore || 0));
}

When a ship scores and has initials, it will add that to the highscore:

scored() {
    this.score++;
    if (this.initials) this.game.setHighscore(this.initials, this.score);
}

The table is only updated if the new score is above the highscore. And if the table was modified, then we call persistSession() with a JSON oject. This is the object that will be passed into init() of the next session (but never the same session, because the same session starts from a snapshot and does not call init() ever again):

setHighscore(initials, score) {
    if (this.highscores[initials] >= score) return;
    this.highscores[initials] = score;
    this.persistSession({ highscores: this.highscores });
}

In a more complex application, you should design the JSON persistence format carefully, e.g. by including a version number so that future code versions can correctly interpret the data written by an older version. Croquet makes no assumptions about this, it only stores and retrieves that data.

From this point on, even when you change the model code, the highscores will always be there.

Step 9: Support for mobile etc. ๐Ÿ“ฑ

(full source code) (run it)

This is the finished tutorial game. It has some more features, like

  • support for mobile devices via touch input
  • ASDW keys in addition to arrow keys
  • visible thrusters
  • "wrapped" drawing so that objects are half-visible on both sides when crossing the screen edge
  • prevents ships getting destroyed by an asteroid in the spawn position
  • etc.

We're not going to go into much detail here because all of these are independent of Croquet, it's more about playability and web UX.

One cute thing is the "wrapped rendering". If an object is very close to the edge of the screen, its other "half" should be visible on the other side to maintain illusion of a continuous space world. That means it needs to be drawn twice (or even 4 times if it is in a corner):

drawWrapped(x, y, size, draw) {
    const drawIt = (x, y) => {
        this.context.save();
        this.context.translate(x, y);
        draw();
        this.context.restore();
    }
    drawIt(x, y);
    // draw again on opposite sides if object is near edge
    if (x - size < 0) drawIt(x + 1000, y);
    if (x + size > 1000) drawIt(x - 1000, y);
    if (y - size < 0) drawIt(x, y + 1000);
    if (y + size > 1000) drawIt(x, y - 1000);
    if (x - size < 0 && y - size < 0) drawIt(x + 1000, y + 1000);
    if (x + size > 1000 && y + size > 1000) drawIt(x - 1000, y - 1000);
    if (x - size < 0 && y + size > 1000) drawIt(x + 1000, y - 1000);
    if (x + size > 1000 && y - size < 0) drawIt(x - 1000, y + 1000);
}

Advanced Game ๐Ÿš€๐Ÿ’‹

There's an even more polished game with some gimmicks at github.com/croquet/multiblaster.

One of its gimmicks is that if the initials contain an emoji, it will be used for shooting. The trickiest part of that is properly parsing out the emoji, which can be composed of many code points ๐Ÿ˜‰

You can play it online at croquet.io/multiblaster.