Reading Along: TicTacToe¶
Guide Home | Previous: Reading Along: Bingo | Next: Reading Along: SupportChat
View in another language — C++ · C#/.NET · Java · Kotlin · Node/TypeScript
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.
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/java/systems/zlink/samples/tictactoe/server/play/PlayServer.java
var apiClient = options.addClientServerChannel(SampleNames.ApiChannel).client();
for (String endpoint : settings.apiChannelEndpoints()) {
// Api A와 Api B를 모두 수동 등록하고 request마다 가용 endpoint를 선택한다.
apiClient.connect(endpoint);
}
ZLinkMeshNodeBuilder node = options.addRouteMesh(SampleNames.SpotMesh);
String routeEndpoint =
settings.routeEndpoint().isBlank()
? settings.spotEndpoint()
: settings.routeEndpoint();
node.listen(routeEndpoint)
.setRoutingId(RoutingId.from("tictactoe-" + settings.nodeId()));
node.channelName(SampleNames.PlayNode).server();
node.peerConnections().connect(settings.peerSpotEndpoint());
node.objects()
.server()
.addEntrySpot(PlayEntrySpot.class)
.addSpotFactory(
"tictactoe.game",
TicTacToeGame.class,
factory -> factory.disableRelocation())
.addActorFactory(
SampleNames.PlayActor,
PlayActor.class,
PlayActorFactory.class,
factory -> factory.preserveStateWith(PlayActorRelocationAdapter.class));
options.addStreamNode(SampleNames.PlayStream)
.bind(settings.playEndpoint())
.enableActorDispatch()
.registerSession(PlaySession.class)
// request: STREAM AuthenticateReq를 처리하고 AuthenticateRes를 reply한다.
.addSessionPacketHandler(AuthenticatePlaySessionHandler.class);
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/java/systems/zlink/samples/tictactoe/server/api/ApiServer.java
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.
Server/src/main/java/systems/zlink/samples/tictactoe/server/api/handlers/CreateGameHttpHandler.java
return spots.create("tictactoe.game") // 이 stable type을 등록한 node가 후보가 된다.
.inMesh(SampleNames.SpotMesh) // Spot을 만들 mesh를 고른다.
.request(new TicTacToeGameCreateReq(gameName, SampleNames.RequiredLevel))
.timeout(SampleNames.RequestTimeout)
.submit() // Java의 비동기 완료 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/java/systems/zlink/samples/tictactoe/server/play/infrastructure/zlink/sessions/handlers/AuthenticatePlaySessionHandler.java
.thenCompose(
authenticated ->
actors.getOrCreate(
authenticated.player().actorId(),
SampleNames.PlayActor)
.request(new PlayerActorCreateReq(authenticated.player()))
.submit()
.thenCompose(
result -> {
ActorRef resolvedActor = requireActor(result);
return context.actors()
.bind(requireActor(result))
.thenCompose(
boundActor -> {
if (!boundActor
.ref()
.equals(
resolvedActor)) {
throw new IllegalStateException(
"Bound ActorRef"
+ " does"
+ " not match"
+ " the resolved"
+ " ActorRef"
+ " for '"
+ authenticated
.player()
.actorId()
+ "'.");
}
if (result
instanceof
ZLinkActorCreateResult
.Existing) {
LOGGER.info(
"tictactoe-lifecycle"
+ " actor-bound"
+ " actor={}",
boundActor
.actorId());
}
return context.client()
.reply(
new AuthenticateRes(
authenticated
.player()))
.submit();
});
}));
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/java/systems/zlink/samples/tictactoe/server/play/infrastructure/zlink/spots/entryspot/handlers/PlayActorJoinGameHandler.java
public final class PlayActorJoinGameHandler {
@ZLinkSpotActorSend
public CompletionStage<Void> joinGame(
PlayEntrySpot entrySpot,
PlayActor actor,
ZLinkMessageContext context,
JoinGameMsg request) {
actor.trackDeferredJoin(request.roomId());
actor.context()
.joinSpot(
request.roomId(),
new TicTacToeGameJoinReq(request.roomId(), actor.requirePlayer()))
.timeout(SampleNames.RequestTimeout)
.defer();
return CompletableFuture.completedFuture(null);
}
}
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/java/systems/zlink/samples/tictactoe/server/play/infrastructure/zlink/actors/PlayActorRelocationAdapter.java
@Override
public CompletionStage<byte[]> capture(
PlayActor actor, ZLinkRelocationCancellation cancellation) {
try {
return CompletableFuture.completedFuture(
JSON.writeValueAsBytes(
new TransferState(
actor.joinedRoomId(),
actor.playerOrNull(),
actor.destroyAfterEntrySpotJoin(),
actor.disconnected())));
} catch (IOException error) {
return CompletableFuture.failedFuture(error);
}
}
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/java/systems/zlink/samples/tictactoe/server/play/infrastructure/zlink/spots/tictactoegamespot/TicTacToeGame.java
@Override
public CompletionStage<Void> onJoinedActor(PlayActor actor) {
TicTacToeGameJoinReq joinRequest = pendingJoins.remove(actor.actorId());
if (joinRequest == null) {
throw new IllegalStateException("joined actor does not have a pending admission");
}
join(actor, joinRequest.roomId(), joinRequest.player());
return CompletableFuture.completedFuture(null);
}
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.
Server/src/main/java/systems/zlink/samples/tictactoe/server/play/infrastructure/zlink/spots/tictactoegamespot/handlers/PlayActorPlaceMarkHandler.java
public final class PlayActorPlaceMarkHandler {
@ZLinkSpotActorRequest
public CompletionStage<PlaceMarkRes> placeMark(
TicTacToeGame spot,
PlayActor actor,
ZLinkMessageContext context,
PlaceMarkReq request) {
actor.requireJoinedGame();
PlaceMarkRes response = spot.placeMark(actor, request.cell());
return CompletableFuture.completedFuture(response);
}
}
The requester receives the state in the reply; the opponent receives the same state as a bound session push.
Server/src/main/java/systems/zlink/samples/tictactoe/server/play/infrastructure/zlink/spots/tictactoegamespot/TicTacToeGame.java
private void broadcast(GameState state, String excludedActorId) {
actors.stream()
.filter(
actor ->
excludedActorId == null || !actor.actorId().equals(excludedActorId))
.forEach(
actor ->
actor.context()
.boundSession()
.send(new GameStateNotify(state))
.submit());
}
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/java/systems/zlink/samples/tictactoe/server/play/infrastructure/zlink/spots/tictactoegamespot/TicTacToeGame.java
@Override
public CompletionStage<Void> onInitialize() {
// timer: TicTacToeGameTimerHandler가 turn timeout을 주기적으로 확인한다.
return context.addTimer(
"game-tick", GAME_TICK_PERIOD, TicTacToeGameTimerHandler.class, null)
.thenAccept(timer -> gameTick = timer);
}
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.
Server/src/main/java/systems/zlink/samples/tictactoe/server/play/infrastructure/zlink/spots/tictactoegamespot/TicTacToeGame.java
context.outbound()
.publish(
SampleNames.PlayNode,
SampleNames.PlayerMilestoneTopic,
new PlayerWinMilestoneEvent(
after.roomId(), actor.actorId(), player.displayName(), wins))
.submit();
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/java/systems/zlink/samples/tictactoe/server/play/infrastructure/zlink/spots/entryspot/handlers/PlayerWinMilestoneEventHandler.java
@ZLinkSpotSubscription(topic = SampleNames.PlayerMilestoneTopic)
public final class PlayerWinMilestoneEventHandler
implements ZLinkSpotSubscriptionHandler<PlayEntrySpot, PlayerWinMilestoneEvent> {
@Override
public CompletionStage<Void> handle(PlayEntrySpot spot, PlayerWinMilestoneEvent event) {
spot.notifyMilestone(event);
return CompletableFuture.completedFuture(null);
}
}
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/java/systems/zlink/samples/tictactoe/server/play/infrastructure/zlink/spots/entryspot/PlayEntrySpot.java
public void notifyMilestone(PlayerWinMilestoneEvent event) {
WinMilestoneNotify payload =
new WinMilestoneNotify(
event.roomId(), event.actorId(), event.displayName(), event.wins());
for (PlayActor observer : List.copyOf(milestoneObservers)) {
observer.context().boundSession().send(payload).submit();
}
}
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.
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/java/systems/zlink/samples/tictactoe/server/play/infrastructure/zlink/spots/tictactoegamespot/TicTacToeGame.java
public CompletionStage<Void> leaveGame(PlayActor actor, String roomId) {
if (!this.roomId.equals(roomId)) {
throw new IllegalStateException("leave request room id does not match game room");
}
if (!isTerminal(snapshot())) {
return CompletableFuture.completedFuture(null);
}
actor.markForDestroyAfterRoomLeave();
return context.leaveActor(actor);
}
The Actor returns to the Entry Spot, and the Entry Spot's joined callback checks the mark and calls destroy.
Server/src/main/java/systems/zlink/samples/tictactoe/server/play/infrastructure/zlink/spots/entryspot/PlayEntrySpot.java
@Override
public CompletionStage<Void> onJoinedActor(PlayActor actor) {
if (actor.destroyAfterEntrySpotJoin()) {
return context.destroyActor(actor)
.thenRun(
() ->
LOGGER.info(
"tictactoe-lifecycle actor-destroy-complete actor={}",
actor.actorId()));
}
return CompletableFuture.completedFuture(null);
}
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.
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.
11. Related Documents¶
- Comparison with the other samples and how to choose: Picking a Sample
- Requirements, message contract and completion criteria: TicTacToe scenario
- The same game built with automatic connection, automatic registration and a separate Session server: Reading Along: Bingo