Skip to content

Creation and destruction

Entities come and go throughout a session: players join and leave, enemies spawn and die, objects appear and disappear. Each event has to reach every client so their view of the world stays consistent. golem-engine handles the serialization for each case—you call CreateEntity on golem.Server (or CreateEntity(e, ownerID) when the entity is session-owned) and DeleteEntity when it despawns.

Every live entity has a unique int64 ID. The server maintains an internal counter that starts at 1 and increments each time an entity needs an ID—you never manage it yourself.

Auto-assignment (most common): construct the entity without an ID and let CreateEntity assign one:

npc := synced.NewSyncedGoblin(x, y)
srv.CreateEntity(npc)
fmt.Println(npc.EntityID()) // 1, 2, 3, … assigned by the server

Pre-reserved IDs: when you need to know an entity’s ID before it is registered—for example, to wire two entities together up-front—call ReserveEntityID first:

id := srv.ReserveEntityID()
npc := synced.NewSyncedGoblin(x, y, id) // optional trailing argument
srv.CreateEntity(npc)

ReserveEntityID advances the same counter, so reserved and auto-assigned IDs never collide.

If you are loading a persisted session and need IDs to resume from a known value, call srv.SetEntityIDCounter(n) during initialisation. Every ID issued after that call will be greater than n.

CreateEntity always checks for duplicates—it returns an error and refuses to register the entity if the ID is already in use.

When you CreateEntity, the server notes that entity as newly spawned. On the next tick’s flush, it receives a full-state update (FullUpdate)—all fields, so clients that have never seen this entity can create it from scratch.

Unowned entities (world NPCs, pickups, environmental objects) need only the entity itself:

npc := synced.NewSyncedGoblin(x, y)
if err := srv.CreateEntity(npc); err != nil {
// fails if the ID was pre-reserved and already in use
}

Owned entities (player avatars) take a session ID as the second argument, which ties the entity to that session for authority checks on entity-targeted commands:

srv.OnConnect(func(sess *golem.Session) {
player := synced.NewSyncedPlayer(0, 0, "Aria")
_ = srv.CreateEntity(player, sess.ID)
})

Subsequent ticks use FlushUpdate instead of FullUpdate—only fields that changed since the last flush are included. If nothing changed, FlushUpdate returns nil and no bytes are sent for that entity.

Mutate fields between ticks via generated Set… methods. Fields declared sync: tick in your YAML are tracked automatically; no extra bookkeeping needed:

// Inside OnTick — changed fields are picked up by FlushAll at the end of the tick
player.SetPositionX(player.PositionX() + dx)
player.SetPositionY(player.PositionY() + dy)

When you need to look up an entity by ID—for example, inside a command handler or a utility function that only has the ID—use srv.Get:

if raw, ok := srv.Get(entityID); ok {
p := raw.(*synced.SyncedPlayer)
p.SetPosition(p.PositionX()+dx, p.PositionY()+dy)
p.SetHealth(newHP)
}

Fields declared sync: once are sent only on spawn (full update) and not tracked for deltas.

DeleteEntity drops the entity from the registry and queues its ID for a removal broadcast. The server turns that ID into bytes using the MarshalEntityRemoved function you provided to SetRemovalSerializer:

// Inside OnTick
srv.DeleteEntity(enemyID)
// Next flush: clients receive a removal message for enemyID

If entities are removed without a serializer set, Run returns an error. Wire SetRemovalSerializer before the loop starts.

When you want an entity to react to its own creation or destruction—initializing state, logging, cleaning up side effects—implement the golem.Spawner or golem.Remover interface on your entity struct. The registry calls the hook automatically; you don’t register anything extra.

type Enemy struct {
*synced.SyncedEnemy
}
func (e *Enemy) OnSpawn() {
log.Printf("enemy %d entered the world", e.EntityID())
}
func (e *Enemy) OnRemove() {
log.Printf("enemy %d left the world", e.EntityID())
}

OnSpawn fires right after the entity is registered (via CreateEntity). OnRemove fires right after the entity is deleted (via DeleteEntity). Both hooks run outside the engine’s store lock, so it is safe to call other Server entity methods from inside them. Because CreateEntity and DeleteEntity are typically called from OnConnect, OnTick, or command handlers — all of which run on the tick goroutine — OnSpawn and OnRemove also run on the tick goroutine in normal usage.

These hooks are opt-in: entities that don’t implement Spawner or Remover behave exactly as before. You can combine them with per-entity tick logic (golem.Ticker)—see Game loop.

New connections need to catch up immediately—they didn’t receive the spawns that already happened. The internal listener snapshots every live entity on connect and sends one full-state update per entity before normal tick traffic begins. The message format is identical to a normal spawn, so client code handles join catch-up the same way it handles any spawn.

When interest management is enabled, entity snapshots on connect are deferred. The client receives world data immediately, but entity visibility is handled by the interest system: once you call AssignFOI in OnConnect, the next tick sends full-state updates for every entity inside the new FOI. The client sees the same spawn messages—it doesn’t need to know whether they came from a snapshot or from interest-based visibility.

See State updates.