Skip to content

Client commands

Commands are how players act on the server: move a character, fire a weapon, interact with an object. You define each command in YAML, run golem-bake, and get a typed CommandRouter that decodes incoming messages, checks that the sender owns the target entity, and calls your handler. With the generated JS runtime, the reliable transport payload is a ClientPacket that can contain one or more commands, so the normal server entrypoint is DispatchPacket.

Wire the generated CommandRouter into srv.OnMessage:

router := synced.NewCommandRouter(srv)
router.OnMove(func(senderID int64, ent *synced.SyncedPlayer, cmd *synced.MoveCommand) {
// Only reached when senderID owns ent — safe to apply movement
ent.SetPositionX(ent.PositionX() + cmd.Dx)
ent.SetPositionY(ent.PositionY() + cmd.Dy)
})
srv.OnMessage(func(sess *golem.Session, data []byte) {
if err := router.DispatchPacket(sess, data); err != nil {
log.Println("command error:", err)
}
})

OnMove, SyncedPlayer, and MoveCommand are examples—the names come from your command and entity YAML.

DispatchPacket is the right choice when your clients use the generated JS runtime or any other transport that sends a ClientPacket. It unpacks the packet and calls Dispatch for each enclosed command in order. Dispatch is still public because it remains the single-command primitive: use it in tests, custom transports, or any code path that already has one encoded ClientMessage at a time.

Session-scoped commands (declared target: session) receive the sender’s session ID but no entity:

router.OnReadyUp(func(senderID int64, cmd *synced.ReadyUpCommand) {
// applies to the session, not a specific entity
})

When command logic belongs on the entity itself rather than in a central handler, you can implement the generated receiver interface directly on your entity wrapper. For each entity-targeted command, golem-bake emits a {Name}CommandReceiver interface with a single method On{Name}Command(senderID int64, cmd *{Name}Command).

If your entity struct embeds *SyncedPlayer (or whichever generated type the command targets), the GetSynced{Entity} method is promoted automatically, so ownership checks and entity resolution still work. Implement the interface and DispatchPacket reaches it through the underlying Dispatch call for each command:

type Player struct {
*synced.SyncedPlayer
server *golem.Server
}
func (p *Player) OnMoveCommand(_ int64, cmd *synced.MoveCommand) {
p.SetMoveDir(gameplay.ClampDir(cmd.DirX), gameplay.ClampDir(cmd.DirY))
}
func (p *Player) OnAttackCommand(_ int64, _ *synced.AttackCommand) {
applyAttackAoE(p.server, p.SyncedPlayer, attackRadiusSq, attackDamage)
}

The wiring in setup becomes just:

router := synced.NewCommandRouter(srv)
// no OnMove, no OnAttack needed — Player handles both through its interfaces
srv.OnMessage(func(sess *golem.Session, data []byte) {
if err := router.DispatchPacket(sess, data); err != nil {
log.Println("command error:", err)
}
})

Dispatch still does the actual ownership check and entity resolution for each command, then calls OnMoveCommand (or OnAttackCommand) directly on the entity if the interface is implemented. A callback registered with router.OnMove(...) fires independently if set—both can be active at the same time.

The server field needs to be available on the struct before CreateEntity is called, which the constructor handles naturally:

srv.OnConnect(func(sess *golem.Session) {
pl := &Player{
SyncedPlayer: synced.NewSyncedPlayer(0, 0),
server: srv,
}
srv.CreateEntity(pl, sess.ID)
})

OnMoveCommand, OnAttackCommand, and MoveCommandReceiver/AttackCommandReceiver are examples—the names come from your command YAML.

  1. Add a YAML file under the directory set by command_schema in golem.yaml (default schemas/commands/)—see Command schemas.
  2. Run golem-bake to refresh generated synced code and command types.
  3. Register a handler on the CommandRouter for the new command, or implement the receiver interface on your entity wrapper.
  4. Make sure Session.ID matches the owner you passed to srv.CreateEntity for that player’s entity.

Commands are optional. If you have no command YAML, golem-bake generates entity code only. OnMessage still receives raw bytes—you can handle them however you like and add a CommandRouter later when you’re ready.

The js-client integration generates build…Command helpers and re-exports them from client.js. Pass the result to client.send()—see JavaScript client for the full client-side flow.

The go-client integration emits BuildXxxCommand(...) helpers for native Go clients:

if err := client.Send(clientpkg.BuildMoveCommand(playerEntityID, dx, dy)); err != nil {
log.Println("command:", err)
}

The csharp-client integration emits CommandBuilders.BuildXxxCommand(...) helpers for Unity and other C# clients:

client.Send(CommandBuilders.BuildMoveCommand(playerEntityId, dx, dy));

Generated JS, Go, and C# clients send the same ClientPacket shape, so DispatchPacket stays the normal server entrypoint.

The standard runtime keeps each reliable command payload at 32000 bytes or less. On the client side, client.send() encodes one logical command as a ClientMessage, then the runtime batches one or more commands into a ClientPacket before flushing them over the active reliable lane.

That means:

  • DispatchPacket receives the raw reliable payload and is the normal server entrypoint.
  • Dispatch still handles one logical command at a time after DispatchPacket unwraps it.
  • A single command that cannot fit inside one ClientPacket causes client.send() to throw.

As with state updates, those logical packet boundaries are separate from the underlying transport writes. WebSocket may carry one or more logical packets per ordered binary message, and WebTransport may carry one or more length-prefixed logical packets per ordered stream write, but DispatchPacket still receives the original reliable payload bytes that the client flushed.

WebTransport also exposes reliable ordered and reliable unordered datagram lanes for explicit command helpers. The default client.send() path stays on the reliable stream so ordering and delivery remain consistent across transports.