Game loop
Game loop
Section titled “Game loop”Every multiplayer game server needs a heartbeat. golem.Server gives you one: a fixed-rate tick loop that calls your simulation code at a steady cadence, flushes the registry for anything that changed, serializes it, and broadcasts bytes to connected clients. You write the gameplay; the loop handles the timing, the serialization pipeline, and, when enabled, either WebSocket or WebTransport networking.
Setting it up
Section titled “Setting it up”Construct a server, register your callbacks, and call Run. When Addr is set, Run also starts the HTTP server plus the configured integrated transport and auto-broadcasts updates:
srv := golem.NewServer(golem.ServerConfig{ TickRate: 20, Addr: ":8080",})srv.SetRemovalSerializer(synced.MarshalEntityRemoved)
srv.OnTick(func(dt float64, s *golem.Server) { // your simulation: CreateEntity, mutate fields, DeleteEntity})
if err := srv.Run(ctx); err != nil && err != context.Canceled { log.Fatal(err)}TickRate is ticks per second. It defaults to 20 when unset or ≤ 0. Cancel the context (e.g. on SIGINT) for clean shutdown—Run then returns ctx.Err().
If you omit Addr, Run only ticks with no networking. Use OnUpdates to pipe the bytes to your own transport—see Minimal server wiring for both approaches.
What happens each tick
Section titled “What happens each tick”- Session events — queued
OnConnect,OnMessage, andOnDisconnectcallbacks are drained and called here, before any simulation runs. This meansCreateEntityandDeleteEntityare safe to call directly from all three callbacks. - Entity ticks — the registry calls
Tick(dt)on every entity that implementsgolem.Ticker(see Per-entity tick logic below). OnTickruns your simulation.- Collision step (when a backend is configured) — entity positions are fed into the backend, contacts are detected, physics corrections are written back, then per-entity contact events run if enabled, and an optional
OnContacthook if you registered one. See Collision & physics. - An internal flush collects spawns (full state for new entities), deltas (changed fields for existing ones), and queued removal IDs.
- Each removal ID is serialized using the function you provided to
SetRemovalSerializer. - If an
OnUpdatescallback is registered, it receives the combined[][]byte(for logging, metrics, or filtering). - When integrated networking is active, the server broadcasts the updates to all connected clients automatically.
OnTickStart fires before step 1 and OnTickEnd fires after step 8, both receiving the current tick counter. Use them to attach profiling instrumentation — see Profiling.
When interest management is enabled (CellSize > 0), step 8 changes: instead of broadcasting everything to everyone, the server updates a spatial grid, computes per-session visibility diffs, and sends each session only the entities in its field of interest. Your simulation code in steps 2–3 is unchanged.
Callbacks
Section titled “Callbacks”OnTick(dt, s) is your simulation entry point—dt is elapsed seconds, s is the server so you can call CreateEntity, Get, DeleteEntity, and so on. SetRemovalSerializer wires in the generated MarshalEntityRemoved function; it must be called before the loop starts if you ever remove entities. OnUpdates is an optional hook that receives the combined [][]byte for every change this tick, before auto-broadcast—useful for logging or metrics. AssignFOI / RemoveFOI attach or detach a circular field of interest to a session; see Interest management.
Session lifecycle
Section titled “Session lifecycle”When Addr is set, wire OnConnect, OnMessage, and OnDisconnect to handle players joining and leaving:
srv.OnConnect(func(sess *golem.Session) { // Runs on the tick goroutine — safe to call CreateEntity directly. player := synced.NewSyncedPlayer(0, 0, "Aria") _ = srv.CreateEntity(player, sess.ID)})
srv.OnMessage(func(sess *golem.Session, data []byte) { // Runs on the tick goroutine — safe to dispatch commands and mutate entities. if err := router.DispatchPacket(sess, data); err != nil { log.Println("command:", err) }})
srv.OnDisconnect(func(sess *golem.Session) { // Runs on the tick goroutine — safe to call DeleteEntity directly. srv.DeleteEntity(playerEntityFor(sess.ID))})All three callbacks are queued from the client’s connection goroutine and dispatched on the tick goroutine at the start of each tick (step 1 above). This means CreateEntity, DeleteEntity, and any entity mutation are safe to call from all three without additional synchronization — no manual queuing or mutexes needed.
Each event fires up to one tick after it occurs. For OnConnect specifically, the world-state snapshot is sent to the new client immediately (before the tick fires), and then your OnConnect handler runs on the next tick’s drain.
Entity updates broadcast to all connected clients automatically after each tick. When interest management is enabled, each client receives only the entities within its field of interest instead of a full broadcast.
Use srv.Send to deliver a message to a single client:
srv.Send(sess.ID, data)Per-session send buffers are bounded. If a client falls too far behind and its buffer fills, the session is closed to protect the server.
If you use WebTransport datagrams, register OnDatagram as well. Datagram callbacks are also queued onto the tick goroutine.
Static file serving
Section titled “Static file serving”Set StaticDir to serve static assets from the same port as the transport endpoint:
srv := golem.NewServer(golem.ServerConfig{ TickRate: 20, Addr: ":8080", StaticDir: "./public",})Non-transport HTTP requests are served from that directory. The transport endpoint takes priority on its configured path (default /ws for WebSocket, /wt for WebTransport). See Map file serving for MapDir, which handles Tiled/LDtk map files on a separate /maps/ prefix.
Advanced: mount the handler yourself
Section titled “Advanced: mount the handler yourself”If you omit Addr, Run only ticks—no HTTP server is started. Mount the server’s transport handler on your own http.ServeMux:
mux := http.NewServeMux()mux.Handle("/ws", srv.Handler())go func() { _ = http.ListenAndServe(":8080", mux) }()if err := srv.Run(ctx); err != nil && err != context.Canceled { log.Fatal(err)}When the configured transport is WebTransport, mount srv.Handler() on an HTTP/3 server instead of http.ListenAndServe.
See Minimal server wiring for the full custom-transport example including MapFileHandler wiring, and Channels and transports for transport-specific behavior.
Per-entity tick logic
Section titled “Per-entity tick logic”Not every game needs a central OnTick that knows about every entity type. If you prefer to co-locate simulation logic with the entity itself—similar to a MonoBehaviour.Update in Unity—implement the golem.Ticker interface on your entity struct:
type Enemy struct { *synced.SyncedEnemy}
func (e *Enemy) Tick(dt float64) { e.SetX(e.X() + e.Speed()*dt)}Every tick, the loop calls Tick(dt) on each entity that implements Ticker before your OnTick callback runs. This means entity-level updates happen first; OnTick can then coordinate across entities that have already stepped forward.
You can mix both styles freely. Entities without a Tick method are skipped; OnTick still fires every tick regardless. See Creation and destruction for the companion OnSpawn / OnRemove hooks.
Error handling and panics
Section titled “Error handling and panics”Run returns if FlushAll fails, if removal serialization fails, or if entities are removed without a serializer set. When integrated networking is active, a listener bind failure (e.g. port already in use) also causes Run to return with an error.
There is no built-in panic recovery in OnTick or OnUpdates—a panic there crashes the process. Add your own recover when you need resilience:
srv.OnTick(func(dt float64, s *golem.Server) { defer func() { if r := recover(); r != nil { log.Printf("tick panic: %v", r) } }() // ...})Next: Creation and destruction, Authority, Interest management, Collision & physics, Profiling.