Skip to content

Reading Along: TicTacToe

Guide Home | Previous: Reading Along: Bingo | Next: Reading Along: SupportChat

What you get from this chapter

You can open the TicTacToe sample in an editor and follow the code from the HTTP call that creates a room to the point where both players leave and their Actors are destroyed. The code in this chapter comes from the TicTacToe 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 explains the TicTacToe sample's roles and code locations, its main message flows, and its run verification in source order. See the TicTacToe scenario for requirements, message contracts, and verification criteria.

1. What This Sample Demonstrates

There is no separate Session server. Play owns the STREAM connection, the player Actor, the Entry Spot and the room Spot together, and Api handles only HTTP room creation and authentication. The connections between servers are not resolved through the Location Store; the code connects directly to the endpoints the runner supplies. Handlers are not left to scanning either — each one is listed in registration code.

↗ View larger

What is manual is the connection between nodes. Where a room or an Actor currently lives is still resolved by the Location Store. The host connects to Play A and the guest and observer to Play B, so within one game the node that owns the room and the node where an Actor was created are bound to differ — how the framework handles that case is the second subject of this sample.

2. Roles and Where the Code Lives

Role Processes Owns Code
Api 2 HTTP room creation, token verification Server/Api
Play 2 STREAM session, Entry Spot, player Actor, room Spot, milestone publish Server/Play/Infrastructure
Client 3 The host, guest and observer scenarios with self-checks Client

The board, the turn and the win/draw decision live in Server/Play/Domain and reference no framework type. Messages are set by the JSON contract in shared.

3. Server Configuration — Manual Connection and Manual Registration

Play registers the stream node, the API channel client and the route mesh, and adds the Entry Spot, the player Actor factory and the room Spot factory to the mesh's object server. The remote endpoints of the API channel and the mesh are read from settings and connected directly.

Server/src/main/kotlin/systems/zlink/samples/kotlin/tictactoe/server/play/PlayServer.kt

val apiClient = options.addClientServerChannel(SampleNames.ApiChannel).client()
settings.apiChannelEndpoints.forEach { endpoint ->
    // Api A와 Api B를 모두 수동 등록하고 request마다 가용 endpoint를 선택한다.
    apiClient.connect(endpoint)
}
val node = options.addRouteMesh(SampleNames.SpotMesh)
val routeEndpoint = settings.routeEndpoint.ifBlank { settings.spotEndpoint }
val peerNodeId = if (settings.nodeId == "play-a") "play-b" else "play-a"

node
    .setRoutingId(RoutingId.from("tictactoe-play-${settings.nodeId}"))
    .listen(routeEndpoint)
node.channelName(SampleNames.PlayNode).server()
node
    .peerConnections()
    .connect(RoutingId.from("tictactoe-play-$peerNodeId"), settings.peerSpotEndpoint)
node
    .objects()
    .server()
    .addEntrySpot<PlayEntrySpot>()
    .addSpotFactory<TicTacToeGame>("tictactoe.game") { disableRelocation() }
    .addActorFactory<PlayActor, PlayActorFactory>(SampleNames.PlayActor) {
        preserveStateWith<PlayActor, PlayActorRelocationAdapter>()
    }
options
    .addStreamNode(SampleNames.PlayStream)
    .bind(settings.playEndpoint)
    .enableActorDispatch()
    .registerSession<PlaySession>()
    // request: STREAM AuthenticateReq를 처리하고 AuthenticateRes를 reply한다.
    .addSessionPacketHandler<AuthenticatePlaySessionHandler>()

The player Actor factory names an adapter that preserves state, and the room Spot factory disables relocation. The Api side connects to Play's mesh endpoint the same way.

Server/src/main/kotlin/systems/zlink/samples/kotlin/tictactoe/server/api/ApiServer.kt

settings.spotEndpoints.forEachIndexed { index, endpoint ->
    val playNodeId = if (index == 0) "play-a" else "play-b"
    mesh
        .peerConnections()
        .connect(RoutingId.from("tictactoe-play-$playNodeId"), endpoint)
}

