Skip to content

Phaser integration

When your client is a Phaser 3 game you want the entity state from golem-engine to drive your sprites directly, with the server connection managed as part of the scene lifecycle. Two pieces cover that:

  • golem-phaser — a small npm package with a GameScene base class that owns the transport connection and a loadTiledMap helper for map delivery.
  • phaser bake integration — a new golem-bake target that generates a XxxBridge.ts scaffold per entity. Each bridge wires onSpawn, onRemove, and syncToSprite so your game code only fills in what makes sense for each entity type.
Terminal window
npm install golem-phaser phaser golem-engine

Add the phaser integration to your golem.yaml alongside js-client. The protocol_import key tells the bridge template where the generated SyncedXxx files live:

integrations:
js-client:
out: src/synced/
phaser:
out: src/bridges/
protocol_import: "../synced/"

Run golem-bake — it now writes one XxxBridge.ts into src/bridges/ for every entity in your schemas.

A bridge extends the generated SyncedXxx class with three hooks:

  • onSpawn() — called when the entity first appears on this client. Create your Phaser game object here.
  • onRemove() — called when the entity leaves. The default implementation destroys this.sprite automatically.
  • syncToSprite() — called after every state snapshot or delta. Copy entity fields onto the game object here.

The generated PlayerBridge.ts looks like this (trimmed):

export class PlayerBridge extends SyncedPlayer {
sprite?: Phaser.GameObjects.Sprite;
onSpawn(): void {}
onRemove(): void {
this.sprite?.destroy();
this.sprite = undefined;
}
protected syncToSprite(): void {}
}

Extend it in your game code and fill in the hooks:

import { PlayerBridge } from './bridges/PlayerBridge.js';
class MyPlayer extends PlayerBridge {
onSpawn() {
this.sprite = scene.add.sprite(this.posX, this.posY, 'player');
}
protected syncToSprite() {
if (!this.sprite) return;
this.sprite.x = this.posX;
this.sprite.y = this.posY;
}
}

Register the subclass with the entity manager before connecting:

client.entities.registerPlayer(MyPlayer);

Fields available in syncToSprite are listed in the generated JSDoc comment at the top of each bridge file.

If any server events target an entity type (via entity_type in the event schema), the generated XxxBridge.ts also includes a stub method for each:

export class BombBridge extends SyncedBomb {
// ...
/**
* Called when an Explosion event is received for this entity.
* FOI-filtered: this.sprite is guaranteed to exist when this fires.
*/
onExplosion(e: ExplosionEvent): void {}
}

Override the stub in your subclass to respond to the event:

import { BombBridge } from './bridges/BombBridge.js';
import type { ExplosionEvent } from './synced/events_pb.js';
class MyBomb extends BombBridge {
onExplosion(e: ExplosionEvent) {
if (this.sprite) {
spawnFX(this.sprite.x, this.sprite.y, e.radius);
this.sprite.destroy();
}
}
}

The JSDoc comment indicates whether the event uses foi_only: true. When it does, this.sprite is guaranteed to exist when the method fires (the entity was in the client’s field of interest, so onSpawn has already run). When foi_only is absent or false, guard for the case where the sprite hasn’t been created yet.

Import the event type from the generated events_pb.js:

import type { ExplosionEvent } from './synced/events_pb.js';

GameScene<C> is a Phaser.Scene subclass that owns the connection lifecycle. Implement buildClient() plus connectionOptions() and call super.create():

import { GameScene } from 'golem-phaser';
import { createClient, type Client } from './synced/client.js';
export class BattleScene extends GameScene<Client> {
protected buildClient() { return createClient(); }
protected connectionOptions() {
return `ws://${location.hostname}:8080/ws`;
}
create() {
super.create(); // wires and opens the connection
this.client.entities.registerPlayer(MyPlayer);
this.client.world.onZoneUpdate = async (d) => {
// d.mapUrl is the Tiled file URL delivered on connect
};
}
}

buildClient() is called fresh each time create() runs, so scene restarts automatically produce a clean client.

If you only need a WebSocket URL, serverUrl() still exists as a legacy fallback. Prefer connectionOptions() for new code because it also supports WebTransport. connectionOptions() returns the same string | ConnectOptions shape accepted by GameClient.connect(), so switching a scene from WebSocket to WebTransport does not change the surrounding scene lifecycle code.

GameScene reconnects automatically after unexpected drops (network loss, server restart). The policy is configurable:

export class BattleScene extends GameScene<Client> {
protected maxReconnectAttempts = 10; // 0 = infinite
protected reconnectBaseDelay = 1000; // ms; doubles each attempt
...
}

A semi-transparent overlay displays the current attempt count while reconnecting. Override showConnectionOverlay(message) and hideConnectionOverlay() to replace it with your own UI.

When all attempts are exhausted, onReconnectFailed() is called — override to navigate back to the main menu or show an error:

protected onReconnectFailed() {
this.scene.start('MainMenu', { error: 'Connection lost' });
}

The reconnect timer uses Phaser.Time.TimerEvent so it respects scene pause. An intentional disconnect (scene shutdown or destroy) cancels pending reconnects immediately.

protected onConnect(): void // connection established or re-established
protected onDisconnect(ev: DisconnectInfo): void // any close — inspect ev.wasClean

When your world schemas deliver a mapUrl from the server, loadTiledMap bridges it into Phaser’s loader:

import { loadTiledMap } from 'golem-phaser';
// inside create(), after super.create()
this.client.world.onZoneUpdate = async (d) => {
const map = await loadTiledMap(this, 'zone', d.mapUrl, {
tiles: '/assets/tiles.png', // tileset name → image URL
});
const tileset = map.addTilesetImage('tiles', 'tiles');
map.createLayer('Ground', tileset!);
};

loadTiledMap calls scene.load.tilemapTiledJSON, loads any tileset images you provide, starts the loader if it is idle, and resolves with the created Phaser.Tilemaps.Tilemap. If the tilemap is already in the Phaser cache it returns immediately without re-fetching.

The mapUrl comes from the server via PushWorldData — see Map file serving for how to set up the server side.

All exports are fully typed. The C type parameter on GameScene<C> flows through to this.client, so entity manager methods like registerPlayer are available without casting:

class BattleScene extends GameScene<Client> {
create() {
super.create();
this.client.entities.registerPlayer(MyPlayer); // typed, no cast
}
}

See also Channels and transports, JavaScript client, and State updates.