Reading Along: ZoneWorld¶
Guide Home | Previous: Reading Along: GameQuest | Next: 15. E2E Testing — Verifying the Whole System with a Client
View in another language — C++ · C#/.NET · Java · Kotlin · Node/TypeScript
What you get from this chapter
You can open the ZoneWorld sample in an editor and follow, in the code, the path of a player who
enters the world and crosses a zone boundary onto another node, and the path by which the ops
console's announcements and maintenance reach every node. The code in this chapter runs as it
stands in framework/languages/java/samples/kotlin/ZoneWorld.
Picking a Sample introduced what this sample demonstrates. This chapter is what you read after that introduction — the roles and where their code lives, the message flow of the main scenarios, and, for each flow, the framework feature it uses and the chapter that explains it, in the order the source is laid out. This chapter has no spec document that owns a contract. The requirements, message contract and verification criteria are owned by the ZoneWorld scenario, and this chapter does not restate them.
1. What This Sample Demonstrates¶
The world is divided into zones, and each zone is a User Spot with an id. Which ZoneNode owns which zone is decided by Location Store placement, not by configuration. A player is an Actor with an id, and crossing a zone boundary means joining the neighbouring zone Spot — when the owner differs, the framework moves the Actor, and the client's connection stays as it is. The ops console sends announcements and maintenance without a list of nodes.
The flow this chapter follows is entry and binding → moving and the boundary join → relocation and join completion → the zone tick's pushes and border snapshots → the ops fanout and node observation. The subject of this sample is that "doing something to several nodes" needs a different surface in each situation, and the code for each surface appears in that order.
2. Roles and Where the Code Lives¶
| Role | Processes | Owns | Code |
|---|---|---|---|
| Gateway | 1 | The game STREAM, player Actor binding, relay and pushes | Server/Gateway |
| ZoneNode | 2 | The Entry Spot, zone Spots, player Actors, bot timers, local reports | Server/ZoneNode |
| Ops | 1 | The ops STREAM, runtime event collection, fanout publish, the maintenance store | Server/Ops |
| Client | browser | The game screen and the ops console | Client |
Movement rules and zone coordinates live in Server/ZoneNode/Domain and reference no framework type.
Messages are set by the JSON contract in shared.
3. Server Configuration — Mesh, Capacity and Fanout¶
ZoneNode registers the Entry Spot, the player Actor factory and the zone Spot factory. The zone Spot factory declares a per-node capacity — every ZoneNode requests every zone, and the capacity is what makes each node own only some of them.
Server/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/Program.kt
mesh
.objects()
.server()
.addEntrySpot<ZoneEntrySpot>()
.addSpotFactory<ZoneSpot>(ZoneWorldNames.ZONE_SPOT_TYPE) {
stableTypeLimit(topology.zoneCapacityValue()).disableRelocation()
}
.addActorFactory<PlayerActor, PlayerActorFactory>(
ZoneWorldNames.PLAYER_ACTOR_TYPE
) {
preserveStateWith<PlayerActor, PlayerActorRelocationAdapter>()
}
The player Actor factory names an adapter that preserves state, and the zone Spot factory disables relocation. Separately from the mesh, the node registers a subscriber on the classic fanout channel.
Server/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/Program.kt
options
.addFanoutChannel(ZoneWorldNames.BROADCAST_CHANNEL)
.enableSubscriber()
.addHandlerGroup(ZoneWorldNames.BROADCAST_HANDLER_GROUP)
The Ops side is the publisher on the same fanout channel.
Server/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/Program.kt
options
.addFanoutChannel(ZoneWorldNames.BROADCAST_CHANNEL)
.setRoutingIdPrefix("zoneworld-kotlin-ops-broadcast")
.enablePublisher()
Placement by capacity is covered by Activation and Lifetime, and the fanout channel by Channel Messaging.
4. Entry — Binding and the First Zone Join¶
When the browser connects to Gateway and sends a join, the session creates or finds the Actor by
PlayerId, binds it to the current session, and relays the join packet to that Actor.
Server/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/gateway/GameSession.kt
val result =
kotlinActors
.getOrCreate(request.playerId, ZoneWorldNames.PLAYER_ACTOR_TYPE)
.inMesh(ZoneWorldNames.MESH)
.request(ZLinkMessage.empty())
.await()
if (result is ZLinkActorCreateResult.Rejected) error("actor creation rejected")
val actor =
when (result) {
is ZLinkActorCreateResult.Created -> result.actor
is ZLinkActorCreateResult.Existing -> result.actor
is ZLinkActorCreateResult.Rejected -> error("unreachable")
}
sessionContext.actors().bindOrGet(actor).await().kotlin().relay(dispatch, payload).await()
The Actor starts in the Entry Spot. The Entry Spot's handler derives the zone from the coordinates and reserves a join into that zone Spot.
Server/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/zone/ZoneDomain.kt
actor.prepareEntry(request.x, request.y, request.isBot, request.dirX, request.dirY)
val zone = ZoneWorldSpec.zoneOf(request.x, request.y)
actor
.context()
.joinSpot(
zone,
Messages.EnterZoneReq(
actor.actorId,
request.x,
request.y,
request.isBot,
true,
"",
false,
),
)
.defer()
The join runs after the handler returns, and its result arrives at the Actor's completion callback. The entry reply is sent from that callback — receiving it means admission into the target zone has completed. Binding is covered by Session and Actor, and the reservation rules by Actor Membership.
5. Movement — Inside a Zone and Across a Boundary¶
A move request is relayed to the bound Actor and arrives at the zone Spot's actor send handler. The domain decides, and the result splits into a rejection, a move inside the zone, or a zone change.
Server/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/zone/ZoneDomain.kt
val decision = ZoneWorldSpec.validateMove(actor.x, actor.y, targetX, targetY)
if (!decision.accepted) {
if (!actor.isBot)
actor.send(
Messages.MoveRejectedNotify(decision.reason ?: "Rejected", actor.x, actor.y)
)
return
}
val targetZone = ZoneWorldSpec.zoneOf(targetX, targetY)
if (!decision.zoneChanged) {
actor.updatePosition(targetX, targetY)
context
.outbound()
.kotlin()
.sendToSpot(
context.spotId(),
Messages.UpdatePositionMsg(actor.actorId, targetX, targetY, actor.isBot),
)
.await()
if (!actor.isBot)
kotlinActors
.sendToActor(
actor.actorId,
Messages.DeliverZoneStateMsg(context.spotId(), tickValue, statePlayers()),
)
.await()
return
}
actor.prepareMove(targetX, targetY, targetZone)
actor
.context()
.joinSpot(
targetZone,
Messages.EnterZoneReq(
actor.actorId,
targetX,
targetY,
actor.isBot,
false,
actor.zoneId,
false,
),
)
.timeout(Duration.ofSeconds(10))
.defer()
println(
"zone transfer requested actor=${actor.actorId} from=${context.spotId()} to=$targetZone node=${topology.nodeValue()}"
)
return
Inside the zone, the Actor updates its coordinate and sends the copy update to the zone Spot. When the zone changes, it reserves a join into the target zone Spot.
Server/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/zone/ZoneDomain.kt
actor.prepareMove(targetX, targetY, targetZone)
actor
.context()
.joinSpot(
targetZone,
Messages.EnterZoneReq(
actor.actorId,
targetX,
targetY,
actor.isBot,
false,
actor.zoneId,
false,
),
)
.timeout(Duration.ofSeconds(10))
.defer()
The application does not distinguish whether the target's owner is the same node or another. On the same node only the membership changes; on another node the framework moves the Actor inside the join. Messages that reach the previous owner during the move are forwarded to the target.
6. Relocation and Join Completion¶
When the owner changes, the Actor's application state is serialized by the adapter named on the factory. It carries only the coordinate, the zone, the bot direction and the pending join; queues and timers are moved by the framework.
Server/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/zone/ZoneDomain.kt
override fun capture(
actor: PlayerActor,
cancellation: ZLinkRelocationCancellation,
): CompletionStage<ByteArray> =
CompletableFuture.completedFuture(
mapper.writeValueAsBytes(
State(
actor.x,
actor.y,
actor.zoneId,
actor.isBot,
actor.dirX,
actor.dirY,
actor.pendingTargetX,
actor.pendingTargetY,
actor.pendingTargetZone,
actor.pending,
actor.pendingPurposeName,
actor.completedJoinIds.map { OperationId(it.high(), it.low()) },
)
)
)
The target zone Spot's admission is the final judge. If that node is under maintenance it rejects the join; otherwise it records the join as pending and accepts.
Server/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/zone/ZoneDomain.kt
if (maintenance.rejectsArrival(topology.nodeValue(), zone, join.fromZoneId)) {
return ZLinkSpotActorJoinResult.reject(Messages.EnterZoneRes(zone, "ZoneMaintenance"))
}
if (join.crashBoundaryProbe) {
println("crash-boundary join pending zone=$zone actor=$actorId")
awaitCancellation()
}
pending[actorId] = join
return ZLinkSpotActorJoinResult.accept(Messages.EnterZoneRes(zone))
The join's result arrives at the Actor's completion callback. The callback filters duplicates by the operation id, then sends the entry reply on acceptance or a failure notify on rejection to the bound session. A rejected move leaves the coordinate where it was.
Server/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/zone/ZoneDomain.kt
override suspend fun onJoinCompletedSuspending(completion: ZLinkActorJoinCompletion) {
val operationId =
when (completion) {
is ZLinkActorJoinCompletion.Accepted -> completion.operationId()
is ZLinkActorJoinCompletion.Rejected -> completion.operationId()
is ZLinkActorJoinCompletion.Failed -> completion.operationId()
}
if (operationId in completedJoins) return
rememberJoin(operationId)
pendingJoin = false
when (completion) {
is ZLinkActorJoinCompletion.Accepted -> {
val reply = completion.reply().decode<Messages.EnterZoneRes>()
val joinedZone =
reply.zoneId.ifBlank {
pendingZone.ifBlank { ZoneWorldSpec.zoneOf(pendingX, pendingY) }
}
applyAtZone(pendingX, pendingY, joinedZone, isBot)
pendingZone = ""
when (pendingPurpose) {
JoinPurpose.INITIAL_HUMAN ->
send(Messages.JoinWorldNotify(actorId, joinedZone, x, y))
JoinPurpose.CRASH_PROBE -> send(Messages.CrashRelocationProbeRes())
else -> Unit
}
pendingPurpose = JoinPurpose.NONE
}
else -> {
val reason =
when (completion) {
is ZLinkActorJoinCompletion.Rejected ->
completion.reply().decode<Messages.EnterZoneRes>().error ?: "Rejected"
is ZLinkActorJoinCompletion.Failed -> mapFailure(completion.kind().name)
else -> error("unreachable")
}
pendingZone = ""
when (pendingPurpose) {
JoinPurpose.INITIAL_HUMAN ->
send(
Messages.JoinWorldNotify(
actorId,
ZoneWorldSpec.zoneOf(pendingX, pendingY),
pendingX,
pendingY,
reason,
)
)
JoinPurpose.CRASH_PROBE -> send(Messages.CrashRelocationProbeRes(reason))
else ->
if (!isBot) send(Messages.MoveRejectedNotify(reason, x, y))
else reverseDirection()
}
pendingPurpose = JoinPurpose.NONE
}
}
}
What survives a move and the adapter's part are covered by Relocation, and the rule that the connection is kept during the move by How Session Binding Works.
7. The Zone Tick — Pushes and Border Snapshots¶
The zone Spot ticks on a timer and builds a state notify from its own zone and the neighbouring snapshots. The notify is sent to each player Actor, and the Actor's handler pushes it to the bound session. A bot is an Actor of the same type without a bound session.
▶ Try it in the interactive tour — RouteMesh pub/sub
Server/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/zone/ZoneDomain.kt
suspend fun deliverState(actor: PlayerActor, message: Messages.DeliverZoneStateMsg) {
if (residents[actor.actorId] !== actor) return
actor.send(Messages.ZoneStateNotify(message.zoneId, message.tick, message.players))
}
The state near the border is published on a topic per neighbouring zone. Both the sending and the receiving zone are in the topic name, so unrelated zones never receive it.
Server/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/zone/ZoneDomain.kt
private suspend fun publishBorders() {
ZoneWorldSpec.adjacentZones(context.spotId()).forEach { target ->
val players =
residents.values
.filter { ZoneWorldSpec.inBorderBand(it.x, it.y, context.spotId(), target) }
.map { Messages.PlayerView(it.actorId, it.x, it.y, context.spotId(), it.isBot) }
.sortedWith(compareBy(ZoneWorldSpec.utf8Order) { it.playerId })
context
.outbound()
.kotlin()
.publish(
ZoneWorldNames.ZONE_CHANNEL,
ZoneWorldNames.borderTopic(context.spotId(), target),
Messages.ZoneBorderEvent(context.spotId(), target, tickValue, players),
)
.await()
}
}
Each zone Spot subscribes only to the topics coming from its own neighbours.
Server/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/zone/ZoneDomain.kt
ZoneWorldSpec.adjacentZones(context.spotId()).forEach { fromZoneId ->
BorderSubscriptionHandlers.registerForRoute(
context.handlers(),
fromZoneId,
context.spotId(),
)
}
Timers are covered by Timers and Workers, and Logical Multicast that picks targets by topic by How Channels Work.
8. Operations — Fanout and Node Observation¶
Ops publishes announcements and the maintenance desired state over fanout. The publisher holds no list of nodes — adding a node changes nothing on this side.
Server/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/ops/OpsSession.kt
kotlinFanout
.publish(
ZoneWorldNames.BROADCAST_CHANNEL,
ZoneWorldNames.MAINTENANCE_TOPIC,
Messages.NodeMaintenanceChangedEvent(request.nodeId, request.enabled),
)
.await()
reply(Messages.SetMaintenanceRes(request.nodeId, request.enabled, node.zones))
The subscriber on each ZoneNode applies only its own NodeId's share to the local policy. Even when
this cache is stale, the final judge is the target admission of the previous section.
Server/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/zone/ZoneDomain.kt
@ZLinkHandlerGroup(ZoneWorldNames.BROADCAST_HANDLER_GROUP)
class NodeMaintenanceSubscriber(
private val state: NodeMaintenanceState,
private val store: MaintenanceStore,
private val topology: SampleTopology,
private val statusReporter: ObjectProvider<ZoneStatusReporter>,
) : ZLinkSuspendingPublishHandler<Messages.NodeMaintenanceChangedEvent> {
override suspend fun handle(
message: Messages.NodeMaintenanceChangedEvent,
context: ZLinkPublishMessageContext,
) {
state.apply(message.nodeId, message.enabled)
store.set(message.nodeId, message.enabled)
if (topology.nodeValue() == message.nodeId) {
println("maintenance state node=${message.nodeId} enabled=${message.enabled}")
statusReporter.ifAvailable?.let { it.reportNow() }
}
}
}
Whether a node is registered and connected is learned by observing runtime status, not by a request. Ops observes the mesh's status and turns changes in the set of Ready peers into node connection state.
Server/src/main/kotlin/systems/zlink/samples/kotlin/zoneworld/server/ops/NodeLivenessObserver.kt
val publisher = runtime.observe(ZoneWorldNames.MESH, 32)
observation = publisher
publisher.subscribe(
object : Flow.Subscriber<ZLinkObservedStatus<ZLinkMeshNodeSnapshot>> {
override fun onSubscribe(subscription: Flow.Subscription) {
subscription.request(Long.MAX_VALUE)
}
override fun onNext(observed: ZLinkObservedStatus<ZLinkMeshNodeSnapshot>) {
registry.applyLiveRoutingIds(readyRoutingIds(observed.status()))
}
override fun onError(error: Throwable) {
println(
"node observation error mesh=${ZoneWorldNames.MESH}" +
" detail=${error.message}"
)
}
override fun onComplete() {}
}
)
The forms of fanout are covered by How Channels Work, and runtime status and drain by Operations and Lifecycle.
9. Running and Verifying¶
One runner starts the Redis container, the server processes and the browser scenario together.
The browser scenario checks the boundary move, the connection that survives relocation, the arrival of announcements, and the admission rejection under maintenance. The checks and the scenario ids are set by the ZoneWorld scenario.
10. Related Documents¶
- Comparison with the other samples and how to choose: Picking a Sample
- Requirements, message contract and completion criteria: ZoneWorld scenario
- The same path — an Actor joining a room on another node — in the smallest layout: Reading Along: TicTacToe