| title | Multiblaster Game Tutorial |
|---|---|
| description | Build a complete multiplayer game step-by-step with Multisynq - from asteroids to spaceships, shooting, scoring, and persistence |
This comprehensive tutorial will guide you through building a complete multiplayer game using Multisynq. You'll learn how to create a 2D space game with asteroids, spaceships, shooting mechanics, collision detection, scoring, and persistence.
<iframe src="https://multisynq.github.io/multiblaster-tutorial/step9.html" style={{ width: '100%', height: '512px', border: '2px solid #ccc', borderRadius: '8px', marginBottom: '24px' }} title="Multiblaster Game - Final Version" allowFullScreen /> [**Click here to play the full game**](https://multisynq.github.io/multiblaster-tutorial/step9.html) - then scan the QR code or share the generated session URL to invite other players!This tutorial is structured as a progressive series of steps, each building upon the previous one. You'll start with a simple asteroid simulation and end with a fully-featured multiplayer game:
- Step 0: Basic asteroids (non-Multisynq)
- Step 1: Synchronized asteroids with Multisynq
- Step 2: Interactive spaceships with player controls
- Step 3: Shooting mechanics with blasters
- Step 4: Collision detection and asteroid destruction
- Step 5: Ship-asteroid collisions and debris
- Step 6: Scoring system
- Step 7: View smoothing for 60fps animation
- Step 8: Persistent highscore table
- Step 9: Mobile support and final polish
The finished game includes:
- Multiplayer synchronization - All players see the same game state
- Player-controlled spaceships - Arrow keys or WASD for movement
- Shooting mechanics - Space bar to fire blasters
- Collision detection - Blasters destroy asteroids, asteroids destroy ships
- Scoring system - Points for destroying asteroids
- Persistent leaderboard - Highscores survive code changes
- Mobile support - Touch controls for mobile devices
- Smooth animation - 60fps rendering with view smoothing
- QR code sharing - Easy session sharing between devices
This is a non-Multisynq app showing asteroids floating through space. If you run this in two windows, the asteroids will move differently - they're not synchronized.
Each asteroid has position (x, y) and angle (a) properties, along with delta values (dx, dy, da) for movement:
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);
}Drawing is done with simple white strokes on a 1000×1000 canvas:
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.
- [**Full source code**](https://github.com/multisynq/multiblaster-tutorial/blob/main/step1.html) - [**Live demo**](https://multisynq.github.io/multiblaster-tutorial/step1.html)Now we add Multisynq synchronization! The asteroids will move exactly the same in all browsers and devices.
The app is divided into two parts:
- Model: The synchronized part (
Multisynq.Model) - shared computation - View: The display part (
Multisynq.View) - local rendering
The key innovation is the future() method:
this.future(50).move();This schedules the move() method to be called again in 50ms, but synchronously across all clients. This is how you define an object's behavior over time in Multisynq.
The last few lines connect to a Multisynq session:
Multisynq.Session.join({
appId: "your-app-id",
password: "your-password",
name: "multiblaster-tutorial",
model: Game,
view: Display
});This version has only 20 lines more than the non-Multisynq version from Step 0.
- [**Full source code**](https://github.com/multisynq/multiblaster-tutorial/blob/main/step2.html) - [**Live demo**](https://multisynq.github.io/multiblaster-tutorial/step2.html)Now we add interactive spaceships! Each player gets their own ship when they join.
The game subscribes to join/exit events to manage players:
class Game extends Multisynq.Model {
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 input events from its specific player:
class Ship extends Multisynq.Model {
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 view publishes input events to control the ship:
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;
}
};Add shooting mechanics! Press the space bar to fire blasters.
When firing, the ship creates a new blast moving in its direction:
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 });
}Blasts automatically destroy themselves after a timeout:
class Blast extends Multisynq.Model {
init({ x, y, dx, dy }) {
this.x = x; this.y = y;
this.dx = dx; this.dy = dy;
this.t = 0;
this.game.blasts.add(this);
this.move();
}
move() {
this.t++;
if (this.t > 30) {
this.destroy();
return;
}
this.x = (this.x + this.dx + 1000) % 1000;
this.y = (this.y + this.dy + 1000) % 1000;
this.future(50).move();
}
get game() { return this.wellKnownModel("modelRoot"); }
}Add collision detection! When blasts hit asteroids, they break into smaller pieces.
The game runs collision detection in its main loop:
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();
}
checkCollisions() {
for (const asteroid of this.asteroids) {
const minx = asteroid.x - asteroid.size;
const maxx = asteroid.x + asteroid.size;
const miny = asteroid.y - asteroid.size;
const maxy = asteroid.y + asteroid.size;
for (const blast of this.blasts) {
if (blast.x > minx && blast.x < maxx &&
blast.y > miny && blast.y < maxy) {
asteroid.hitBy(blast);
break;
}
}
}
}When hit, asteroids split into two smaller pieces:
hitBy(blast) {
if (this.size > 20) {
// Split into two pieces
this.size *= 0.7;
this.da *= 1.5;
this.dx = -blast.dy * 10 / this.size;
this.dy = blast.dx * 10 / this.size;
// Create the other piece
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(); // Too small, destroy completely
}
blast.destroy();
}Add ship-asteroid collisions! When ships hit asteroids, they turn into debris.
Ships track their damage state with a wasHit property:
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;
}
// ... position updates ...
}The view shows exploded ships with scattered line segments:
// Normal ship
if (!wasHit) {
this.context.moveTo(+20, 0);
this.context.lineTo(-20, +10);
this.context.lineTo(-20, -10);
this.context.closePath();
} else {
// Exploded ship - segments fly apart
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);
}Add scoring! Players earn points for destroying asteroids.
Store a reference to the firing ship in each blast:
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, ship: this }); // ← Ship reference
}When asteroids are hit, the firing ship gets points:
hitBy(blast) {
blast.ship.scored(); // ← Award points to the shooter
// ... asteroid destruction code ...
}The view displays each player's score and highlights their own ship:
update() {
// ... other rendering ...
// Display score next to ship
this.context.fillText(score, 30 - wasHit * 2, 0);
// Fill our own ship to distinguish it
if (viewId === this.viewId) {
this.context.fill();
}
// ... rest of rendering ...
}Add 60fps animation smoothing! The model updates at 20fps, but we render at 60fps for smooth visuals.
- Model: Updates at 20fps (50ms intervals) for reliable synchronization
- View: Renders at 60fps (16ms intervals) for smooth animation
- Solution: Interpolate between model positions for smooth rendering
Use a WeakMap to store rendering positions separate from model positions:
class Display extends Multisynq.View {
constructor() {
super();
this.smoothing = new WeakMap();
}
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 (object jumped)
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;
}
}Use smoothed positions for rendering:
update() {
for (const asteroid of this.model.asteroids) {
const { x, y, a } = this.smoothPos(asteroid); // ← Smoothed position
const { size } = asteroid; // ← Direct from model
// ... rendering code uses smoothed x, y, a ...
}
}Add persistent highscores that survive code changes!
- Multisynq automatically saves session state - State persists when all players leave - BUT: Code changes create new sessions - Use `persistSession()` to save JSON data - Data survives code changes - Passed to `init()` of new sessionsAdd an input field for player names:
initials.onchange = () => {
localStorage.setItem("io.multisynq.multiblaster.initials", initials.value);
this.publish(this.viewId, "set-initials", initials.value);
}
// Auto-restore from localStorage
if (localStorage.getItem("io.multisynq.multiblaster.initials")) {
initials.value = localStorage.getItem("io.multisynq.multiblaster.initials");
this.publish(this.viewId, "set-initials", initials.value);
}Initialize highscores from persistent data:
class Game extends Multisynq.Model {
init(_, persisted) {
this.highscores = persisted?.highscores ?? {};
// ... other initialization ...
}
setHighscore(initials, score) {
if (this.highscores[initials] >= score) return;
this.highscores[initials] = score;
this.persistSession({ highscores: this.highscores });
}
}Update highscores when players score:
scored() {
this.score++;
if (this.initials) {
this.game.setHighscore(this.initials, this.score);
}
}The final version with mobile support and polish!
- Touch controls for mobile devices
- WASD keys in addition to arrow keys
- Visible thrusters for better feedback
- Wrapped drawing for seamless screen edges
- Spawn protection to prevent immediate destruction
Objects near screen edges are drawn on both sides:
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);
// Handle corners (4 additional draws)
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);
}- Emoji shooting - If your initials contain an emoji, you shoot that emoji!
- Advanced graphics - Enhanced visual effects and animations
- Better mobile UX - Optimized touch controls and responsive design
