Client commands
Client commands
Section titled “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.
Receiving a command on the server
Section titled “Receiving a command on the server”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})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 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.
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—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()—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.
Transport size and MTU behavior
Section titled “Transport size and MTU behavior”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:
DispatchPacketreceives the raw reliable payload and is the normal server entrypoint.Dispatchstill handles one logical command at a time afterDispatchPacketunwraps it.- A single command that cannot fit inside one
ClientPacketcausesclient.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.