Skip to content

Your first entity

A booted server is empty until you put entities in it. Entities are your game objects (players, projectiles, pickups): the server tracks them, ticks them, and replicates their changing state to every client each tick. You describe an entity’s replicated fields in YAML, golem-bake generates a Synced<Name> type that stores those fields and serializes what changed, and you attach gameplay by embedding that type in your own struct.

schemas/entities/player.yaml
entity: Player
vars:
health: { tag: 1, type: double, sync: tick }
display_name: { tag: 2, type: string, sync: once }

sync: tick fields replicate whenever they change; sync: once fields are sent a single time when a client first sees the entity. Run golem-bake and you get a SyncedPlayer type with Health() / SetHealth, DisplayName(), Position() / SetPosition, and the serialization the loop needs. The full field reference is in Entity schemas.

Embed the generated type in your own struct. Embedding promotes its getters and setters, so your wrapper is a Player to your own code and a valid entity to the engine:

type Player struct {
*synced.SyncedPlayer
}

Put game-only fields (state you never replicate, like AI timers or input buffers) on this struct alongside the embedded synced state.

Implement Tick(dt) and the registry calls it once per tick for every Player, before your OnTick closure runs:

func (p *Player) Tick(dt float64) {
p.SetHealth(p.Health() + 5*dt) // simple health regen
}

dt is the fixed timestep in seconds. Mutating a sync: tick field through its setter (here SetHealth) marks it changed, and the loop replicates the new value after the tick. See Game loop for the full tick order and how per-entity Tick relates to the server-wide OnTick.

Construct an entity and hand it to the server. CreateEntity takes the entity plus an optional owner session ID, passing sess.ID ties the entity to that player so ownership-checked commands work:

players := map[int64]int64{} // sessionID -> entityID
srv.OnConnect(func(sess *golem.Session) {
p := &Player{SyncedPlayer: synced.NewSyncedPlayer(0, 0, 100, "")}
if err := srv.CreateEntity(p, sess.ID); err != nil {
log.Printf("spawn player: %v", err)
return
}
players[sess.ID] = p.EntityID()
})
srv.OnDisconnect(func(sess *golem.Session) {
if id, ok := players[sess.ID]; ok {
srv.DeleteEntity(id)
delete(players, sess.ID)
}
})

The generated constructor takes the starting position, then each vars field in tag order, then an optional ID: NewSyncedPlayer(posX, posY, health, displayName). Leave the ID off and CreateEntity assigns one, readable afterward with EntityID(). OnConnect and OnDisconnect both run on the tick goroutine, so the plain players map needs no locking. For per-entity setup or teardown, implement OnSpawn() / OnRemove() on the wrapper; see Creation and destruction.

Commands that target an entity can be handled on the entity itself. Declare the command:

schemas/commands/move.yaml
command: Move
target: entity
entity_type: Player
fields:
dx: float
dy: float

Then implement the generated receiver interface on your wrapper. golem-bake emits an On<Command>Command method for each entity-targeted command. The router resolves the target entity, checks that the sender owns it, and calls your method only when the check passes:

func (p *Player) OnMoveCommand(senderID int64, cmd *synced.MoveCommand) {
x, y := p.Position()
p.SetPosition(x+cmd.Dx, y+cmd.Dy)
}

No central registration is required: with rt.Commands.BindAllHandlers wired in main (see Minimal server wiring), owned Move commands reach this method. Prefer a central rt.Commands.OnMove(...) handler when the logic spans several systems rather than one entity. Both styles are covered in Client commands.