A manual connection still names the routing id — the framework matches the endpoint against the Location Store descriptor to confirm the peer is the node it expects. The difference between connection and discovery is covered by How Channels Work.

Handlers are not left to scanning; they are registered directly, with their packet names, on the Spot and session handler registries — the host configuration above and each Spot's configure callback hold those registrations. TicTacToe is the only sample that registers this way in the managed languages. The registration variants are covered by Handlers and Message Processing.

4. Room Creation — Spot Create from an HTTP Request

The client creates a room over HTTP. Api's HTTP handler asks the Spot manager to create a room Spot and replies with the RoomId the framework issued and the list of Play endpoints.

↗ View larger

Server/src/main/kotlin/systems/zlink/samples/kotlin/tictactoe/server/api/handlers/CreateGameHttpHandler.kt

val created =
    kotlinSpots
        .create("tictactoe.game") // 이 stable type을 등록한 node가 후보가 된다.
        .inMesh(SampleNames.SpotMesh) // Spot을 만들 mesh를 고른다.
        .request(TicTacToeGameCreateReq(gameName, SampleNames.RequiredLevel))
        .timeout(SampleNames.RequestTimeout)
        .await() // Kotlin의 비동기 완료 terminal이다.

The Play endpoints in the reply are where the client connects, not the room's owner. Api does not decide which Play the room is created on; where it was created is recorded in the Location Store. Creating a User Spot is covered by Spot, and when it is created and which callbacks run by Activation and Lifetime.

5. Authentication and Binding — Play Owns the Session Too

The client connects over STREAM to the Play endpoint it chose from the reply and authenticates. The session handler has Api verify the token, then creates or finds the player Actor and binds it to the current session.

Server/src/main/kotlin/systems/zlink/samples/kotlin/tictactoe/server/play/infrastructure/zlink/sessions/handlers/AuthenticatePlaySessionHandler.kt

val playActor =
    actors
        .kotlin()
        .getOrCreate(authenticated.player.actorId, SampleNames.PlayActor)
        .request(PlayerActorCreateReq(authenticated.player))
        .await()
val resolvedActor = requireActor(playActor)
val boundActor = context.actors().bind(requireActor(playActor)).await()

Unlike Bingo, the server that accepts the connection and the server that owns the Actor are the same process. Even so, the call that obtains the Actor does not decide which node creates it. Binding is covered by Session and Actor, and the rule that a reconnected session binds to the same Actor again by How Session Binding Works.

6. Room Join — Reservation and the Move to Another Node

The join request arrives at the Entry Spot's actor send handler. The handler only reserves the room join and returns.

Server/src/main/kotlin/systems/zlink/samples/kotlin/tictactoe/server/play/infrastructure/zlink/spots/entryspot/handlers/PlayActorJoinGameHandler.kt

class PlayActorJoinGameHandler {
    @ZLinkSpotActorSend
    suspend fun joinGame(
        entrySpot: PlayEntrySpot,
        actor: PlayActor,
        context: ZLinkMessageContext,
        request: JoinGameMsg,
    ) {
        actor.trackDeferredJoin(request.roomId)
        actor
            .context()
            .joinSpot(request.roomId, TicTacToeGameJoinReq(request.roomId, actor.requirePlayer()))
            .timeout(SampleNames.RequestTimeout)
            .defer()
    }
}

When the reserved join runs and the node holding the Actor differs from the node that owns the room, the framework moves the Actor to the room's owner inside the join operation. During the move the application state is serialized by the adapter named on the player Actor factory.

Server/src/main/kotlin/systems/zlink/samples/kotlin/tictactoe/server/play/infrastructure/zlink/actors/PlayActorRelocationAdapter.kt

override fun capture(
    actor: PlayActor,
    cancellation: ZLinkRelocationCancellation,
): CompletionStage<ByteArray> =
    CompletableFuture.completedFuture(
        json.writeValueAsBytes(
            TransferState(
                actor.joinedRoomIdOrNull(),
                actor.playerOrNull(),
                actor.destroyAfterEntrySpotJoin,
                actor.disconnected,
            )
        )
    )

