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/game-api-module.ts
builder
.addStreamNode(SampleNames.playerStreamNode)
.enableActorDispatch()
.bind(config[streamEndpointKey])
.registerSession(GameQuestSessionFactory);
const mesh = builder
.addRouteMesh(SampleNames.playerQuestSpotMesh)
.listen(config[actorSpotEndpointKey])
.setRoutingIdPrefix('gamequest-api');
const objectServer = mesh.objects().server();
objectServer.addEntrySpot(GameQuestEntrySpot);
objectServer.addActorFactory(
SampleNames.playerActorType,
GameQuestPlayerActorFactory,
(factory) => factory.recreateOnRelocation()
);
The QuestMission side registers only the Instance Spot factory on the mesh's object server.
Server/QuestMission/gamequest-quest-module.ts
const spotMesh = builder
.addRouteMesh(SampleNames.playerQuestSpotMesh)
.listen(config[spotRouterEndpointKey])
.setRoutingIdPrefix('gamequest-mission');
spotMesh
.objects()
.server()
.addInstanceSpotFactory(SampleNames.playerQuestSpotType, PlayerQuestSpot, (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/game-api-session.ts
const located = await this.actorManager.find(request.playerId);
const created =
located === undefined
? await this.actorManager
.getOrCreate(request.playerId, SampleNames.playerActorType)
.inMesh(SampleNames.playerQuestSpotMesh)
.request(request)
.submit()
: undefined;
if (created?.status === 'rejected') {
throw new Error(`Player actor '${request.playerId}' creation was rejected.`);
}
const actorRef = located ?? created?.actor;
if (actorRef === undefined) {
throw new Error(`Player actor '${request.playerId}' was not resolved.`);
}
await context.actors.bindOrGet(actorRef);
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/Infrastructure/ZLink/gamequest-player-handlers.ts
@zlinkEntrySpotActorRequestHandler({
actor: () => GameQuestPlayerActor,
entrySpot: () => GameQuestEntrySpot,
packetName: PacketNames.killMonsterReq
})
class KillMonsterHandler extends GameQuestActionHandler<KillMonsterReq, { eventId: string }> {
constructor(actions: GameplayActionService) {
super(actions);
}
async handle(
_spot: GameQuestEntrySpot,
actor: GameQuestPlayerActor,
_context: ZLinkMessageContext,
request: KillMonsterReq
) {
this.requirePlayer(actor, request.playerId);
return (await this.actions.killMonster(request)).response;
}
}
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/Application/gameplay-action-service.ts
const { event: stored, recorded } = this.store.recordGameplayEvent(candidate);
try {
await this.publisher.send(stored);
} catch (error) {
if (
error instanceof ZLinkFrameworkException &&
error.kind === ZLinkFrameworkErrorKind.Unavailable
) {
console.error(`gamequest-owner unavailable player=${stored.playerId}`);
}
throw 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/Infrastructure/ZLink/gameplay-event-publisher.ts
const message = gameplayMsg(event);
await this.spots
.sendToSpot(questMissionSpotId(event.playerId), message)
.instanceSpot(SampleNames.playerQuestSpotType)
.inMesh(SampleNames.playerQuestSpotMesh)
.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/Infrastructure/ZLink/Spots/PlayerQuestSpot/player-quest-spot.ts
// The Instance Spot is addressed by `questMissionSpotId(playerId)`, so the owning
// player identity is already known at activation. Binding it here (the .NET
// reference sets `PlayerId = Context.SpotId` in `OnInitializeAsync`) means the Spot
// never depends on an inbound request field to learn who it belongs to.
async onInitialize(): Promise<void> {
this.playerId = playerIdFromQuestMissionSpotId(this.context.spotId);
this.aggregate = this.processor.rehydrate(this.playerId);
console.error(
`gamequest-spot initialize player=${this.playerId} spot=${this.context.spotId} generation=${this.context.objectGeneration}`
);
}
async onClosing(): Promise<void> {
console.error(
`gamequest-spot closing-entered player=${this.playerId} generation=${this.context.objectGeneration}`
);
}
bindPlayer(playerId: string): void {
if (this.playerId === '') {
this.playerId = playerId;
return;
}
if (this.playerId !== playerId) {
throw new Error(`Player quest spot '${this.playerId}' cannot process player '${playerId}'.`);
}
}
ensureAggregate(load: () => PlayerQuestAggregate): PlayerQuestAggregate {
this.aggregate ??= load();
return this.aggregate;
}
The gameplay message arrives at the Spot's packet handler.
Server/QuestMission/Infrastructure/ZLink/Spots/PlayerQuestSpot/player-quest-spot-handlers.ts
@Injectable()
@zlinkSpotPacketHandler({ spot: () => PlayerQuestSpot, packetName: 'GameplayMsg' })
class ApplyGameplayEventSpotHandler implements ZLinkSpotPacketHandler<
PlayerQuestSpot,
GameplayMsg
> {
constructor(
private readonly processor: QuestEventProcessor,
private readonly notifier: PlayerQuestNotifier,
@Inject(GAMEQUEST_INSTANCE_ID) private readonly missionName: string
) {}
async handle(spot: PlayerQuestSpot, message: GameplayMsg): Promise<void> {
requirePlayer(spot, message.playerId);
const aggregate = spot.ensureAggregate(() => this.processor.rehydrate(message.playerId));
const result = this.processor.process(
{
eventId: message.eventId,
playerId: message.playerId,
type: message.type,
payload: message.payload,
occurredAtUnixMs: message.occurredAtUnixMs
},
aggregate
);
spot.replaceAggregate(result.aggregate);
if (message.payload.idempotencyKey === 'owner-ready-intent') {
console.error(`gamequest-owner ready player=${message.playerId} node=${this.missionName}`);
}
await this.notifier.notify(message.playerId, result.changedProgress, result.completedQuestIds);
}
}
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/Application/quest-event-processor.ts
process(event: GameplayEventEnvelope, aggregate: PlayerQuestAggregate): QuestProcessingResult {
const decision = QuestDomain.decide(event, aggregate);
const result = this.commit(
event.playerId,
decision.events,
decision.changedQuestIds,
decision.completedQuestIds
);
for (const questId of decision.changedQuestIds) {
console.error(`gamequest-mission processed player=${event.playerId} quest=${questId}`);
}
return result;
}
rehydrate(playerId: string): PlayerQuestAggregate {
const events = this.events.rehydrate(playerId);
const aggregate = PlayerQuestAggregate.from(events);
this.readModel.project(playerId, events);
return aggregate;
}
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/Infrastructure/ZLink/player-quest-notifier.ts
const actor = await this.actorManager.find(playerId);
if (actor === undefined) {
console.error(`gamequest notification skipped: no bound actor location player=${playerId}`);
return;
}
for (const changed of progress) {
await this.actors
.sendToActor(
actor.actorId,
new DeliverQuestNotificationMsg(new QuestProgressNotify(playerId, changed))
)
.submit();
if (completedQuestIds.includes(changed.questId)) {
await this.actors
.sendToActor(
actor.actorId,
new DeliverQuestNotificationMsg(new QuestCompletedNotify(playerId, changed, true))
)
.submit();
}
}
GameApi's Entry Spot handler receives it and pushes a notify to the Actor's bound session.
Server/GameApi/Infrastructure/ZLink/quest-notification-handlers.ts
@zlinkEntrySpotActorSendHandler({
actor: () => GameQuestPlayerActor,
entrySpot: () => GameQuestEntrySpot,
packetName: PacketNames.deliverQuestNotificationMsg
})
class DeliverQuestNotificationHandler implements ZLinkEntrySpotActorSendHandler<
GameQuestEntrySpot,
GameQuestPlayerActor,
DeliverQuestNotificationMsg
> {
async handle(
_spot: GameQuestEntrySpot,
actor: GameQuestPlayerActor,
_context: ZLinkMessageContext,
message: DeliverQuestNotificationMsg
): Promise<void> {
if (message.packetName === PacketNames.questCompletedNotify) {
await actor.push(
new QuestCompletedNotify(message.playerId, message.progress, message.rewardGranted ?? false)
);
return;
}
if (message.packetName !== PacketNames.questProgressNotify) {
throw new Error(`Unsupported GameQuest notification '${message.packetName}'.`);
}
await actor.push(new QuestProgressNotify(message.playerId, message.progress));
}
}
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/Application/quest-event-processor.ts
// Contract §7.3: read the GameplayStateStore snapshot as the authoritative fact
// and let QuestDomain (the single owner of the First Hunt target) compare it
// against the current fold.
const snapshot = this.gameplay.readGameplaySnapshot(request.playerId);
const decision = QuestDomain.reconcileFirstHunt(request.playerId, snapshot, aggregate);
const result = this.commit(
request.playerId,
decision.events,
decision.changedQuestIds,
decision.completedQuestIds
);
if (decision.events.some((event) => event.type === 'QuestReconciled')) {
console.error(
`gamequest-mission reconciled player=${request.playerId} quest=${QuestIds.FirstHunt}`
);
}
return { ...result, updatedQuests: result.projection };
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/Infrastructure/ZLink/Spots/PlayerQuestSpot/player-quest-spot-handlers.ts
@Injectable()
@zlinkSpotPacketHandler({ spot: () => PlayerQuestSpot, packetName: 'ClosePlayerQuestMsg' })
class ClosePlayerQuestSpotHandler implements ZLinkSpotPacketHandler<
PlayerQuestSpot,
ClosePlayerQuestMsg
> {
constructor(
@Inject(QuestEventStore) private readonly events: QuestEventStore,
@Inject(GAMEQUEST_INSTANCE_ID) private readonly missionName: string
) {}
async handle(spot: PlayerQuestSpot, _message: ClosePlayerQuestMsg): Promise<void> {
this.events.closeOwner(spot.playerId, this.missionName);
void spot.context.close();
}
}
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