You can make a Phaser game multiplayer without writing or hosting a server. Each player's browser sends its own player, the GameRelay SDK smooths everyone else's, and an invite link gets friends into the same room. It takes about 40 lines.
How it works
Phaser draws and simulates; GameRelay moves the data. You declare the things other players need to see as entities (a player, a ball, a coin). Each entity has one owner. The owner writes it, and everyone else gets it smoothed, about 100 ms behind, so motion looks right on real networks. One player's browser is the host and runs the shared world, if your game has one. If the host leaves, another player takes over.
This guide uses Phaser 3. The SDK doesn't depend on Phaser, so the same steps work with any version.
1. Install
Sign in at gamerelay.io/app, create an instance for your game, and copy its public key (gr_pub_…). It's safe to ship in the page. Then install the SDK:
npm i phaser @gamerelay/sdk No bundler? Add <script src="https://gamerelay.io/sdk/gamerelay.js"></script> and use window.GameRelay.
2. Connect and join a room
Connect before you start Phaser, so the scene has a room from its first frame:
import { GameRelay } from '@gamerelay/sdk';
const relay = await GameRelay.connect({ publicKey: 'gr_pub_…', playerName: 'ada' });
// A friend who opened an invite link joins that room; anyone else is matched with anyone.
const room = (await relay.joinInvite().catch(() => null)) ?? (await relay.quickMatch({ maxPlayers: 4 }));
history.replaceState(null, '', room.inviteUrl()); // the address bar is now the invite link
// Declare what other players need to see. Same call in every player's game.
const players = room.define('player', { x: 'number', y: 'number', flip: 'flag' });3. Sync players in your scene
Spawn your own player in create(). In update(), copy your sprite's position to it, and draw a sprite for every other player:
import Phaser from 'phaser';
class Play extends Phaser.Scene {
create() {
this.cursors = this.input.keyboard.createCursorKeys();
this.hero = this.add.rectangle(100, 300, 24, 24, 0xff6b1a);
this.me = players.spawn({ x: this.hero.x, y: this.hero.y, flip: false }); // yours to write
this.others = new Map(); // entity id → the sprite that draws it
players.on('remove', (p) => {
this.others.get(p.id)?.destroy();
this.others.delete(p.id);
});
}
update(_time, deltaMs) {
// Move your own player however your game does it...
const dt = deltaMs / 1000, c = this.cursors;
const dx = (c.right.isDown ? 1 : 0) - (c.left.isDown ? 1 : 0);
const dy = (c.down.isDown ? 1 : 0) - (c.up.isDown ? 1 : 0);
this.hero.x += dx * 220 * dt;
this.hero.y += dy * 220 * dt;
// ...then copy it to your entity. The SDK sends what changed, about 20 times a second.
this.me.x = this.hero.x;
this.me.y = this.hero.y;
if (dx) this.me.flip = dx < 0;
// Draw everyone else. Their positions are already smoothed.
for (const p of players.all()) {
if (p.mine) continue;
let sprite = this.others.get(p.id);
if (!sprite) {
sprite = this.add.rectangle(p.x, p.y, 24, 24, 0x4fc3f7);
this.others.set(p.id, sprite);
}
sprite.setPosition(p.x, p.y);
}
}
}
new Phaser.Game({ type: Phaser.AUTO, width: 800, height: 600, backgroundColor: '#111', scene: Play });Open the page in two browser tabs. Each tab is its own player, so you can test alone. Copy the address bar to a friend and they join your room.
Arcade Physics and Matter
Keep physics on your own player as usual and copy its position after the step. Other players' sprites should have no physics of their own, or physics and the smoothing fight each other and the sprites jitter:
// Remote players: drawn where the SDK says, never pushed by physics.
sprite = this.physics.add.sprite(p.x, p.y, 'hero');
sprite.body.setAllowGravity(false);
sprite.body.moves = false; // setPosition() moves it; the physics step leaves it aloneIf players must collide with each other fairly (a ball both can push), the host should simulate it. Let the host own that object and have other players send inputs. The SDK guide's "host simulates" starter shows the pattern.
Moments, scores and the rest
Things that happen once go in events, not entities:
// A moment everyone sees, sender included: a jump, an emote, a shot.
room.emit('shoot', { x: this.hero.x, y: this.hero.y, dir: this.me.flip ? -1 : 1 });
room.on('shoot', ({ x, y, dir }, from) => this.spawnBullet(x, y, dir, from));Leaderboards, saved player data and chat are built in too:
const { rank } = await relay.leaderboard.submit('points', score);
const { entries } = await relay.leaderboard.top('points', { limit: 10 });Common pitfalls
- Don't send positions as messages. Write them to entities. The SDK batches, smooths and limits them for you, and late joiners get them automatically.
- Only write what you own. Writes to another player's entity are skipped with a console warning. To affect someone else, send them an event.
- Hidden tabs. Browsers pause Phaser's loop in a background tab. That's fine for your own player, but a host simulating the world should use
relay.tick(60, (dt) => …), which keeps running. - Respawns. Set the new position and call
this.me.teleport()in the same frame, so others see a jump, not a slide across the map. - Going live. Add your site under Allowed origins on the instance, so nobody else can use your key.
Try it
GameRelay has a free plan, no card needed. Get a public key, then follow the quickstart, or point your AI coding agent at llms.txt.