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/src/main/kotlin/systems/zlink/samples/kotlin/gamequest/server/gameapi/Program.kt

options
    .addRouteMesh(SampleNames.PlayerQuestMesh)
    .setRoutingId(RoutingId.from("gamequest-api-${api.instanceName}"))
    .listen()
    .objects()
    .server()
    .addEntrySpot<GameQuestEntrySpot>()
    .addActorFactory<GameQuestPlayerActor, GameQuestPlayerActorFactory>(
        SampleNames.PlayerSessionActorType
    ) {
        recreateOnRelocation()
    }
options
    .addStreamNode(SampleNames.StreamNode)
    .bind(api.streamEndpoint)
    .enableActorDispatch()
    .registerSession<GameQuestSession>()

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

Server/QuestMission/src/main/kotlin/systems/zlink/samples/kotlin/gamequest/server/questmission/Program.kt

options
    .addRouteMesh(SampleNames.PlayerQuestMesh)
    .setRoutingId(RoutingId.from("gamequest-mission-${mission.instanceName}"))
    .listen(mission.channelEndpoint)
    .objects()
    .server()
    .addInstanceSpotFactory<PlayerQuestSpot>(SampleNames.PlayerQuestSpotType) {
        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/kotlin/systems/zlink/samples/kotlin/gamequest/server/gameapi/Program.kt

val actorRef = ensurePlayerActor(request)
playerActor =
    context.actors().find(actorRef.actorId).orElse(null)
        ?: context.actors().bind(actorRef).await()

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/src/main/kotlin/systems/zlink/samples/kotlin/gamequest/server/gameapi/Program.kt

private suspend fun handleKill(request: KillMonsterReq) {
    try {
        val processed =
            process(
                event(
                    request.playerId,
                    request.idempotencyKey,
                    "kill",
                    request.monsterId,
                    1,
                    true,
                )
            )
        context.client().kotlin().reply(KillMonsterRes(processed.eventId)).await()
    } catch (failure: Exception) {
        println("gamequest-owner unavailable player=${request.playerId}")
        throw failure
    }
}

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/kotlin/systems/zlink/samples/kotlin/gamequest/server/gameapi/Program.kt

store.recordGameplay(event)
routes
    .sendToSpot(event.playerId, event)
    .instanceSpot(SampleNames.PlayerQuestSpotType)
    .inMesh(SampleNames.PlayerQuestMesh)
    .await()
println("gamequest-api event-routed player=${event.playerId}")

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/kotlin/systems/zlink/samples/kotlin/gamequest/server/gameapi/Program.kt

routes
    .sendToSpot(event.playerId, event)
    .instanceSpot(SampleNames.PlayerQuestSpotType)
    .inMesh(SampleNames.PlayerQuestMesh)
    .await()

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/kotlin/systems/zlink/samples/kotlin/gamequest/server/questmission/Program.kt

override fun onInitialize(): CompletionStage<Void> {
    val rehydrationCount = store.markRehydrated(instanceContext.spotId())
    val generation = instanceContext.objectGeneration()
    if (rehydrationCount > 1) {
        println(
            "gamequest-mission replayed player=${instanceContext.spotId()} generation=$generation"
        )
    }
    println(
        "gamequest-owner-ready player=${instanceContext.spotId()} " +
            "node=${store.nodeName()} generation=$generation"
    )
    return CompletableFuture.completedFuture(null)
}

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

Server/QuestMission/src/main/kotlin/systems/zlink/samples/kotlin/gamequest/server/questmission/Program.kt

class GameplayMsgRouteHandler(private val actors: ZLinkActorClient) :
    ZLinkSuspendingSpotPacketHandler<PlayerQuestSpot, GameplayMsg> {
    override suspend fun handle(spot: PlayerQuestSpot, request: GameplayMsg) {
        val processed = spot.apply(request)
        processed.projection.firstOrNull()?.let { progress ->
            println(
                "gamequest-mission processed player=${request.playerId} quest=${progress.questId}"
            )
        }
        actors.kotlin().sendToActor(request.playerId, processed).await()
    }
}

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/kotlin/systems/zlink/samples/kotlin/gamequest/server/questmission/Program.kt

restorePlayer(event.playerId)
val key = "${event.playerId}:${event.decodePayload().idempotencyKey}"
eventIdsByIdempotency[key]?.let {
    return QuestProcessingMsg(
        it,
        event.playerId,
        copyProjection(event.playerId),
        emptyList(),
        emptyList(),
        true,
    )
}
eventIdsByIdempotency[key] = event.eventId
val decision =
    domain.apply(event, copyProjection(event.playerId), nextVersion(event.playerId))
projections[event.playerId] = decision.projection.toMutableList()
events += decision.storedEvents
shared.writeProjection(event.playerId, decision.projection)
shared.appendQuestEvents(decision.storedEvents)

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/kotlin/systems/zlink/samples/kotlin/gamequest/server/questmission/Program.kt

processed.projection.firstOrNull()?.let { progress ->
    println(
        "gamequest-mission processed player=${request.playerId} quest=${progress.questId}"
    )
}
actors.kotlin().sendToActor(request.playerId, processed).await()

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

Server/GameApi/src/main/kotlin/systems/zlink/samples/kotlin/gamequest/server/gameapi/Program.kt

class QuestProcessingActorHandler(private val store: GameQuestStore) :
    ZLinkSuspendingEntrySpotActorSendHandler<
        GameQuestEntrySpot,
        GameQuestPlayerActor,
        QuestProcessingMsg,
    > {
    override suspend fun handle(
        entrySpot: GameQuestEntrySpot,
        actor: GameQuestPlayerActor,
        context: ZLinkMessageContext,
        message: QuestProcessingMsg,
    ) {
        store.mergeProjection(message.playerId, message.projection)
        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.

↗ View larger

Server/QuestMission/src/main/kotlin/systems/zlink/samples/kotlin/gamequest/server/questmission/Program.kt

restorePlayer(playerId)
val firstHuntCount =
    gameplay.snapshot(playerId).killCounts.firstOrNull { it.monsterId == "wolf" }?.count
        ?: 0
val projection = copyProjection(playerId).toMutableList()
val firstHunt = projection.firstOrNull { it.questId == QuestIds.FirstHunt }
if (firstHunt == null || firstHunt.currentCount < firstHuntCount) {
    val now = Instant.now().toEpochMilli()
    val reconciled =
        QuestProgress(
            playerId,
            QuestIds.FirstHunt,
            if (firstHuntCount >= 3) QuestStatuses.RewardGranted
            else QuestStatuses.InProgress,
            firstHuntCount,
            3,
            "sync-$now",
            now,
        )
    projection.removeIf { it.questId == QuestIds.FirstHunt }
    projection += reconciled
    projections[playerId] = projection
    val reconciledEvent =
        StoredQuestEvent(
            "sync-$now",
            null,
            playerId,
            QuestIds.FirstHunt,
            QuestProgressReconciledEvent::class.java.simpleName,
            reconciled.currentCount,
            reconciled.requiredCount,
            reconciled.status,
            nextVersion(playerId),
            now,
        )
    events += reconciledEvent
    shared.writeProjection(playerId, projection)
    shared.appendQuestEvents(listOf(reconciledEvent))
    println("gamequest-mission reconciled player=$playerId quest=${QuestIds.FirstHunt}")
}
return SyncQuestProgressRes(copyProjection(playerId))

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/kotlin/systems/zlink/samples/kotlin/gamequest/server/questmission/Program.kt

// #895: the spec has no Kotlin handler form for an entry/instance Spot packet handler.
class ClosePlayerQuestSpotHandler : ZLinkSpotPacketHandler<PlayerQuestSpot, ClosePlayerQuestMsg> {
    override fun handle(
        spot: PlayerQuestSpot,
        request: ClosePlayerQuestMsg,
    ): CompletionStage<Void> {
        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.

framework/languages/java/samples/kotlin/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.