Skip to content

Reading Along: GameQuest

Guide Home | Previous: Reading Along: ShoppingMall | Next: Reading Along: ZoneWorld

What you get from this chapter

You can open the GameQuest sample in an editor and follow the code from a client's gameplay action, through the decision in the per-player owner Spot, to the progress and completion pushes, and through the reconciliation of lost progress. The code in this chapter comes from the GameQuest sample in the per-language example repositories.

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 GameQuest scenario, and this chapter does not restate them.

1. What This Sample Demonstrates

GameApi validates the client's action and turns it into a gameplay event, and one owner Spot on QuestMission decides every event of the same PlayerId in order. The decision is recorded in an event stream, and progress and completion are pushed to the connection through the player's session Actor. The layout is the same owner Spot plus event sourcing as ShoppingMall, but the progress-tier messages are best-effort — when one is lost, the authoritative fact is read and the progress is reconciled.

↗ View larger

The flow this chapter follows is join and the session Actor binding → action intake and the send to the owner → the owner Spot's decision and recording → the progress push → reconciliation and closing.

2. Roles and Where the Code Lives

Role Processes Owns Code
GameApi 2 STREAM sessions, session Actors, action validation and gameplay events, owner calls Server/GameApi
QuestMission 2 The player quest Instance Spot, replay, decision, append and projection, notifies Server/QuestMission
Client 1 The join, action, reconnect and reconcile assertions Client

Quest conditions, the aggregate fold and the completion rule live in Server/QuestMission/Domain and reference no framework type. Action validation and event creation belong to Server/GameApi/Application. Messages are set by the JSON contract in Shared.

3. Server Configuration

GameApi registers the Entry Spot and the session Actor factory as an object server and receives STREAM connections. QuestMission registers the player quest Instance Spot factory on the same mesh.

Server/GameApi/main.cpp

auto gamequest = options.add_route_mesh ("gamequest");
gamequest
  .set_routing_id (zlink::routing_id_t::from ("gamequest-" + topology.api_name + "-spot"))
  .listen (topology.selected_api_spot_route_endpoint ());
/* Location Store discovery owns every peer in this shared RouteMesh. */
gamequest.objects ()
  .server ()
  .add_entry_spot<player_entry_spot_t, game_api_store_t> ()
  .add_actor_factory<player_actor_t, player_actor_factory_t> (gamequest_player_actor_type)
  .recreate_on_relocation ();
options.add_stream_node (sample_names_t::stream_node)
  .bind (topology.selected_api_stream_endpoint ())
  .register_session<gamequest_session_t> ();

The QuestMission side registers only the Instance Spot factory on the mesh's object server.

Server/QuestMission/main.cpp

auto gamequest = options.add_route_mesh ("gamequest");
gamequest
  .set_routing_id (zlink::routing_id_t::from (topology.mission_name == "mission-b"
                                                ? sample_names_t::mission_b_rid
                                                : sample_names_t::mission_a_rid))
  .listen (topology.selected_mission_spot_route_endpoint ());
/* GameApi owns the outbound peer connections for this RouteMesh. A
     * RouteMesh connection carries traffic in both directions, so the
     * owner spot can send notifications over the accepted API link
     * without creating a duplicate admission path here. */
gamequest.objects ()
  .server ()
  .add_instance_spot_factory<player_quest_spot_t,
                             quest_event_store_t,
                             actor_directory_t,
                             actor_client_t,
                             sample_topology_t> (sample_names_t::player_quest_spot)
  .recreate_on_relocation ();

Both roles share one mesh and no per-mission channel name exists. The Instance Spot factory chooses the policy that recreates the Spot on the target during relocation — the state can be restored from the event stream. The creation policy of each kind is covered by Activation and Lifetime.

4. Join and the Session Actor

When the client joins, GameApi's session handler creates or finds the session Actor by PlayerId and binds it to the current session. This Actor holds no quest state — it is the place that carries the notifies sent by the owner Spot onto the connection.

Server/GameApi/main.cpp

auto actor = _actors.get_or_create (gamequest_player_actor_type, request.player_id);
if (!actor) {
    throw framework_exception_t (
      actor.error_kind (),
      actor.error () ? actor.error ()->what () : "gamequest session actor bind failed");
}
auto bound = co_await _actors.bind_or_get (actor.value ().ref ()).async ();

Reconnecting to a different GameApi binds a new session to the Actor with the same id. Binding is covered by Session and Actor.

