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 typed client helpers plus a CommandRouter that decodes incoming messages, checks that the sender owns the target entity, and calls your handler. The generated clients choose a command lane for WebTransport and use the reliable stream for WebSocket.
Receiving a command on the server
Section titled “Receiving a command on the server”Register your handlers, then bind all transport entrypoints:
router := synced.NewCommandRouter(srv)
router.OnMove(func(senderID int64, ent *synced.SyncedPlayer, cmd *synced.MoveCommand) { // Only reached when senderID owns ent, so it is safe to apply movement x, y := ent.Position() ent.SetPosition(x+cmd.Dx, y+cmd.Dy)})
router.BindAllHandlers(func(sess *golem.Session, err error) { log.Println("command error:", err)})OnMove, SyncedPlayer, and MoveCommand are examples; the names come from your command and entity YAML.
BindAllHandlers installs stream, reliable unordered, and reliable ordered command routing. Use it when clients may connect with either WebSocket or WebTransport. The narrower binding helpers remain useful when a server intentionally accepts only one transport path.
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})Entity-direct dispatch
Section titled “Entity-direct dispatch”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 the router reaches it through any bound command lane:
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
router.BindAllHandlers(func(sess *golem.Session, err error) { log.Println("command error:", err)})The router performs the 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, and 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.
Adding a command to your project
Section titled “Adding a command to your project”- Add a YAML file under the directory set by
command_schemaingolem.yaml(defaultschemas/commands/). See Command schemas. - Run
golem-baketo refresh generated synced code and command types. - Register a handler on the
CommandRouterfor the new command, or implement the receiver interface on your entity wrapper. - Make sure
Session.IDmatches the owner you passed tosrv.CreateEntityfor that player’s entity.
Starting without commands
Section titled “Starting without commands”Commands are optional. If you have no command YAML, golem-bake generates entity code only. OnMessage still receives raw bytes, so you can handle them however you like and add a CommandRouter later when you’re ready.
Sending commands from generated clients
Section titled “Sending commands from generated clients”The js-client integration generates build…Command helpers and re-exports them from client.js. Pass the result to client.send() or client.sendOrdered():
client.send(buildMoveCommand(playerEntityId, dx, dy));client.sendOrdered(buildUseItemCommand(playerEntityId, inventorySlot));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)}
if err := client.SendOrdered(clientpkg.BuildUseItemCommand(playerEntityID, inventorySlot)); err != nil { log.Println("ordered command:", err)}The csharp-client integration emits CommandBuilders.BuildXxxCommand(...) helpers for Unity and other C# clients:
client.Send(CommandBuilders.BuildMoveCommand(playerEntityId, dx, dy));client.SendOrdered(CommandBuilders.BuildUseItemCommand(playerEntityId, inventorySlot));Generated JS, Go, and C# clients use the same two delivery choices. send/Send selects reliable unordered delivery on WebTransport, while sendOrdered/SendOrdered selects reliable ordered delivery. On WebSocket, both use the same ordered reliable stream.
Transport size and MTU behavior
Section titled “Transport size and MTU behavior”On WebTransport, one encoded ClientMessage must fit the selected datagram payload. Reliable unordered commands allow up to 1176 bytes; reliable ordered commands allow up to 1174 bytes. An oversized command fails at the client and does not fall back to the stream.
On WebSocket, commands use ClientPacket payloads on the reliable stream, with a 32000-byte message cap. JavaScript and Unity may batch adjacent calls; Go sends one command per packet. Calls remain ordered on this path, so send and sendOrdered have identical wire behavior over WebSocket.