Brandon Romano
// September 13, 2026
Entities and Behaviors
How the server runs the world without a tick loop
Anything in the world of Isles of the Cloud Realm that is not static terrain is an entity: players, enemies, trees, mining deposits, plants, crafting stations, etc. This post is about how the server represents them, and what each one is doing between the moments a player sees it act.
If you've read Client-Server Architecture, you've seen entities from the outside: spawn and appear messages carrying a state object, state_change messages when something happens, and map-chunk topics that route it all to nearby players. This is the inside of that picture.
On the server, each entity is a BaseEntity with a list of behaviors attached.
A behavior is a self-contained piece of game logic. MoveBehavior handles tile-based steps and validates geometry. HealthBehavior tracks hit points. InventoriesBehavior manages item slots. StateMachineBehavior runs a state machine. Rather than relying on inheritance, entities use composition (opens in new tab). An enemy is a BaseEntity with movement, health, ability, awareness, and state machine behaviors. A tree has health, variant, interaction, and state machine behaviors, but no movement behavior. A player entity attaches more than a dozen.
Three interfaces do most of the work:
// Contributes an entry to the entity's `state` object in spawn/appear.
type StatefulBehavior interface {
GetStateEntry(isSelf bool) (string, any)
}
// Wants to be told about messages addressed to (or near) this entity.
type MessageHandlerBehavior interface {
OnMessageReceived(ctx context.Context, message Message)
}
// Runs its own long-lived process.
type RunnableBehavior interface {
Run()
Stop()
}
When a client needs to learn about an entity, the server queries every StatefulBehavior for its entry and bundles them into a map. For an oak tree, health comes from HealthBehavior and variant from VariantBehavior. The isSelf parameter allows behaviors to return private data only to the entity's owner. Your inventory is included in your own spawn message, but excluded from the appear messages other players receive about you.
Because state is assembled from behaviors, supporting a new entity type on the wire requires minimal work. Adding a HealthBehavior to an entity automatically gives it a client-side health bar, and all existing health_loss messages apply to it immediately.
Most entities that act over time have a StateMachineBehavior, a RunnableBehavior whose Run executes inside its own goroutine (opens in new tab), Go's lightweight thread abstraction. That goroutine manages a finite-state machine (opens in new tab): a collection of named states with defined transitions.
type State interface {
Slug() string
Enter(ctx context.Context, data StateTransitionData)
Exit() (string, StateTransitionData)
ShouldBroadcastStateChange(from State) bool
}
The execution loop calls Enter on the current state, which blocks until the state completes, then calls Exit to determine the next state.
While other entities react to the world through programmed logic, the player's state machine is driven by an actual human on the other end of the client. Those inputs turn into server messages that push the state machine from one activity to the next:
Each state decides whether its transition gets broadcast to nearby players. When a client receives a state_change, it updates the entity's animations or visuals, such as swapping an oak tree to a stump when it moves from available to depleted.
Unlike many multiplayer games, Isles of the Cloud Realm is not tick-based. Nothing wakes every entity twenty times a second to poll for updates. An enemy's Standing state blocks for two to five seconds before transitioning to Patrolling. Patrolling picks a reachable tile, moves there one step at a time while sleeping for the exact step duration, and transitions to Pursuing if a player enters range. A tree's Available state blocks until a player sends a valid chop request.
Entities do not consume CPU while waiting (and entities are almost always waiting), so tens of thousands of them can sit idle for just a few kilobytes of memory each.
Take an enemy patrolling three tiles. Each time its goroutine wakes, it picks the next tile, validates the step, publishes the move, and goes straight back to sleep for the full duration of that step. I measured this on my laptop against the real test zone: an enemy step takes 1.19 seconds to walk, and the compute between sleeps has a median of about 2 µs. The first step of a patrol is closer to 13 µs, since it also picks a destination and runs A* to make sure the enemy can actually get there.
The diamonds are the only moments this entity touches a CPU. Everything else is the Go runtime holding a parked goroutine. Across 2,000 simulated patrols (12,598 steps), the goroutine was awake for a total of 0.97 seconds out of 4 hours and 10 minutes of walking, or 0.0065% of the time. That was with no players nearby to fan the messages out to, so treat it as a best case, but the shape of it doesn't change: the entity is asleep almost the entire time it exists.
Note
This is why I picked Go for the server. Its concurrency model (opens in new tab) makes goroutines cheap enough that every entity can have its own execution loop. When an entity is waiting, the Go runtime parks the goroutine and it costs almost no CPU. I get to write ordinary blocking code instead of managing OS threads or a global tick system.
The tradeoff is memory for compute. Even when parked, each goroutine holds a small stack (around 2 to 4 KB) plus some runtime overhead. At 100,000 entities, that's a few hundred megabytes of RAM holding dormant loops. A few hundred megabytes of cheap RAM beats polling 100,000 entities twenty times a second.
Entities are almost always asleep, so a message is what wakes them.
In the client-server post, players learn about nearby entities by subscribing to the map-chunk topics around them. Non-player entities subscribe to chunks the same way. A player entity forwards what it hears down a WebSocket; an enemy entity feeds what it hears into its behaviors. The subscription code is the same either way.
Every message that reaches an entity, from a chunk topic or its own direct topic, goes through BaseEntity first. It drops duplicates by message ID (a move that crosses a chunk boundary arrives from both chunks), ignores the entity's own messages (except move, because a behavior sometimes needs to know its owner has moved), and hands the rest to every registered MessageHandlerBehavior.
StateMachineBehavior is one of those. It forwards the message to the current state, if that state wants it:
type MessageHandlingState interface {
OnMessageReceived(ctx context.Context, message Message)
}
func (s *StateMachine) OnMessageReceived(ctx context.Context, message Message) {
if handler, ok := s.currentState.(MessageHandlingState); ok {
handler.OnMessageReceived(ctx, message)
}
}
A state only hears messages while it is active. A tree in Depleted can't be chopped because nothing is listening for chop requests; there is no if depleted { return } anywhere.
An enemy also subscribes to nearby chunks, but it only cares about players within its aggro range. EntityAwareBehavior does the narrowing. It watches chunk traffic for spawn, appear, move, disappear, and despawn, keeps a set of entities currently within range, and fires a callback when something enters or leaves that set.
The Standing state uses that callback to cut its sleep short:
func (s *Standing) Enter(ctx context.Context, _ StateTransitionData) {
ctxWithTimeout, cancel := context.WithTimeout(ctx, s.StandingDuration())
defer cancel()
defer s.EntityAwareBehavior.OnEntityAppear(func(entity Entity) {
if entity.GetEntityType() == player.PlayerEntityType {
s.foundTarget = entity.(combat.Combattant)
cancel()
}
})()
<-ctxWithTimeout.Done()
}
The goroutine parks on ctxWithTimeout.Done(). Two to five seconds later the timer fires and Exit picks between standing again or patrolling. If a player's move arrives on a chunk topic first, EntityAwareBehavior sees the destination is within two steps, fires the callback, and the callback cancels the timeout. Exit sees foundTarget is set and returns Pursuing.
Patrolling registers the same callback and checks foundTarget between steps, so an enemy mid-patrol turns on a player as soon as it finishes the tile it was walking to.
Note
Aggro range, attack range, deaggro distance, and idle timing all vary per enemy type. The numbers here are from one enemy, used as concrete values so the diagram is readable.
Pursuing paths to the nearest tile within attack range, walks it one step at a time, and re-paths if the target has moved since the last step. It exits when the target is within one tile (Fighting) or more than five tiles away (Standing). It also implements MessageHandlingState, listening for die or despawn from the target so it doesn't chase a corpse or a logged-out player. None of the enemy's states broadcast a state_change. Clients only see the move messages, which is enough to render a chase.
Chunk topics are for things everyone nearby should know. When one entity needs to tell exactly one other entity something, it uses that entity's direct topic, <id>/direct.
The chop sequence from the client-server post is two direct messages. When your interaction_requested arrives over the WebSocket, your player entity publishes an InteractionRequested to the tree's direct topic. The tree's Available state is blocked in Enter waiting for it:
func (s *Available) OnMessageReceived(ctx context.Context, message Message) {
gatherer, ok := tryAcceptGatherer(ctx, s.Entity, message)
if !ok {
return
}
s.nextSlug = StateEngagedSlug
s.nextData = EngagedEnterData{Gatherer: gatherer}
s.cancelEnter()
}
tryAcceptGatherer checks that this is the chop interaction, that the player is within one real walking step (measured with A*, so standing one level up with no slope down is rejected), that they meet the level requirement, and that they have an axe equipped. If everything passes, the tree publishes InteractionAccepted to the player's direct topic and wakes its own goroutine so Exit can move it into Engaged. The tree decides whether it can be chopped; the player only asked.
Your player entity's Idle state is parked the same way. Its OnMessageReceived sees the InteractionAccepted, turns you to face the tree, and queues the transition to Woodcutting. The two entities never call methods on each other. They publish and wait.
Note
Pub/sub delivers each message synchronously on the publisher's goroutine, so OnMessageReceived can run on many goroutines at once. If two players click the same tree in the same instant, both requests reach Available.OnMessageReceived concurrently. A mutex around the transition fields keeps the first one as the founding gatherer and queues the second to join once Engaged starts.
An entity is a bag of behaviors. One of them is a state machine. That state machine is a goroutine that sleeps until a message arrives on a topic it subscribed to.
