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/cpp/samples/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/ZoneNode/main.cpp
auto mesh = options.add_route_mesh (names_t::mesh);
mesh.set_automatic_routing_id_prefix ("zn");
if (configuration.mesh_advertise_host)
mesh.set_advertise_host (*configuration.mesh_advertise_host);
mesh.listen (configuration.mesh_endpoint);
mesh.channel (names_t::zone_channel).server ();
mesh.channel (names_t::report_channel).client ();
mesh.channel (names_t::ops_channel (configuration.node_id))
.server ()
.add_request_handler<apply_maintenance_handler_t,
apply_node_maintenance_req_t,
apply_node_maintenance_res_t> ()
.add_request_handler<diagnostics_handler_t,
get_node_diagnostics_req_t,
get_node_diagnostics_res_t> ();
mesh.objects ()
.server ()
.add_entry_spot<zone_entry_spot_t> ()
.add_spot_factory<zone_spot_t, fw::actor_client_t> (names_t::zone_spot)
.set_stable_type_limit (configuration.zone_capacity)
.disable_relocation ()
.add_actor_factory<player_actor_t, player_actor_factory_t> (names_t::player_actor)
.preserve_state_with<player_relocation_adapter_t> ();
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/main.cpp
options.handlers ()
.group ("zoneworld-broadcast")
.add_publish<maintenance_fanout_handler_t> ()
.add_publish<announce_fanout_handler_t> ();
options.add_fanout_channel (names_t::broadcast_channel)
.enable_subscriber ()
.use_handler_group ("zoneworld-broadcast");
The Ops side is the publisher on the same fanout channel.
Server/Ops/main.cpp
options.add_fanout_channel (names_t::broadcast_channel)
.set_routing_id (zlink::routing_id_t::from ("zoneworld-ops-publisher"))
.enable_publisher (configuration.broadcast_endpoint);
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/Gateway/main.cpp
auto &actors = stream.actors ();
auto located = actors.get_or_create (names_t::player_actor, request.player_id);
if (!located)
throw fw::framework_exception_t (located.error_kind (),
"player actor could not be located");
auto bound = co_await actors.bind_or_get (located.value ().ref ()).async ();
_player_id = std::string (bound.actor_id ());
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/main.cpp
inline void prepare_join (player_actor_t &actor, int x, int y, bool initial, bool crash = false)
{
actor.pending_join = true;
actor.pending_initial_entry = initial;
actor.pending_crash_probe = crash;
actor.pending_x = x;
actor.pending_y = y;
actor.pending_zone_id = zone_of (x, y);
actor.context ()
.join_spot (actor.pending_zone_id,
enter_zone_req_t{actor.player_id,
x,
y,
actor.is_bot,
initial,
actor.initial_entry ? std::nullopt
: std::optional<std::string> (actor.zone_id),
crash})
// The crash probe must outlive the owner lease TTL (15 s) plus the
// Location poll so the join ends at the Unavailable boundary, not at
// its own deadline (same 30 s the java sample uses for the probe).
.timeout (std::chrono::seconds (crash ? 30 : 15))
.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/ZoneNode/main.cpp
if (const auto error = validate_move (actor.x, actor.y, message.x, message.y, false)) {
if (actor.is_bot) {
actor.dir_x = -actor.dir_x;
actor.dir_y = -actor.dir_y;
std::cout << "zoneworld-bot-reversed player=" << actor.player_id << " reason=" << *error
<< " position=" << actor.x << ',' << actor.y << " dir=" << actor.dir_x << ','
<< actor.dir_y << std::endl;
} else {
actor.context ()
.bound_session ()
.send (move_rejected_notify_t{*error, actor.x, actor.y})
.async ();
}
return false;
}
const auto target = zone_of (message.x, message.y);
if (target != actor.zone_id) {
prepare_join (actor, message.x, message.y, false);
return false;
}
actor.x = message.x;
actor.y = message.y;
return true;
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/main.cpp
actor.context ()
.join_spot (actor.pending_zone_id,
enter_zone_req_t{actor.player_id,
x,
y,
actor.is_bot,
initial,
actor.initial_entry ? std::nullopt
: std::optional<std::string> (actor.zone_id),
crash})
// The crash probe must outlive the owner lease TTL (15 s) plus the
// Location poll so the join ends at the Unavailable boundary, not at
// its own deadline (same 30 s the java sample uses for the probe).
.timeout (std::chrono::seconds (crash ? 30 : 15))
.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/ZoneNode/player_actor_relocation_adapter.hpp
fw::task_t<std::vector<std::byte>> capture (player_actor_t &actor, std::stop_token) override
{
const auto message = zlink::message_t::from_json (
player_state_t{actor.x,
actor.y,
actor.zone_id,
actor.is_bot,
actor.dir_x,
actor.dir_y,
actor.initial_entry,
actor.pending_join,
actor.pending_initial_entry,
actor.pending_crash_probe,
actor.pending_x,
actor.pending_y,
actor.pending_zone_id,
actor.completed_join_operation_order});
co_return std::vector<std::byte> (message.bytes ().begin (), message.bytes ().end ());
}
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/main.cpp
if (g_node_state->maintenance.load ()
&& (!enter.from_zone_id || *enter.from_zone_id != _context.spot_id ()))
co_return fw::spot_actor_join_result_t::reject (
enter_zone_res_t{_context.spot_id (), reject_reason_t::zone_maintenance});
{
std::lock_guard lock (_mutex);
_pending[std::string (actor_id)] = enter;
}
co_return fw::spot_actor_join_result_t::accept (
enter_zone_res_t{_context.spot_id (), std::nullopt});
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/player_actor_relocation_adapter.hpp
const auto operation = std::visit (
[] (const auto &value) {
return std::pair{value.operation_id_high, value.operation_id_low};
},
completion);
if (completed_join_operations.contains (operation))
co_return;
if (std::holds_alternative<fw::actor_join_accepted_t> (completion)) {
const std::string line = std::format (
"zoneworld-join-accepted player={} zone={} pending-initial={} bot={}\n",
player_id,
zone_id,
pending_initial_entry ? "true" : "false",
is_bot ? "true" : "false");
std::cerr << line;
if (pending_initial_entry && !is_bot) {
co_await _context.bound_session ()
.send (join_world_res_t{player_id, zone_id, x, y, std::nullopt})
.async ();
std::cout << "zoneworld-join-response player=" << player_id << " zone=" << zone_id
<< std::endl;
}
pending_join = false;
initial_entry = false;
pending_crash_probe = false;
} else if (const auto *failed = std::get_if<fw::actor_join_failed_t> (&completion)) {
const std::string line = std::format ("zoneworld-join-failed player={} kind={}\n",
player_id,
static_cast<int> (failed->error_kind));
std::cerr << line;
const auto error = framework_error_name (failed->error_kind);
if (pending_crash_probe && !is_bot) {
co_await _context.bound_session ()
.send (crash_relocation_probe_res_t{error})
.async ();
} else if (pending_initial_entry && !is_bot) {
co_await _context.bound_session ()
.send (join_world_res_t{player_id, zone_id, x, y, error})
.async ();
}
pending_join = false;
pending_crash_probe = false;
} else if (const auto *rejected = std::get_if<fw::actor_join_rejected_t> (&completion)) {
const std::string line = std::format ("zoneworld-join-rejected player={}\n", player_id);
std::cerr << line;
auto reason = std::string (reject_reason_t::zone_maintenance);
if (rejected->reply) {
reason = rejected->reply->decode<enter_zone_res_t> ().error.value_or (reason);
}
if (is_bot) {
dir_x = -dir_x;
dir_y = -dir_y;
} else if (pending_initial_entry) {
co_await _context.bound_session ()
.send (join_world_res_t{player_id, zone_id, x, y, reason})
.async ();
} else {
co_await _context.bound_session ()
.send (move_rejected_notify_t{reason, x, y})
.async ();
}
pending_join = false;
pending_crash_probe = false;
}
remember_join_operation (operation);
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/main.cpp
if (!actor.is_bot)
actor.context ()
.bound_session ()
.send (zone_state_notify_t{message.zone_id, message.tick, message.players})
.async ();
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/main.cpp
for (const auto &to : adjacent_zones (_context.spot_id ())) {
std::vector<player_view_t> border_players;
std::copy_if (local.begin (),
local.end (),
std::back_inserter (border_players),
[] (const auto &player) {
return std::abs (player.x - spec_t::zone_split) <= spec_t::border_band
|| std::abs (player.y - spec_t::zone_split)
<= spec_t::border_band;
});
_context
.publish (
border_topic (_context.spot_id (), to),
zone_border_event_t{_context.spot_id (), to, _tick, std::move (border_players)})
.async ();
}
Each zone Spot subscribes only to the topics coming from its own neighbours.
Server/ZoneNode/main.cpp
for (const auto &from : adjacent_zones (_context.spot_id ()))
_context.handlers ().add_subscribe<&zone_spot_t::border> (
border_topic (from, _context.spot_id ()));
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/Ops/main.cpp
co_await _publisher
.publish (names_t::broadcast_channel,
names_t::maintenance_topic,
node_maintenance_changed_event_t{request.node_id, request.enabled})
.async ();
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/main.cpp
struct maintenance_fanout_handler_t
{
using event_type = node_maintenance_changed_event_t;
static constexpr const char *topic_name = names_t::maintenance_topic;
void handle (const node_maintenance_changed_event_t &event,
const fw::publish_message_context_t &)
{
if (event.node_id == g_node_state->node_id)
g_node_state->maintenance.store (event.enabled);
}
};
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/main.cpp
_observation = runtime.observe (
names_t::mesh,
64,
[this] (const fw::observed_status_t<fw::mesh_node_snapshot_t> &observed) {
apply_snapshot (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.
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