Reading Along: GameQuest¶
Guide Home | Previous: Reading Along: ShoppingMall | Next: Reading Along: ZoneWorld
View in another language — C++ · C#/.NET · Java · Kotlin · Node/TypeScript
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.
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/src/main/java/systems/zlink/samples/gamequest/server/gameapi/Program.java
options.addRouteMesh(SampleNames.PlayerQuestSpotDiscovery)
.setRoutingIdPrefix("gamequest-api")
.listen(api.spotRouterEndpoint())
.objects()
.server()
.addEntrySpot(GameQuestEntrySpot.class)
.addActorFactory(
SampleNames.PlayerSessionActorType,
GameQuestPlayerActor.class,
GameQuestPlayerActorFactory.class,
factory -> factory.recreateOnRelocation());
options.addStreamNode(SampleNames.StreamNode)
.bind(api.streamEndpoint())
.enableActorDispatch()
.registerSession(GameQuestSession.class);
The QuestMission side registers only the Instance Spot factory on the mesh's object server.
Server/QuestMission/src/main/java/systems/zlink/samples/gamequest/server/questmission/Program.java
ZLinkMeshNodeBuilder node = options.addRouteMesh(SampleNames.PlayerQuestSpotDiscovery);
node.listen(mission.spotRouterEndpoint()).setRoutingIdPrefix("gamequest-mission-owner");
node.objects()
.server()
.addInstanceSpotFactory(
SampleNames.PlayerQuestSpotType,
PlayerQuestSpot.class,
factory -> factory.recreateOnRelocation());
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/src/main/java/systems/zlink/samples/gamequest/server/gameapi/sessions/GameQuestSession.java
return ensurePlayerActor(request)
.thenCompose(actorRef -> context.actors().bind(actorRef))
.thenCompose(
bound -> {
playerActor = bound;
return channels.requestToSpot(
request.playerId(),
new Messages.GetQuestProgressReq(request.playerId()))
.timeout(SampleTimings.RequestTimeout)
.instanceSpot(SampleNames.PlayerQuestSpotType)
.inMesh(SampleNames.PlayerQuestSpotDiscovery)
.submit(Messages.GetQuestProgressRes.class)
.thenAccept(
ownerProjection -> {
store.mergeProjection(
request.playerId(),
ownerProjection.activeQuests());
context.client()
.reply(
new Messages.JoinSessionRes(
ownerProjection
.activeQuests()))
.submit();
});
});
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.
Server/GameApi/src/main/java/systems/zlink/samples/gamequest/server/gameapi/sessions/GameQuestSession.java
private CompletionStage<Void> handleKill(Messages.KillMonsterReq request) {
Messages.GameplayMsg event =
event(
request.playerId(),
request.idempotencyKey(),
"kill",
request.monsterId(),
1,
true);
store.recordGameplay(event);
return process(event)
.thenRun(
() ->
System.out.printf(
"gamequest-api event-routed player=%s%n", event.playerId()))
.thenCompose(
ignored ->
context.client()
.reply(new Messages.KillMonsterRes(event.eventId()))
.submit())
.exceptionallyCompose(
error -> {
if (isUnavailable(error)) {
System.out.printf(
"gamequest-owner unavailable player=%s%n",
event.playerId());
return CompletableFuture.failedFuture(
new ZLinkFrameworkException(
ZLinkFrameworkErrorKind.UNAVAILABLE,
"Mission owner is unavailable",
error));
}
return CompletableFuture.failedFuture(error);
});
}
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/src/main/java/systems/zlink/samples/gamequest/server/gameapi/sessions/GameQuestSession.java
store.recordGameplay(event);
return process(event)
.thenRun(
() ->
System.out.printf(
"gamequest-api event-routed player=%s%n", event.playerId()))
.thenCompose(
ignored ->
context.client()
.reply(new Messages.KillMonsterRes(event.eventId()))
.submit())
.exceptionallyCompose(
error -> {
if (isUnavailable(error)) {
System.out.printf(
"gamequest-owner unavailable player=%s%n",
event.playerId());
return CompletableFuture.failedFuture(
new ZLinkFrameworkException(
ZLinkFrameworkErrorKind.UNAVAILABLE,
"Mission owner is unavailable",
error));
}
return CompletableFuture.failedFuture(error);
});
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/src/main/java/systems/zlink/samples/gamequest/server/gameapi/sessions/GameQuestSession.java
private CompletionStage<Void> process(Messages.GameplayMsg event) {
return channels.sendToSpot(event.playerId(), event)
.instanceSpot(SampleNames.PlayerQuestSpotType)
.inMesh(SampleNames.PlayerQuestSpotDiscovery)
.submit();
}
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/src/main/java/systems/zlink/samples/gamequest/server/questmission/spots/PlayerQuestSpot.java
@Override
public CompletionStage<Void> onInitialize() {
store.activate(playerId);
if (store.hasEvents(playerId)) {
System.out.printf(
"gamequest-mission replayed player=%s generation=%d%n",
playerId, context.objectGeneration());
}
if ("player-owner-unavailable".equals(playerId)) {
System.out.printf(
"gamequest-owner-initialized player=%s node=%s%n", playerId, store.nodeId());
}
return CompletableFuture.completedFuture(null);
}
The gameplay message arrives at the Spot's packet handler.
Server/QuestMission/src/main/java/systems/zlink/samples/gamequest/server/questmission/spots/handlers/ApplyGameplaySpotHandler.java
public final class ApplyGameplaySpotHandler
implements ZLinkSpotPacketHandler<PlayerQuestSpot, Messages.GameplayMsg> {
private final ZLinkActorClient actors;
public ApplyGameplaySpotHandler(ZLinkActorClient actors) {
this.actors = actors;
}
@Override
public CompletionStage<Void> handle(PlayerQuestSpot spot, Messages.GameplayMsg request) {
Messages.QuestProcessingMsg result = spot.apply(request);
if (!result.duplicate()) {
String questId = questIdFor(request);
if (questId != null) {
System.out.printf(
"gamequest-mission processed player=%s quest=%s%n",
request.playerId(), questId);
}
}
return actors.sendToActor(request.playerId(), result).submit();
}
private static String questIdFor(Messages.GameplayMsg request) {
return switch (request.eventType()) {
case "kill" -> Messages.QuestIds.FirstHunt;
case "collect" -> Messages.QuestIds.HerbGathering;
case "feature" -> Messages.QuestIds.OpenAuction;
default -> null;
};
}
}
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/src/main/java/systems/zlink/samples/gamequest/server/questmission/store/QuestStore.java
PlayerState state = state(event.playerId());
if (state.appliedEventIds.contains(event.eventId())) {
shared.recordDeduplicatedEvent(event.eventId());
return new Messages.QuestProcessingMsg(
event.eventId(),
event.playerId(),
copyProjection(state),
List.of(),
List.of(),
true);
}
List<Messages.QuestProgress> current = copyProjection(state);
QuestDomain.QuestDecision decision = domain.apply(event, current, nextVersion(state));
state.projection = new ArrayList<>(decision.projection());
state.events.addAll(decision.storedEvents());
shared.writeProjection(event.playerId(), decision.projection());
shared.appendQuestEvents(decision.storedEvents());
state.appliedEventIds.add(event.eventId());
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/src/main/java/systems/zlink/samples/gamequest/server/questmission/spots/handlers/ApplyGameplaySpotHandler.java
Messages.QuestProcessingMsg result = spot.apply(request);
if (!result.duplicate()) {
String questId = questIdFor(request);
if (questId != null) {
System.out.printf(
"gamequest-mission processed player=%s quest=%s%n",
request.playerId(), questId);
}
}
return actors.sendToActor(request.playerId(), result).submit();
GameApi's Entry Spot handler receives it and pushes a notify to the Actor's bound session.
Server/GameApi/src/main/java/systems/zlink/samples/gamequest/server/gameapi/spots/QuestProcessingActorHandler.java
public final class QuestProcessingActorHandler
implements ZLinkEntrySpotActorSendHandler<
GameQuestEntrySpot, GameQuestPlayerActor, Messages.QuestProcessingMsg> {
private final GameQuestStore store;
public QuestProcessingActorHandler(GameQuestStore store) {
this.store = store;
}
@Override
public CompletionStage<Void> handle(
GameQuestEntrySpot entrySpot,
GameQuestPlayerActor actor,
ZLinkMessageContext context,
Messages.QuestProcessingMsg message) {
store.mergeProjection(message.playerId(), message.projection());
return actor.push(message);
}
}
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.
Server/QuestMission/src/main/java/systems/zlink/samples/gamequest/server/questmission/store/QuestStore.java
int firstHuntCount =
gameplay.snapshot(playerId).killCounts().stream()
.filter(kill -> "wolf".equals(kill.monsterId()))
.mapToInt(Messages.KillCountSnapshot::count)
.findFirst()
.orElse(0);
List<Messages.QuestProgress> projection = copyProjection(state);
Messages.QuestProgress firstHunt =
projection.stream()
.filter(progress -> progress.questId().equals(Messages.QuestIds.FirstHunt))
.findFirst()
.orElse(null);
if (firstHunt == null || firstHunt.currentCount() < firstHuntCount) {
long now = Instant.now().toEpochMilli();
Messages.QuestProgress reconciled =
new Messages.QuestProgress(
playerId,
Messages.QuestIds.FirstHunt,
firstHuntCount >= 3
? Messages.QuestStatuses.RewardGranted
: Messages.QuestStatuses.InProgress,
firstHuntCount,
3,
"sync-" + now,
now);
projection.removeIf(progress -> progress.questId().equals(Messages.QuestIds.FirstHunt));
projection.add(reconciled);
state.projection = projection;
Messages.StoredQuestEvent reconciledEvent =
new Messages.StoredQuestEvent(
"sync-" + now,
null,
playerId,
Messages.QuestIds.FirstHunt,
Messages.QuestProgressReconciledEvent.class.getSimpleName(),
0,
reconciled.currentCount(),
reconciled.requiredCount(),
reconciled.status(),
nextVersion(state),
now);
state.events.add(reconciledEvent);
shared.writeProjection(playerId, projection);
shared.appendQuestEvents(List.of(reconciledEvent));
System.out.printf(
"gamequest-mission reconciled player=%s quest=%s%n",
playerId, Messages.QuestIds.FirstHunt);
}
return new Messages.SyncQuestProgressRes(copyProjection(state));
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/src/main/java/systems/zlink/samples/gamequest/server/questmission/spots/handlers/ClosePlayerQuestSpotHandler.java
public final class ClosePlayerQuestSpotHandler
implements ZLinkSpotPacketHandler<PlayerQuestSpot, ClosePlayerQuestMsg> {
@Override
public CompletionStage<Void> handle(PlayerQuestSpot spot, ClosePlayerQuestMsg message) {
spot.context().close();
return CompletableFuture.completedFuture(null);
}
}
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.
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.
10. Related Documents¶
- Comparison with the other samples and how to choose: Picking a Sample
- Requirements, message contract and completion criteria: GameQuest scenario
- The same owner Spot and event sourcing applied to a lossless domain: Reading Along: ShoppingMall