When both are on the same node, the adapter is not called. Once the join completes, the room Spot's joined callback registers the participant and notifies the other participants.

Server/src/main/kotlin/systems/zlink/samples/kotlin/tictactoe/server/play/infrastructure/zlink/spots/tictactoegamespot/TicTacToeGame.kt

suspend fun join(actor: PlayActor, roomId: String, player: PlayerInfo): TicTacToeGameJoinRes {
    validateJoin(roomId, player)
    actor.applyPlayer(player)
    val change = match.joinPlayer(actor.actorId, Instant.now())
    var slot = players.firstOrNull { it.actor.actorId == actor.actorId }
    if (slot == null) {
        slot = PlayerSlot(actor, change.mark)
        players += slot
    } else {
        slot.actor = actor
    }
    actor.joinGame(roomId)
    val state = change.state
    if (change.isNewPlayer) {
        notifyPlayerJoined(actor, slot, state)
    }
    broadcast(state, actor.actorId)
    return TicTacToeGameJoinRes(state)
}

The reservation rules are covered by Actor Membership, and what survives when an Actor is moved and the adapter's part by Relocation.

7. Placing Marks and Pushes

A place-mark request is relayed to the bound Actor and arrives at the room Spot's actor request handler. The handler applies the mark to the domain and replies with the updated state.

↗ View larger

Server/src/main/kotlin/systems/zlink/samples/kotlin/tictactoe/server/play/infrastructure/zlink/spots/tictactoegamespot/handlers/PlayActorPlaceMarkHandler.kt

class PlayActorPlaceMarkHandler {
    @ZLinkSpotActorRequest
    suspend fun placeMark(
        spot: TicTacToeGame,
        actor: PlayActor,
        context: ZLinkMessageContext,
        request: PlaceMarkReq,
    ): PlaceMarkRes {
        actor.requireJoinedGame()
        return spot.placeMark(actor, request.cell)
    }
}

The requester receives the state in the reply; the opponent receives the same state as a bound session push.

Server/src/main/kotlin/systems/zlink/samples/kotlin/tictactoe/server/play/infrastructure/zlink/spots/tictactoegamespot/TicTacToeGame.kt

private suspend fun broadcast(state: GameState, excludedActorId: String?) {
    players
        .asSequence()
        .map { it.actor }
        .filter { excludedActorId == null || it.actorId != excludedActorId }
        .forEach { actor ->
            try {
                actor.context().boundSession().kotlin().send(GameStateNotify(state)).await()
            } catch (_: RuntimeException) {
                // A stale player cannot prevent later best-effort notifications.
            }
        }
}

The turn time limit is checked by the room Spot's timer. The timer is registered in the initialize callback, and each tick runs inside the room's turn.

Server/src/main/kotlin/systems/zlink/samples/kotlin/tictactoe/server/play/infrastructure/zlink/spots/tictactoegamespot/TicTacToeGame.kt

override suspend fun onInitializeSuspending() {
    // timer: TicTacToeGameTimerHandler가 turn timeout을 주기적으로 확인한다.
    gameTick =
        context
            .addTimer<TicTacToeGameTimerHandler>(
                "game-tick",
                gameTickPeriod,
                ZLinkTimerOptions(ZLinkTimerOverrunPolicy.SKIP_LATE_TICKS, 1, false),
            )
            .await()
}

A wrong turn or an already-used cell is an application rejection and ends in a typed error reply. The handler kinds are covered by Handlers and Message Processing, and timers by Timers and Workers.

8. Milestones over Logical Multicast

When the host's accumulated wins reach 100, the room Spot publishes a milestone event. The observer is connected to a different Play than the host, so the room publishes with a channel and a topic and never needs to know where the observer is.

↗ View larger

Server/src/main/kotlin/systems/zlink/samples/kotlin/tictactoe/server/play/infrastructure/zlink/spots/tictactoegamespot/TicTacToeGame.kt

