Skip to content

JavaScript client

The js-client integration gives your browser game a typed client that opens the built-in transport connection, decodes incoming server frames, and sends encoded commands, all wired to your specific entity and command schemas. The same generated client works over WebSocket or WebTransport and selects the appropriate command lane for the active transport.

After golem-bake, the generated client.js (and client.d.ts for TypeScript) in your output directory is the only file you import from game code.

Fetch the server’s realtime configuration so the client uses the deployed transport URL, certificate hashes, and eventual-state ACK interval. Query helpers append game-specific authentication values without hand-building a URL:

import {
connectOptionsFromRealtimeConfig,
fetchRealtimeConfig,
withQueryParam,
} from 'golem-engine';
import { createClient } from './synced/client.js';
const realtime = await fetchRealtimeConfig('/api/realtime-config');
const options = connectOptionsFromRealtimeConfig(
realtime,
withQueryParam('token', sessionToken),
withQueryParam('char_id', selectedCharacterId),
);
const client = createClient();
client.onConnect(() => {
console.log('connected');
});
client.connect(options);

fetchRealtimeConfig rejects unsupported transports, invalid URLs, oversized or malformed responses, and invalid ACK intervals. connectOptionsFromRealtimeConfig returns a fresh options object, preserves existing query values, and clones certificate hashes. Pass an AbortSignal through the fetch options when startup can be cancelled.

createClient() assembles the client from the generated managers and codecs. Passing a string to connect() selects the built-in WebSocket adapter:

const client = createClient();
client.connect('ws://localhost:8080/ws');

To configure WebTransport directly, pass transport-aware options:

client.connect({
transport: 'webtransport',
url: 'https://localhost:4433/wt',
serverCertificateHashes: [
{ algorithm: 'sha-256', value: '0123456789abcdef...' },
],
});

Use serverCertificateHashes for local development or another explicit certificate pin. For publicly trusted TLS certificates, omit it and let the browser use WebPKI validation.

connect() opens the chosen transport and starts processing frames immediately. Transport write boundaries are opaque here: after transport decoding, the client applies one logical frame at a time. Call client.disconnect() to close the connection.

The EntityManager at client.entities keeps your local entity state in sync with the server. You don’t call it directly; it processes incoming reliable stream frames automatically.

Each entity in the manager is an instance of a generated class for its type (e.g. SyncedPlayer). The class exposes getters for every field declared in your YAML (player.health, player.displayName, player.posX, player.posY), updated in place with each accepted update from the server. In 3D projects, generated entities also expose posZ. Generated entity updates carry a revision, and the manager ignores stale state, delta, and removal frames.

To react to entity lifecycle events, extend the generated class and add onSpawn() or onRemove() methods:

import { createClient, SyncedPlayer } from './synced/client.js';
class MyPlayer extends SyncedPlayer {
onSpawn() {
console.log('player spawned:', this.posX, this.posY);
}
onRemove() {
console.log('player removed');
}
}
const client = createClient({ PlayerClass: MyPlayer });

Whether and how you pass a custom class depends on the generated createClient signature for your schemas. Check client.d.ts for the exact options.

For each command in your schemas, bake emits a buildXxxCommand helper. Use send when commands do not depend on one another, and sendOrdered when their processing order matters:

import { buildMoveCommand, buildUseItemCommand } from './synced/client.js';
// Called from your game input loop
function onInput(playerId, dx, dy, inventorySlot) {
client.send(buildMoveCommand(playerId, dx, dy));
client.sendOrdered(buildUseItemCommand(playerId, inventorySlot));
}

On WebTransport, send uses reliable unordered datagrams and accepts up to 1176 bytes per encoded command. sendOrdered uses reliable ordered datagrams and accepts up to 1174 bytes. Each call sends one command immediately. An oversized command throws instead of falling back to the stream.

On WebSocket, both methods use the same ordered reliable stream. Commands queued in one JavaScript turn share ClientPacket batches up to 32000 bytes, and calls retain their original order regardless of which method you used.

Bind all generated command handlers so the server accepts the WebSocket stream path and both WebTransport command lanes:

router := synced.NewCommandRouter(srv)
router.OnMove(func(senderID int64, player *synced.SyncedPlayer, cmd *synced.MoveCommand) {
x, y := player.Position()
player.SetPosition(x+cmd.Dx, y+cmd.Dy)
})
router.BindAllHandlers(func(sess *golem.Session, err error) {
log.Println("command error:", err)
})

If you have event schemas, client.events is an EventManager that dispatches incoming server events to your handlers.

Register a callback before connecting. Callbacks set after connect() may miss events that arrive during the handshake:

client.events.onChatMessage((e) => {
chat.append(e.senderName, e.text);
});
client.connect('ws://localhost:8080/ws');

Each callback is named onXxx where Xxx matches the event name from your YAML. The argument is a typed object with the fields you declared in the schema.

Events with an entity_type are dispatched as optional instance methods on the Synced* entity subclass rather than as global callbacks. Override the generated stub in your subclass:

class MyPlayer extends SyncedPlayer {
onBuffApplied(e: BuffAppliedEvent) {
this.applyBuff(e.buffId);
}
}

Import the event type from the generated events_pb.js:

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

When the event has foi_only: true, the entity is guaranteed to be in the client’s interest set when the method fires, so this fields reflect current state. When foi_only is absent or false, the event may arrive for an entity not currently visible to this client, so handle the case where entity state is unavailable if needed.

If you have world schemas, the client’s world manager dispatches typed callbacks when world data arrives. Register your callbacks before connecting so you don’t miss the initial frames sent on connect:

client.world.onZoneUpdate((data) => {
console.log('zone name:', data.name);
console.log('gravity:', data.gravity);
});
client.connect('ws://localhost:8080/ws');

Each callback is named onXxxUpdate where Xxx matches the world type name from your YAML. It fires once on connect (with whatever the server has stored) and again whenever the server calls PushWorldData("Zone").

client.onDisconnect() receives a transport-neutral DisconnectInfo object:

client.onDisconnect((info) => {
console.log(info.wasClean, info.code, info.reason);
});

That keeps disconnect handling consistent across WebSocket and WebTransport.

client.d.ts exports full types for everything: createClient, entity classes, command builders, and world manager callbacks. Import from the generated output directory and your editor provides completion and type checking with no extra setup.

import type { SyncedPlayer } from './synced/client.js';
function renderPlayer(p: SyncedPlayer) {
drawSprite(p.posX, p.posY); // 3D projects can also read p.posZ.
}

The generated code imports the GameClient, PbWriter, and PbReader types from the golem-engine npm package. Make sure it’s installed alongside your other dependencies:

Terminal window
npm install golem-engine

See also Channels and transports, State updates, and Client commands.