5. Action Intake and the Send to the Owner

An action packet is relayed to the bound Actor and arrives at the Entry Spot's actor request handler. The handler delegates to the application service and replies with the issued EventId.

↗ View larger

Server/GameApi/main.cpp

if (packet == kill_monster_req_t::packet_name) {
    const auto request = payload.parse_json<kill_monster_req_t> ();
    const auto event = event_for (
      request.player_id, request.idempotency_key, "MonsterKilled", request.monster_id, 1);
    try {
        co_await apply_event (event);
    }
    catch (const framework_exception_t &error) {
        if (error.kind () == framework_error_kind_t::unavailable) {
            const std::string line = std::format ("gamequest-owner unavailable player={}\n",
                                                  request.player_id);
            std::cerr << line;
        }
        throw;
    }
    stream.reply_packet (zlink::message_t::from_json (kill_monster_res_t{event.event_id}))
      .async ();
    co_return;
}

The service stores the event under the idempotency key and then sends it to the owner. A repeated request with the same key yields the same EventId. If the owner is Unavailable the call fails as it is — the framework does not resend to another node.

Server/GameApi/main.cpp

const auto event = event_for (
  request.player_id, request.idempotency_key, "MonsterKilled", request.monster_id, 1);
try {
    co_await apply_event (event);
}
catch (const framework_exception_t &error) {
    if (error.kind () == framework_error_kind_t::unavailable) {
        const std::string line = std::format ("gamequest-owner unavailable player={}\n",
                                              request.player_id);
        std::cerr << line;
    }
    throw;
}

The message to the owner is a one-way send whose Spot id is the PlayerId. If no Spot has that id, this first message creates one on one of the eligible nodes.

Server/GameApi/main.cpp

co_await _routes.send_to_spot (player_spot_id (event.player_id), event)
  .instance_spot (sample_names_t::player_quest_spot)
  .async ();

The reply means GameApi accepted the action, not that the owner has decided it. The decision is confirmed by the push that follows. Calling an Instance Spot is covered by Spot, and the difference between send and request by Channel Messaging.

6. The Owner Spot's Decision and Recording

When the Spot is created, its initialize callback records the PlayerId and the generation.

Server/QuestMission/main.cpp

task_t<void> on_initialize () override
{
    const auto spot_id = _context.spot_id ();
    constexpr std::string_view prefix = "player:";
    _player_id = spot_id.starts_with (prefix) ? spot_id.substr (prefix.size ()) : spot_id;
    const auto generation = _store.rehydrate_owner (_player_id);
    const auto projection = _store.projection (_player_id);
    const std::string owner_ready_line = std::format (
      "gamequest-owner ready player={} node={}\n", _player_id, _topology.mission_name);
    std::cerr << owner_ready_line;
    if (!projection.empty ()) {
        const std::string replayed_line = std::format (
          "gamequest-mission replayed player={} generation={}\n", _player_id, generation);
        std::cerr << replayed_line;
    }
    co_return;
}

The gameplay message arrives at the Spot's packet handler.

Server/QuestMission/main.cpp

void configure () override
{
    _context.handlers ()
      .add_handler<&player_quest_spot_t::apply> (gameplay_msg_t::packet_name)
      .add_handler<&player_quest_spot_t::sync> (sync_quest_progress_owner_req_t::packet_name)
      .add_handler<&player_quest_spot_t::get> (get_quest_progress_req_t::packet_name)
      .add_handler<&player_quest_spot_t::admin> (projection_admin_req_t::packet_name)
      .add_handler<&player_quest_spot_t::close> (close_player_quest_msg_t::packet_name);
}

The processor reads the (PlayerId, QuestId) stream, restores the aggregate, evaluates the condition, appends the events and updates the projection. A source event already stored is not appended again.

Server/QuestMission/main.cpp

const auto rule = quest_rule_for (event);
if (!rule) {
    return {projection_unlocked (event.player_id), {}, {}, false};
}

auto projection = replay_unlocked (event.player_id);
const auto current = find_progress (projection, rule->quest_id);
const auto previous_count = current ? current->current_count : 0;
const auto previous_status = current ? current->status
                                     : std::string (quest_status_t::active);
const auto reconciliation = event.type == "SnapshotKillCount";
if (!reconciliation && previous_status == quest_status_t::reward_granted) {
    return {projection, {}, {}, false};
}

