Skip to content

Reading Along: ZoneWorld

Guide Home | Previous: Reading Along: GameQuest | Next: 15. E2E Testing — Verifying the Whole System with a Client

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/node/samples/ZoneWorld.Ts.

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.

↗ View larger

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/ZoneNode/zone-node-module.ts

const zoneMesh = builder.addRouteMesh(ZoneWorldNames.zoneMesh).setRoutingIdPrefix('zn');
if (node.spotRouterAdvertiseHost !== undefined) {
  zoneMesh.setAdvertiseHost(node.spotRouterAdvertiseHost);
}
zoneMesh.listen(node.spotRouterEndpoint);
const objectServer = zoneMesh.objects().server();
objectServer.addEntrySpot(ZoneEntrySpot);
objectServer.addSpotFactory(ZoneSpot.name, ZoneSpot, (factory) =>
  factory.stableTypeLimit(node.zoneCapacity).disableRelocation()
);
objectServer.addActorFactory(
  ZoneWorldNames.playerActorType,
  PlayerActorFactory,
  (factory) => factory.preserveStateWith(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/ZoneNode/zone-node-module.ts

builder
  .addFanoutChannel(ZoneWorldNames.broadcastChannel)
  .enableSubscriber()
  .addHandlerGroup('zone-broadcast');

The Ops side is the publisher on the same fanout channel.

Server/Ops/ops-module.ts

builder
  .addFanoutChannel(ZoneWorldNames.broadcastChannel)
  .enablePublisher(ops.broadcastEndpoint)
  .setRoutingIdPrefix('zoneworld-ops');

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.

↗ View larger

Server/Gateway/player-session.ts

const ensured = await this.actors
  .getOrCreate(request.playerId, ZoneWorldNames.playerActorType)
  .inMesh(ZoneWorldNames.zoneMesh)
  .request(new PlayerActorCreateReq(request.playerId))
  .timeout(5_000)
  .submit();
if (ensured.status === 'rejected')
  throw new Error(`Player actor '${request.playerId}' creation was rejected.`);
const actor = await context.actors.bindOrGet(ensured.actor);

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/ZoneNode/Infrastructure/ZLink/Handlers/player-handlers.ts

const targetZone = zoneOf(request.x, request.y);
actor.beginPendingJoin(request.isBot ? 'bot' : 'world');
try {
  actor.context
    .joinSpot(
      targetZone,
      new EnterZoneReq(actor.actorId, request.x, request.y, request.isBot, true)
    )
    .timeout(10_000)
    .defer();
} catch (error) {
  actor.completePendingJoin();
  throw error;
}

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/ZoneNode/Infrastructure/ZLink/Handlers/player-handlers.ts

const decision = validateMove(actor, x, y, () => false);
if (decision.kind === 'rejected') {
  await this.reject(actor, decision.reason);
  return;
}
const targetZone = zoneOf(x, y);
if (!decision.zoneChanged) {
  actor.x = x;
  actor.y = y;
  const spotRid = actor.context.spotId;
  if (spotRid === undefined)
    throw new Error(`Player '${actor.actorId}' is not joined to a zone.`);
  await this.spotOutbound
    .sendToSpot(spotRid, new UpdateZonePositionMsg(actor.actorId, x, y))
    .submit();
  return;
}
actor.beginPendingJoin(actor.isBot ? 'bot' : 'move');
try {
  actor.context
    .joinSpot(targetZone, new EnterZoneReq(actor.actorId, x, y, actor.isBot, false))
    .timeout(10_000)
    .defer();
} catch (error) {
  actor.completePendingJoin();
  throw error;
}
console.log(
  `zone change scheduled player=${actor.actorId} from=${previousZone} to=${targetZone}`
);

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/ZoneNode/Infrastructure/ZLink/Handlers/player-handlers.ts

actor.beginPendingJoin(actor.isBot ? 'bot' : 'move');
try {
  actor.context
    .joinSpot(targetZone, new EnterZoneReq(actor.actorId, x, y, actor.isBot, false))
    .timeout(10_000)
    .defer();
} catch (error) {
  actor.completePendingJoin();
  throw error;
}
console.log(
  `zone change scheduled player=${actor.actorId} from=${previousZone} to=${targetZone}`
);

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.

↗ View larger

Server/ZoneNode/Infrastructure/ZLink/Actors/player-actor-relocation-adapter.ts

async capture(actor: PlayerActor): Promise<Uint8Array> {
  return new TextEncoder().encode(
    JSON.stringify({
      x: actor.x,
      y: actor.y,
      zoneId: actor.zoneId,
      isBot: actor.isBot,
      dirX: actor.dirX,
      dirY: actor.dirY,
      pendingJoin: actor.pendingJoin(),
      processedJoinOperations: actor.captureProcessedJoinOperations()
    } satisfies PlayerTransferState)
  );
}

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/ZoneNode/Infrastructure/ZLink/Spots/zone-spot.ts

if (this.nodeState.rejectsArrival()) {
  console.log(
    `zone admission rejected zone=${String(this.context.spotId)} player=${actorId} reason=maintenance`
  );
  return {
    accepted: false,
    reply: new EnterZoneRes(String(this.context.spotId), 'ZoneMaintenance')
  };
}
console.log(
  `zone admission accepted zone=${String(this.context.spotId)} player=${actorId} initial=${enter.initialEntry}`
);
this.pendingJoins.set(actorId, enter);
return { accepted: true };

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/ZoneNode/Infrastructure/ZLink/Actors/player-actor.ts

async onJoinCompleted(completion: ZLinkActorJoinCompletion): Promise<void> {
  const operationId = `${completion.operationId.high}:${completion.operationId.low}`;
  if (this.processedJoinOperations.has(operationId)) return;
  const kind = 'kind' in completion ? completion.kind : 'none';
  console.log(
    `actor join completed actor=${this.actorId} status=${completion.status}` + ` kind=${kind}`
  );
  const pending = this.pendingJoinKind;
  this.completePendingJoin();
  if (completion.status === 'accepted') {
    if (pending === 'world') await this.sendJoinResult(null);
    this.rememberJoinOperation(operationId);
    return;
  }
  if (pending === 'bot') {
    this.dirX *= -1;
    this.dirY *= -1;
    this.rememberJoinOperation(operationId);
    return;
  }
  let reason = 'ZoneMaintenance';
  if (completion.status === 'rejected' && completion.reply !== undefined) {
    reason = completion.reply.decode<EnterZoneRes>().error ?? reason;
  } else if (completion.status === 'failed') {
    reason = ZLinkFrameworkErrorKind[completion.kind];
  }
  if (pending === 'world') {
    await this.sendJoinResult(reason);
    this.rememberJoinOperation(operationId);
    return;
  }
  this.push(new MoveRejectedNotify(reason as never, this.x, this.y));
  this.rememberJoinOperation(operationId);
}

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/ZoneNode/Infrastructure/ZLink/Actors/player-actor.ts

case 'ZoneStateNotify':
  actor.push(
    new ZoneStateNotify(
      value.zoneId as string,
      value.tick as number,
      value.players as ConstructorParameters<typeof ZoneStateNotify>[2]
    )
  );
  return;

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/ZoneNode/Infrastructure/ZLink/Spots/zone-spot.ts

for (const adjacent of adjacentZones(state.zoneId)) {
  await this.publisher
    .publish(
      ZoneWorldNames.zoneMesh,
      ZoneWorldNames.bridgeMesh,
      ZoneWorldNames.borderTopic(state.zoneId, adjacent),
      new ZoneBorderEvent(state.zoneId, adjacent, tick, state.borderBandFor(adjacent))
    )
    .submit();
}

Each zone Spot subscribes only to the topics coming from its own neighbours.

Server/ZoneNode/Infrastructure/ZLink/Handlers/zone-runtime-handlers.ts

function zoneBorderSubscriptionHandler(): ClassDecorator {
  const topics = Object.values(ZoneIds).flatMap((from) =>
    adjacentZones(from).map((to) => ZoneWorldNames.borderTopic(from, to))
  );
  return (target) => {
    for (const topic of topics) {
      zlinkSpotSubscriptionHandler({
        spot: () => ZoneSpot,
        channelName: ZoneWorldNames.bridgeMesh,
        topic
      })(target);
    }
  };
}

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.

↗ View larger

Server/Ops/ops-handlers.ts

await this.fanout
  .publish(
    ZoneWorldNames.broadcastChannel,
    new NodeMaintenanceChangedEvent(request.nodeId, request.enabled)
  )
  .submit();

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/ZoneNode/Infrastructure/ZLink/Handlers/node-channel-handlers.ts

@Injectable()
@zlinkPublishHandler('zone-broadcast', PacketNames.nodeMaintenanceChangedEvent)
class MaintenanceChangedSubscriber implements ZLinkFanoutHandler<NodeMaintenanceChangedEvent> {
  constructor(private readonly state: NodeRuntimeState) {}

  async handle(
    message: NodeMaintenanceChangedEvent,
    context: ZLinkPublishMessageContext
  ): Promise<void> {
    this.state.setMaintenance(message.nodeId, message.enabled);
    console.log(
      `maintenance cache updated node=${message.nodeId} enabled=${message.enabled} topic=${context.topic}`
    );
  }
}

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/Ops/ops-runtime-events.ts

for await (const observed of this.routeMeshRuntime.observe(
  ZoneWorldNames.zoneMesh,
  64,
  this.stop.signal
)) {
  this.publish(this.nodes.applyLiveRoutingIds(this.liveRoutingIds(observed.status)));
}

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.

framework/languages/node/samples/ZoneWorld/run_sample.sh

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.