context
    .outbound()
    .kotlin()
    .publish(
        SampleNames.PlayNode,
        SampleNames.PlayerMilestoneTopic,
        PlayerWinMilestoneEvent(
            roomId = after.roomId,
            actorId = actor.actorId,
            displayName = player.displayName,
            wins = wins,
        ),
    )
    .await()

The Entry Spot on each Play subscribes to the same topic. The subscription handler is registered on the same registry as the packet handlers.

Server/src/main/kotlin/systems/zlink/samples/kotlin/tictactoe/server/play/infrastructure/zlink/spots/entryspot/handlers/PlayerWinMilestoneEventHandler.kt

@ZLinkSpotSubscription(topic = SampleNames.PlayerMilestoneTopic)
class PlayerWinMilestoneEventHandler :
    ZLinkSuspendingSpotSubscriptionHandler<PlayEntrySpot, PlayerWinMilestoneEvent> {
    override suspend fun handle(spot: PlayEntrySpot, event: PlayerWinMilestoneEvent) {
        spot.notifyMilestone(event)
    }
}

The Entry Spot that receives the event sends a notify to every Actor that asked to observe, and each Actor's bound session carries it to the client. The observer is not a member of the room, and there is no separate Spot type for it.

Server/src/main/kotlin/systems/zlink/samples/kotlin/tictactoe/server/play/infrastructure/zlink/spots/entryspot/PlayEntrySpot.kt

suspend fun notifyMilestone(event: PlayerWinMilestoneEvent) {
    val payload =
        WinMilestoneNotify(
            roomId = event.roomId,
            actorId = event.actorId,
            displayName = event.displayName,
            wins = event.wins,
        )
    milestoneObservers.toList().forEach { observer ->
        try {
            observer.context().boundSession().kotlin().send(payload).await()
        } catch (_: RuntimeException) {
            // A stale observer cannot prevent later best-effort notifications.
        }
    }
}

A publish that completes normally does not mean a subscriber has processed it. The client confirms delivery by the notify that reaches the observer. The forms of pub/sub are covered by How Channels Work.

9. Disconnect, Reconnect and Destroy

When the connection drops, the framework notifies the bound Actor. The Actor is not deleted and the room membership does not change. When the client reconnects and authenticates, a new session is bound to the Actor with the same id, and the client sends the join again with the same RoomId to receive the current state.

↗ View larger

After the game ends, each player sends a leave; the room Spot marks that Actor for destruction and removes it from the room.

Server/src/main/kotlin/systems/zlink/samples/kotlin/tictactoe/server/play/infrastructure/zlink/spots/tictactoegamespot/TicTacToeGame.kt

suspend fun leaveGame(actor: PlayActor, roomId: String) {
    check(this.roomId == roomId) { "leave request room id does not match game room" }
    if (!isTerminal(match.snapshot())) {
        return
    }
    actor.markForDestroyAfterRoomLeave()
    context.leaveActor(actor).await()
}

The Actor returns to the Entry Spot, and the Entry Spot's joined callback checks the mark and calls destroy.

Server/src/main/kotlin/systems/zlink/samples/kotlin/tictactoe/server/play/infrastructure/zlink/spots/entryspot/PlayEntrySpot.kt

override suspend fun onJoinedActorSuspending(actor: PlayActor) {
    if (actor.destroyAfterEntrySpotJoin) {
        context.destroyActor(actor).await()
        logger.info("tictactoe-lifecycle actor-destroy-complete actor={}", actor.actorId)
    }
}

The disconnect notification is covered by How Session Binding Works, and the return to the Entry Spot and destroy by Activation and Lifetime.

10. Running and Verifying

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

framework/languages/java/samples/kotlin/TicTacToe/run_sample.sh

The client asserts the room creation reply, authentication, the join notify, the place-mark results, the milestone notify and the state after reconnecting. The runner checks the server logs for each Actor's room leave and Entry Spot destroy. The checks and the exact log strings are set by the TicTacToe scenario.