const auto next_count = reconciliation
                          ? std::max (previous_count, event.count)
                          : std::min (previous_count + rule->delta, rule->required_count);
if (next_count == previous_count) {
    return {projection_unlocked (event.player_id), {}, {}, false};
}
append_unlocked (stream,
                 reconciliation ? stored_quest_event_t::reconciled
                                : stored_quest_event_t::progressed,
                 event,
                 *rule,
                 next_count - previous_count,
                 next_count);

Messages of the same player are processed one at a time inside one Spot's turn, so there is no competing writer between the fold and the append. The serialization boundary is covered by The Execution Model.

7. The Progress Push — from the Owner to the Session Actor

Once the append completes, QuestMission sends the progress and completion messages with an Actor direct send addressed by PlayerId. Which GameApi holds that Actor is resolved by the framework.

Server/QuestMission/main.cpp

auto actor = co_await _directory.find (message.player_id);
if (!actor) {
    const std::string line = std::format ("gamequest mission kept projection while the "
                                          "player has no session binding. player={}\n",
                                          message.player_id);
    std::cerr << line;
    co_return;
}
co_await _actors
  .send (actor->actor_id (),
         notify_quest_progress_msg_t{
           message.player_id, result.projection, result.completed_quest_id})
  .async ();

GameApi's Entry Spot handler receives it and pushes a notify to the Actor's bound session.

Server/GameApi/main.cpp

void quest_progress_notified (player_actor_t &actor,
                              message_context_t &,
                              const notify_quest_progress_msg_t &notify)
{
    /* The actor may be hosted by another GameApi process. The local store
     * records the projection, while Framework bound_session() routes the
     * push to the current session owner. */
    if (!_store.record_notify (notify)) {
        return;
    }
    for (const auto &progress : notify.projection) {
        actor.actor_context.bound_session ()
          .send (quest_progress_notify_t{notify.player_id, progress})
          .async ();
    }
    if (!notify.completed_quest_id.empty ()) {
        const auto completed = std::find_if (notify.projection.begin (),
                                             notify.projection.end (),
                                             [&] (const quest_progress_t &progress) {
                                                 return progress.quest_id
                                                        == notify.completed_quest_id;
                                             });
        if (completed != notify.projection.end ()) {
            actor.actor_context.bound_session ()
              .send (quest_completed_notify_t{notify.player_id, *completed, true})
              .async ();
        }
    }
}

A notify while no session is bound is not a success condition. The state lives in the event store, and a reconnected client restores it with a query. Calling an Actor by id is covered by Actor.

8. Reconciliation and Closing

When a progress message is lost and the fact diverges from the fold, the client or an operator trigger sends a sync request. The owner Spot reads the authoritative snapshot and appends a reconciliation event through the same decision path.

↗ View larger

Server/QuestMission/main.cpp

sync_quest_progress_res_t sync (const sync_quest_progress_owner_req_t &request)
{
    if (request.snapshot_kill_count > 0) {
        const gameplay_msg_t snapshot{request.player_id + "-snapshot-"
                                        + std::to_string (request.snapshot_kill_count),
                                      request.player_id,
                                      "SnapshotKillCount",
                                      gameplay_payload ("kills", request.snapshot_kill_count),
                                      static_cast<long long> (std::time (nullptr)) * 1000LL};
        auto result = _store.apply (decode_gameplay (snapshot));
        if (!result.reconciled_quest_id.empty ()) {
            const std::string line = std::format (
              "gamequest-mission reconciled player={} quest={}\n",
              request.player_id,
              result.reconciled_quest_id);
            std::cerr << line;
        }
        return {std::move (result.projection)};
    }
    return {_store.projection (request.player_id)};
}

A message with the same PlayerId after an explicit close creates a Spot of a new generation, which replays the stream and continues.

Server/QuestMission/main.cpp

task_t<void> close (const close_player_quest_msg_t &)
{
    (void) _context.close ();
    co_return;
}

If the Ready owner's process disappears, the request in flight ends as Unavailable, and the framework does not create the same Spot on another node by itself. Close and generations are covered by Activation and Lifetime.

9. Running and Verifying

One runner starts the Redis container, the server processes and the client scenario together.

framework/languages/cpp/samples/GameQuest/run_sample.sh

The client asserts the action replies, the progress and completion notifies, the query after reconnecting and the reconciliation result. The checks and the exact log strings are set by the GameQuest scenario.