Interest management (FOI)
Interest management (FOI)
Section titled “Interest management (FOI)”In most multiplayer games, a player does not need to know about every entity on the server. A character on the west side of the map has no use for NPCs on the east side. Field-of-interest (FOI) management solves this: each client can only see the entities near the character(s) it controls, while the server decides what “near” means.
When an entity enters a client’s FOI it is created for that client. While it stays inside the FOI, the client receives update deltas for the entity’s synchronized fields. When it leaves, the client receives a removal notice. The result is lower bandwidth usage, smaller client state, and cheat (zoom-hacking) prevention.
FOI is opt-in. When you don’t configure it, the server broadcasts every entity to every client. This is the default behaviour for small-scale games where everyone should see everything.
Enabling interest management
Section titled “Enabling interest management”FOI is enabled automatically if you set CellSize on ServerConfig. This is the side length (world units) of each cell the world is divided into, which narrows entity lookups before exact distance checks. It does not cap how far a client can see; it affects how much work each spatial query does.
Tuning: pick CellSize based on your largest FOI lookups. A reasonable choice is about 1.5× the maximum query reach (radius + margin).
srv := golem.NewServer(golem.ServerConfig{ TickRate: 20, Addr: ":8080", CellSize: 33, // assuming largest FOI is 20+2})When CellSize > 0, the server switches to per-session filtered sends instead of blind broadcast.
In 3D projects, interest management still uses the XY projection of each entity position. The FOI radius ignores pos_z, so entities above or below the anchor can still enter if they are close enough in X/Y. Use 3D collision queries for gameplay checks that need real volume.
Assigning a FOI
Section titled “Assigning a FOI”After creating a player’s avatar entity with CreateEntity (owned by that session), assign a FOI to that player’s session. The FOI follows the entity it is anchored to:
srv.OnConnect(func(sess *golem.Session) { avatar := synced.NewSyncedPlayer(nextID(), 0, 0, sess.ID) _ = srv.CreateEntity(avatar, sess.ID)
// The player sees entities within 100 units of their avatar. // The 20-unit margin prevents boundary flicker (hysteresis). srv.AssignFOI(sess.ID, avatar.EntityID(), 100, 20)})| Parameter | Meaning |
|---|---|
| sessionID | The client session this FOI belongs to. |
| entityID | The anchor entity—its position centres the FOI circle. Must have an owner. |
| radius | Entities within this distance enter the FOI. |
| margin | Entities must exceed radius + margin to exit. This hysteresis band prevents entities near the boundary from flickering in and out every tick. |
Remove a FOI when the player disconnects (or earlier if you need to):
srv.OnDisconnect(func(sess *golem.Session) { srv.RemoveFOI(sess.ID) srv.DeleteEntity(avatarID) // clean up the avatar entity})How it works each tick
Section titled “How it works each tick”When interest management is active, the tick cycle changes:
TickAllandOnTickrun as normal—your simulation is unchanged.- The interest system updates a spatial grid with every entity’s current position.
- For each session with a FOI, it queries the grid and diffs against the previous tick’s known set.
- The server flushes spawns, deltas, and removals as usual.
- Instead of broadcasting everything to everyone, the server sends per session:
- Entered entities get a full-state update (like a spawn).
- Stayed entities with dirty fields get their delta.
- Exited entities get a removal message.
Global entities bypass spatial checks and are always sent to every session—see Global entities below.
Sessions without a FOI assigned receive only global entity updates.
Position
Section titled “Position”Every entity has an implicit position (pos_x, pos_y, and pos_z in 3D projects). You don’t have to declare it in your YAML schema—it is always present in the generated struct, on the wire, and on the client.
Set it from your game logic just like any other field:
player.SetPosition(worldX, worldY)Read it back:
x, y := player.Position()On the JS client, posX and posY are available as getters on every synced entity, and 3D projects also expose posZ. These update automatically from state and delta messages.
The interest system reads each entity’s position every tick to maintain the spatial grid. Position is always synced as a tick-level field—changing it produces a delta just like any user-defined sync: tick variable.
Global entities
Section titled “Global entities”Some entities should be visible to all clients regardless of distance—scoreboards, match timers, team state. Mark them with global: true in the entity schema:
entity: Scoreboardglobal: true
vars: score_team_a: type: int32 score_team_b: type: int32Global entities still have a position (the field is always present), but the interest system ignores it for visibility—they are included in every session’s updates unconditionally.
Hysteresis
Section titled “Hysteresis”Without hysteresis, an entity sitting right at the edge of the FOI radius would enter one tick, exit the next, enter again, and so on, creating a stream of create/destroy messages the client sees as flickering.
The margin parameter solves this. An entity must cross inside the radius to enter the FOI, but it must travel past radius + margin to exit. This dead zone prevents oscillation:
radius radius + margin ───────┤──────────────┤ ▲ enters here ▲ exits hereA margin of 10-20 % of the radius is a reasonable starting point.
Minimal wiring example
Section titled “Minimal wiring example”Putting it together:
srv := golem.NewServer(golem.ServerConfig{ TickRate: 20, Addr: ":8080", CellSize: 150,})srv.SetRemovalSerializer(synced.MarshalEntityRemoved)
srv.OnTick(func(dt float64, s *golem.Server) { // move entities, run AI, etc. // Call SetPosition on entities that move.})
srv.OnConnect(func(sess *golem.Session) { avatar := synced.NewSyncedPlayer(nextID(), 0, 0, sess.ID) _ = srv.CreateEntity(avatar, sess.ID) srv.AssignFOI(sess.ID, avatar.EntityID(), 100, 20)})
srv.OnDisconnect(func(sess *golem.Session) { srv.RemoveFOI(sess.ID) // remove the avatar entity})
if err := srv.Run(ctx); err != nil && err != context.Canceled { log.Fatal(err)}No client-side changes are needed beyond the generated code—the JS client already handles spawns, deltas, and removals. When an entity enters or exits the FOI, the client sees the same messages it would for a normal spawn or remove.