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: reliable updates and commands share a 32000-byte maximum message, and WebTransport can expose separate datagram lanes whose payload limits sit below the raw 1280-byte datagram cap because of golem protocol headers.

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.

createClient() is the entry point. It assembles the client from the generated decode/encode functions and your entity and world managers:

import { createClient } from './synced/client.js';
const client = createClient();
client.onConnect(() => {
console.log('connected');
});
client.onDisconnect(() => {
console.log('disconnected');
});
client.connect('ws://localhost:8080/ws');

Passing a string to connect() uses the built-in WebSocket adapter. To connect over WebTransport, pass transport-aware options instead:

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. Construct the command and pass it to client.send:

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

client.send encodes the command as a ClientMessage, queues it, and flushes a ClientPacket on the next microtask. Multiple back-to-back send() calls in the same JS turn can share one reliable transport message; low-frequency calls still leave the browser quickly because the flush happens immediately after the current turn completes.

The runtime enforces a hard 32000-byte reliable message cap:

  • each flushed ClientPacket is <= 32000 bytes,
  • if adding another queued command would exceed the cap, the current packet flushes immediately and the new command starts the next packet,
  • if one encoded command cannot fit inside a single ClientPacket, client.send() throws synchronously.

On the server side, pair the generated JS client with router.DispatchPacket(sess, data) inside srv.OnMessage(...). DispatchPacket unwraps the transport packet and calls Dispatch for each enclosed command in FIFO order.

router := synced.NewCommandRouter(srv)
srv.OnMessage(func(sess *golem.Session, data []byte) {
if err := router.DispatchPacket(sess, data); err != nil {
log.Println("command error:", err)
}
})

Dispatch() is still available for tests or custom transports that already hand you one encoded ClientMessage at a time.

That batching rule is independent of how the server groups transport writes. For example, WebTransport may coalesce several framed updates into one stream read, but the current JS runtime still dispatches the decoded reliable frames one by one after removing stream framing.

When the active transport is WebTransport, the built-in client also exposes an optional lossy datagram lane through client.sendUnreliable():

client.sendUnreliable(bytes);

client.sendUnreliable() currently accepts up to 1178 bytes of application payload. Reliable unordered datagrams carry up to 1176 bytes, and reliable ordered datagrams carry up to 1174 bytes. On a WebSocket connection, sendUnreliable() does nothing because there is no unreliable lane. On the server side, pair this with srv.OnDatagram(...), srv.SendUnreliable(...), or srv.BroadcastUnreliable(...).

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—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() now 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.