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/Play/tictactoe-play-module.ts
builder
.addStreamNode(SampleNames.playStream)
.enableActorDispatch()
.bind(config.playStreamEndpoint)
.registerSession(PlaySessionFactory);
const apiChannel = builder.addClientServerChannel(SampleNames.apiChannel).client();
for (const endpoint of config.apiEndpoints) {
// request: AuthenticatePlayerReq is sent to one explicitly connected Api server.
apiChannel.connect(endpoint);
}
const mesh = builder
.addRouteMesh(SampleNames.playSpotNode)
.listen(config.playSpotEndpoint)
.setRoutingIdPrefix(`tictactoe-play-${config.instanceName}`);
const objectServer = mesh.objects().server();
objectServer.addEntrySpot(PlayEntrySpot);
objectServer.addSpotFactory(SampleNames.gameSpotType, TicTacToeGameSpot, (factory) =>
factory.disableRelocation()
);
objectServer.addActorFactory(SampleNames.playerActorType, PlayActorFactory, (factory) =>
factory.preserveStateWith(PlayActorRelocationAdapter)
);
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/Api/tictactoe-api-module.ts
// RouteMesh: each Api explicitly connects to both Play object servers.
for (const endpoint of config.playSpotEndpoints) {
mesh.peerConnections().connect(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.
Server/Api/Handlers/create-game-http-handler.ts
const created = await this.spots
.create(SampleNames.gameSpotType) // 이 stable type을 등록한 node가 후보가 된다.
.inMesh(SampleNames.playSpotNode) // Spot을 만들 mesh를 고른다.
.request(new TicTacToeGameCreateReq(gameName, 3)) // 새 Spot의 생성 callback에 전달할 최초 설정이다.
.submit(); // Node의 비동기 완료 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/Play/Infrastructure/ZLink/Sessions/Handlers/authenticate-play-session-handler.ts
const created = await this.actors
.getOrCreate(authenticated.player.actorId, SampleNames.playerActorType)
.inMesh(SampleNames.playSpotNode)
.request(new PlayerActorCreateReq(authenticated.player))
.submit();
if (created.status === 'rejected') {
throw new Error(`Player actor '${authenticated.player.actorId}' creation was rejected.`);
}
const actorRef = created.actor;
await context.actors.bindOrGet(actorRef);
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/Play/Infrastructure/ZLink/Spots/EntrySpot/Handlers/play-actor-join-game-handler.ts
class PlayActorJoinGameHandler implements ZLinkEntrySpotActorSendHandler<
PlayEntrySpot,
PlayActor,
JoinGameMsg
> {
@ZLinkSpotActorSend(PacketNames.joinGameMsg)
async handle(
_spot: PlayEntrySpot,
actor: PlayActor,
context: ZLinkMessageContext,
message: JoinGameMsg
): Promise<void> {
void context;
const joinRequest: TicTacToeGameJoinReq = {
roomId: message.roomId,
player: {
actorId: actor.actorId,
displayName: actor.displayName,
level: actor.level,
wins: actor.wins
}
};
actor.pendingJoinRoomId = message.roomId;
try {
actor.context.joinSpot(message.roomId, joinRequest).defer();
} catch (error) {
actor.pendingJoinRoomId = undefined;
await actor.push(
new JoinGameFailedNotify(
message.roomId,
error instanceof Error ? error.message : String(error)
)
);
}
}
}
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/Play/Infrastructure/ZLink/Actors/play-actor-relocation-adapter.ts
async capture(actor: PlayActor): Promise<Uint8Array> {
return new TextEncoder().encode(
JSON.stringify({
displayName: actor.displayName,
level: actor.level,
wins: actor.wins,
roomId: actor.roomId,
pendingJoinRoomId: actor.pendingJoinRoomId,
destroyAfterEntrySpotJoin: actor.destroyAfterEntrySpotJoin,
disconnected: actor.disconnected
} satisfies PlayActorTransferState)
);
}
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/Play/Infrastructure/ZLink/Spots/TicTacToeGameSpot/tictactoe-game-spot.ts
async onJoinedActor(actor: PlayActor): Promise<void> {
const actorId = actor.actorId;
this.actors.set(actorId, actor);
const request = this.pendingJoins.get(actorId);
if (request !== undefined) {
this.pendingJoins.delete(actorId);
const joined = this.requireMatch().players.get(actorId);
if (joined === undefined) {
throw new Error(`Accepted TicTacToe actor '${actorId}' has no room membership.`);
}
const state = this.requireMatch().snapshot();
for (const existing of this.actors.values()) {
if (existing.actorId === actorId) {
continue;
}
await existing.push(
playerJoinedNotify(
this.roomId,
actorId,
request.player.displayName,
request.player.level,
joined.mark,
state
)
);
}
}
console.log(`game spot: actor joined. actor=${actorId} roomId=${this.roomId}`);
}
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/Play/Infrastructure/ZLink/Spots/TicTacToeGameSpot/Handlers/play-actor-place-mark-handler.ts
class PlayActorPlaceMarkHandler implements ZLinkSpotActorRequestHandler<
TicTacToeGameSpot,
PlayActor,
PlaceMarkReq,
PlaceMarkRes
> {
@ZLinkSpotActorRequest(PacketNames.placeMarkReq)
async handle(
spot: TicTacToeGameSpot,
actor: PlayActor,
_context: ZLinkMessageContext,
request: PlaceMarkReq
): Promise<PlaceMarkRes> {
if (actor.context.spotId === undefined) {
throw new Error(`Actor '${actor.actorId}' is not joined to a game.`);
}
return spot.placeMark(actor.actorId, request.cell);
}
}
The requester receives the state in the reply; the opponent receives the same state as a bound session push.
Server/Play/Infrastructure/ZLink/Spots/TicTacToeGameSpot/tictactoe-game-spot.ts
for (const joined of match.players.values()) {
if (joined.actorId !== actorId) {
await this.notifyActor(this.requireActorId(joined.actorId), gameStateNotify(state));
}
}
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/Play/Infrastructure/ZLink/Spots/TicTacToeGameSpot/tictactoe-game-spot.ts
// timer: the Room Spot owns the public timer registration and its policy.
this.gameTick = await this.context.addTimer(
'game-tick',
GameTickPeriodMs,
TicTacToeGameTimerHandler,
{
overrunPolicy: ZLinkTimerOverrunPolicy.DelayNextTick,
stopOnUnhandledException: true
}
);
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/Play/Infrastructure/ZLink/Spots/TicTacToeGameSpot/tictactoe-game-spot.ts
await this.context.outbound
.publish(
SampleNames.playerMilestoneChannel,
SampleNames.playerMilestoneTopic,
playerWinMilestoneEvent(after.roomId, actorId, participant.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/Play/Infrastructure/ZLink/Spots/EntrySpot/play-entry-spot.ts
// subscribe: the published milestone event is delivered to this Entry Spot.
this.context.handlers.addSubscribe(
PlayerWinMilestoneEventHandler,
SampleNames.playerMilestoneChannel,
SampleNames.playerMilestoneTopic
);
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/Play/Infrastructure/ZLink/Spots/EntrySpot/entry-spot-registries.ts
async notify(event: PlayerWinMilestoneEvent): Promise<void> {
const payload = winMilestoneNotify(event);
for (const actorId of this.subscriptions) {
if (this.actors.has(actorId)) {
await this.actorClient
.sendToActor(actorId, new DeliverPlayNotificationMsg(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/Play/Infrastructure/ZLink/Spots/TicTacToeGameSpot/Handlers/play-actor-leave-game-handler.ts
@ZLinkSpotActorSend(PacketNames.leaveGameMsg)
async handle(
spot: TicTacToeGameSpot,
actor: PlayActor,
_context: ZLinkMessageContext,
message: LeaveGameMsg
): Promise<void> {
if (actor.context.spotId !== message.roomId) {
throw new Error(`Actor requested leave for a different room. roomId=${message.roomId}`);
}
spot.verifyLeave(actor.actorId, message.roomId);
actor.markForDestroyAfterRoomLeave();
await spot.context.leaveActor(actor);
actor.roomId = undefined;
console.log(`tictactoe-lifecycle leave-completed actor=${actor.actorId}`);
}
The Actor returns to the Entry Spot, and the Entry Spot's joined callback checks the mark and calls destroy.
Server/Play/Infrastructure/ZLink/Spots/EntrySpot/play-entry-spot.ts
async onJoinedActor(actor: PlayActor): Promise<void> {
this.milestoneObservers.track(actor);
if (actor.destroyAfterEntrySpotJoin) {
this.scheduleDestroy(actor);
}
}
async onLeaveActor(actor: PlayActor): Promise<void> {
console.log(`entry spot: actor left. actor=${actor.actorId}`);
this.milestoneObservers.remove(actor.actorId);
}
scheduleDestroy(actor: PlayActor): void {
void this.context
.runIoWorker(async () => true)
.submit()
.then(async () => {
console.log(`entry spot: actor destroy started. actor=${actor.actorId}`);
await this.context.destroyActor(actor);
console.log(`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.
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