Skip to content

Reading Along: Bingo

Guide Home | Previous: 14. Picking a Sample — Start with the Example Closest to Your Problem | Next: Reading Along: TicTacToe

What you get from this chapter

You can open the Bingo sample in an editor and follow a message from the client's authentication to the end of a game, through the code of each server it passes. The code in this chapter comes from the Bingo 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 Bingo sample's roles and code locations, its main message flows, and its run verification in source order. See the Bingo scenario for requirements, message contracts, and verification criteria.

1. What This Sample Demonstrates

The client keeps a single STREAM connection to a Session server. API handles authentication, Matchmaking reserves the waiting room, the Play server that owns the room runs the game, and the Location Store resolves both the connections between servers and the location of every object. A game proceeds as authentication → matching → room join → card submission → timer draws and pushes → winner and reward publish → cleanup, and the sections of this chapter follow the same order.

↗ View larger

This layout uses more framework features than any other sample because each flow needs a different one. Binding a player to a single connection uses session binding; reserving a waiting room uses an Instance Spot that is created when the first request arrives; the room is a User Spot that processes members, timers and pushes in one line; and telling observers on other Play servers uses Logical Multicast.

2. Roles and Where the Code Lives

Each role runs as its own process, and its code sits under Server/<Role> with the same name. The package path differs per language, but the directory names are the same.

Role Processes Owns Code
Session 2 STREAM connections, packets before authentication, Actor binding and relay Server/Session
API 2 Authentication, player records, matching coordination Server/Api
Matchmaking 1 Waiting-room reservation per level bucket — Redis holds the decision Server/Matchmaking
Play 2 Player Actors, room Spots, timers, pushes, reward publish Server/Play/Infrastructure
Client 1 The scenario from authentication to the end of observation, with self-checks Client

The Bingo rules — card validation, number draws, marks and the winner decision — live in Server/Play/Domain and reference no framework type. This chapter does not cover them. Message names and fields are set by the Protobuf schema in Shared; this is the only sample whose payload is Protobuf.

3. Server Configuration

Each role's host configuration shows what that role receives and what it calls. Session receives STREAM connections, calls objects in the Play mesh, and calls the API channel.

Server/Session/SessionServerHostFactory.cs

options
    .AddRouteMesh(SampleNames.PlayMeshName)
    .SetRoutingIdPrefix("session")
    .Listen(session.MeshEndpoint)
    .Objects()
    .Client();
options.AddClientServerChannel(SampleNames.ApiChannel).Client();
options
    .AddStreamNode(SampleNames.StreamNode)
    .Bind(session.StreamEndpoint)
    .EnableActorDispatch()
    .AddSession<BingoSession>();

Registering the session type on the stream node makes packets flow to the Actor bound to an authenticated session — the managed languages also enable actor dispatch there. The mesh and channel registrations carry no remote endpoint — which node serves which name is resolved by the Location Store (Location).

Play registers the Entry Spot, the player Actor factory and the room Spot factory on the same mesh.

Server/Play/PlayServerHostFactory.cs

.AddSpotFactory<BingoRoom>(
    SampleNames.RoomSpotType,
    factory =>
        factory
            .ExecutionMode(ZLinkUserSpotExecutionMode.SpotWide)
            .RelocationCoordinationMode(
                ZLinkSpotRelocationCoordinationMode.ApplicationSignaled
            )
            .PreserveStateWith<BingoRoomRelocationAdapter>()
);

The Entry Spot exists once per node and is where an Actor is first created. A room is a User Spot, one per id, executed as SpotWide. Handlers are registered by scanning in the managed languages and through the builder in C++ (Handlers and Message Processing). The differences between the kinds are covered by Activation and Lifetime.

4. Authentication and the Session–Actor Binding

The first packet the client sends is an authentication request. Session has API verify the token, creates or finds the player Actor by its global id, and binds that Actor to the current session.

↗ View larger

The session handler sends a request to the API channel and, if the result is valid, obtains the Actor from the Play mesh. The Actor's creation payload travels with that call.

Server/Session/Sessions/Handlers/AuthenticateSessionHandler.cs

var authenticated = await channels
    .RequestToChannel(
        SampleNames.ApiChannel,
        new AuthenticatePlayerReq { AccessToken = request.AccessToken }
    )
    .Async<AuthenticatePlayerRes>(cancellationToken);

if (
    !authenticated.Accepted
    || string.IsNullOrWhiteSpace(authenticated.ActorId)
    || string.IsNullOrWhiteSpace(authenticated.DisplayName)
)
    throw new InvalidOperationException(
        authenticated.Reason ?? "Player authentication failed."
    );

var actor = (
    await actors
        .GetOrCreate(authenticated.ActorId, SampleNames.PlayerActorType)
        .InMesh(SampleNames.PlayMeshName)
        .Request(
            new PlayerActorCreateReq
            {
                ActorId = authenticated.ActorId,
                DisplayName = authenticated.DisplayName,
            }
        )
        .Async(cancellationToken)
) switch
{
    ZLinkActorCreateResult.Existing value => value.Actor,
    ZLinkActorCreateResult.Created value => value.Actor,
    _ => throw new InvalidOperationException("Player Actor creation was rejected."),
};

The call that obtains the Actor does not decide which Play node creates it. It returns the existing Actor if there is one and a newly created Actor otherwise, and either way Session binds the returned reference as it is.

Server/Session/Sessions/Handlers/AuthenticateSessionHandler.cs

var boundActor = await context.Actors.BindOrGetAsync(actor, cancellationToken);
logger.LogInformation(
    "bingo session: bound player={ActorId} session={SessionId}",
    boundActor.ActorId,
    context.SessionId
);

await context
    .Client.Reply(
        new AuthenticateRes
        {
            ActorId = authenticated.ActorId,
            DisplayName = authenticated.DisplayName,
        }
    )
    .Async();

Packets that arrive after the binding are received by the Actor's handlers, not by the session handlers. The session's dispatch callback makes that split — a packet that no registered session handler handles is relayed to the bound Actor.

Server/Session/Sessions/BingoSession.cs

public async ValueTask OnDispatchAsync(
    ZLinkSessionDispatchContext dispatch,
    ZLinkMessage payload,
    CancellationToken cancellationToken
)
{
    if (await Context.Handlers.TryHandleAsync(dispatch, payload, cancellationToken))
        return;

    var actor = RequireSingleBoundActor($"relaying packet '{dispatch.PacketName}'");
    await actor.RelayAsync(payload);
}

Session stores no Play endpoint. When the Actor moves through relocation, the framework updates the relay route. Binding itself is covered by Session and Actor; how many may be bound and how the route is refreshed by How Session Binding Works.

5. Matching and Preparing the Room

A matching request is relayed to the bound Actor and arrives at the Entry Spot's actor request handler. The handler asks API for a match, reserves a room join with the RoomId it receives, and replies.

↗ View larger

Server/Play/Infrastructure/ZLink/Spots/EntrySpot/Handlers/MatchBingoActorHandler.cs

var apiRequest = new MatchBingoApiReq
{
    ActorId = actor.ActorId,
    DisplayName = actor.DisplayName,
    Mode = message.Mode,
};
var matched = await entrySpot
    .Context.Outbound.RequestToChannel(SampleNames.ApiChannel, apiRequest)
    .Timeout(TimeSpan.FromSeconds(5))
    .Async<MatchBingoApiRes>(cancellationToken);
logger.LogInformation(
    "match: room allocated. actor={ActorId}, room={RoomId}",
    actor.ActorId,
    matched.RoomId
);

actor.TrackDeferredJoin(matched.RoomId, observeOnly: false);
actor
    .Context.JoinSpot(
        matched.RoomId,
        new BingoRoomJoinReq
        {
            RoomId = matched.RoomId,
            ActorId = actor.ActorId,
            DisplayName = actor.DisplayName,
            ObserveOnly = false,
        }
    )
    .Defer();

The join does not run inside the handler; it runs after the handler returns. That is why the state in the matching reply is the pre-join WaitingForPlayers, and the game start is confirmed by the push that follows. The reservation rules are covered by Actor Membership.

API asks the Matchmaker Instance Spot, whose id is the level bucket, for a reservation, then creates or finds the room Spot with the RoomId it gets back.

Server/Api/Handlers/MatchBingoHandler.cs

const string levelBucket = "1-10";
var allocated = await spotClient
    .RequestToSpot(
        $"match:{levelBucket}",
        new ReserveBingoRoomReq
        {
            Mode = request.Mode,
            ActorId = request.ActorId,
            LevelBucket = levelBucket,
        }
    )
    .InstanceSpot(SampleNames.MatchmakerSpotType)
    .InMesh(SampleNames.MatchmakingMeshName)
    .Async<ReserveBingoRoomRes>(cancellationToken);
logger.LogInformation(
    "api match: allocated. actor={ActorId}, room={RoomId}",
    request.ActorId,
    allocated.RoomId
);
var created = await spots
    .GetOrCreate(allocated.RoomId, SampleNames.RoomSpotType)
    .InMesh(SampleNames.PlayMeshName)
    .Request(new BingoRoomCreateReq { Settings = allocated.Settings })

An Instance Spot is created when its first request arrives. Concurrent requests for the same id wait for the single creation, and the room's GetOrCreate follows the same rule — API does not decide which Play node the room is created on. The Matchmaker decides reservations atomically in Redis, so it keeps no recovery ground in process memory, and it closes itself with a timer when idle.

Server/Matchmaking/Infrastructure/ZLink/BingoMatchmakerHandlers.cs

internal sealed class BingoMatchmakerIdleTimer : IZLinkSpotTimerHandler<BingoMatchmaker>
{
    public ValueTask HandleAsync(
        BingoMatchmaker spot,
        ZLinkTimerTick tick,
        CancellationToken cancellationToken
    )
    {
        _ = tick;
        if (DateTimeOffset.UtcNow - spot.LastActivity >= TimeSpan.FromSeconds(30))
            _ = spot.Context.CloseAsync(cancellationToken);
        return ValueTask.CompletedTask;
    }
}

The next request after a close creates an Instance Spot of a new generation. When each kind is created is covered by Activation and Lifetime, and calling an Instance Spot by Spot.

6. Room Join and Yield

When the reserved join runs, the room Spot's joined callback is invoked. The player branch has to read the player record from API, and if it held the room's execution turn for that round trip, the other player's join and the timer would wait. So the request is sent with Yield.

Server/Play/Infrastructure/ZLink/Spots/BingoRoomSpot/BingoRoom.cs

// Yield releases the Spot execution turn while the API owns the player record lookup.
var record = await Context
    .Outbound.RequestToChannel(
        SampleNames.ApiChannel,
        new GetPlayerRecordReq { ActorId = actor.ActorId }
    )
    .Yield<GetPlayerRecordRes>(cancellationToken);

Why this request gives its turn back with Yield and checks the pending join again after resuming is covered by The Execution Model §5.

7. Cards, the Draw Timer and Pushes

Once both players have submitted cards, the room's timer starts drawing. Each tick draws one number, marks both cards, and pushes the updated state to both players.

↗ View larger

The timer handler runs inside the room Spot's turn, so it never overlaps the card submission handler.

Server/Play/Infrastructure/ZLink/Spots/BingoRoomSpot/Handlers/BingoRoomDrawTimerHandler.cs

[ZLinkSpotTimerHandler("bingo-draw", 200)]
internal sealed class BingoRoomDrawTimerHandler : IZLinkSpotTimerHandler<BingoRoom>
{
    public async ValueTask HandleAsync(
        BingoRoom spot,
        ZLinkTimerTick tick,
        CancellationToken cancellationToken
    )
    {
        _ = tick;
        if (!spot.IsReadyToDraw)
            return;

        var change = spot.DrawNextNumber();
        await spot.PublishAsync(change, cancellationToken);
        if (change.ShouldStopDrawTimer)
        {
            await spot.LeaveFinishedActorsAsync(cancellationToken);
            // This is the final Framework operation in the completed round turn.
            spot.DeferRelocationAtRoundBoundary();
        }
    }
}

Pushes go to the session bound to each player Actor. Whichever Play node the Actor is on and whichever Session node the session is on, the sender addresses only the Actor's bound session.

Server/Play/Infrastructure/ZLink/Spots/Notifications/BingoNotificationPublisher.cs

case BingoRoomEventKind.NumberDrawn:
    await roomEvent
        .Recipient.Context.BoundSession.Send(
            new BingoNumberDrawnNotify
            {
                RoomId = roomEvent.State.RoomId,
                DrawSeq = roomEvent.State.DrawSeq,
                Number = roomEvent.DrawnNumber,
                State = roomEvent.State,
            }
        )
        .Async();
    break;

Timer registration and where a tick executes are covered by Timers and Workers, and the path to a bound session by How Session Binding Works.

8. Rewards over Logical Multicast

Once the winner is decided, the room sends the end-of-game pushes and then publishes the reward event. An observer may be on a different Play node, so the room does not address a particular Spot; it publishes with a topic that sets the scope — the managed languages name the channel as well.

↗ View larger

Server/Play/Infrastructure/ZLink/Spots/BingoRoomSpot/BingoRoom.cs

await Context
    .Outbound.Publish(
        SampleNames.RoomChannel,
        SampleNames.RewardTopic,
        new BingoRewardAcquiredEvent
        {
            RoomId = change.State.RoomId,
            ActorId = change.State.Winners[0],
            DrawSeq = change.State.DrawSeq,
            ItemId = BingoRewardItems.GoldenDauberId,
            ItemName = BingoRewardItems.GoldenDauberName,
            Rarity = BingoRewardItems.LegendaryRarity,
        }
    )
    .Async(cancellationToken);

The observer room on each Play node subscribes to the same topic. The subscription handler sends a notify to the observer Actor's bound session only when the event's RoomId matches the room it observes.

Server/Play/Infrastructure/ZLink/Spots/BingoRoomSpot/Handlers/BingoRewardAcquiredEventHandler.cs

[ZLinkSpotSubscriptionHandler(SampleNames.RoomChannel, SampleNames.RewardTopic)]
internal sealed class BingoRewardAcquiredEventHandler
    : IZLinkSpotSubscriptionHandler<BingoRoom, BingoRewardAcquiredEvent>
{
    public ValueTask HandleAsync(
        BingoRoom spot,
        BingoRewardAcquiredEvent message,
        ZLinkPublishMessageContext context,
        CancellationToken cancellationToken
    )
    {
        return spot.AnnounceRewardAsync(message, cancellationToken);
    }
}

A publish that completes normally means the publish was started, not that a subscriber processed it. The client confirms delivery by the notify that reaches the observer, not by the publish result. The forms of pub/sub and target selection are covered by How Channels Work.

9. End-of-Game Cleanup and Disconnect

When the game ends, the room removes the player Actors. Each Actor returns to the Entry Spot, and the Entry Spot checks the destroy mark and destroys the Actor. The room does not destroy the Actor itself because its leave callback gives the turn back with Yield while it reports the result to API.

↗ View larger

Server/Play/Infrastructure/ZLink/Spots/BingoRoomSpot/BingoRoom.cs

internal async ValueTask LeaveFinishedActorsAsync(CancellationToken cancellationToken)
{
    if (_cleanupStarted || _game?.Status != BingoRoomStatus.Finished)
        return;

    _cleanupStarted = true;
    var actors = _actors.Values.ToArray();
    foreach (var actor in actors)
    {
        actor.MarkForDestroyAfterRoomLeave();
        logger.LogInformation(
            "bingo room: actor marked for destroy. room={RoomId}, actor={ActorId}",
            Context.SpotId,
            actor.ActorId
        );
    }
    foreach (var actor in actors)
        await Context.LeaveActorAsync(actor, cancellationToken);
}

The Entry Spot's joined callback checks the mark and calls destroy. Destroy cleans up the Actor object, the registry and the session binding, and does not invoke the leave callback again.

Server/Play/Infrastructure/ZLink/Spots/EntrySpot/BingoEntrySpot.cs

public async ValueTask OnJoinedActorAsync(
    PlayerActor actor,
    CancellationToken cancellationToken
)
{
    logger.LogInformation(
        "entry spot: actor joined. actor={ActorId}, destroyAfterJoin={DestroyAfterJoin}",
        actor.ActorId,
        actor.DestroyAfterEntrySpotJoin
    );
    if (actor.DestroyAfterEntrySpotJoin)
    {
        logger.LogInformation(
            "entry spot: actor destroy requested. actor={ActorId}",
            actor.ActorId
        );
        // Finished-room cleanup is server lifecycle work and must complete even
        // when the client closes immediately after receiving the final result.
        await Context.DestroyActorAsync(actor, CancellationToken.None);
        logger.LogInformation(
            "bingo-lifecycle entry-destroy-complete actor={ActorId}",
            actor.ActorId
        );
        return;
    }
}

A dropped client connection is a separate path. When the connection drops, the framework notifies every Actor bound at that moment. The session's disconnect callback only logs that fact; it neither destroys the Actor nor removes it from the room — some languages also call the notification directly, but even when it overlaps the automatic one the callback runs once.

Server/Session/Sessions/BingoSession.cs

public ValueTask OnDisconnectedAsync(CancellationToken cancellationToken)
{
    // Framework cleanup owns the disconnect notification (spec 7.5): this callback
    // only records the sample lifecycle evidence, one line per bound Actor, and
    // submits nothing.
    foreach (var actor in Context.Actors.Bound)
    {
        logger.LogInformation(
            "bingo-lifecycle session-disconnect actor={ActorId} destroy=false",
            actor.ActorId
        );
    }
    return ValueTask.CompletedTask;
}

The order in which an Actor returns to the Entry Spot is covered by Activation and Lifetime, and where the disconnect notification lands by How Session Binding Works.

10. Preparing for Relocation

The room Spot factory chooses SpotWide execution and application-signaled relocation timing, and names the adapter that preserves state. The Node implementation disables relocation for the room Spot and keeps an adapter only for the player Actor.

Server/Play/PlayServerHostFactory.cs

// SpotWide is the default. Naming it here keeps the choice visible:
// every callback of this room runs through one gate.
.AddSpotFactory<BingoRoom>(
    SampleNames.RoomSpotType,
    factory =>
        factory
            .ExecutionMode(ZLinkUserSpotExecutionMode.SpotWide)
            .RelocationCoordinationMode(
                ZLinkSpotRelocationCoordinationMode.ApplicationSignaled
            )
            .PreserveStateWith<BingoRoomRelocationAdapter>()
);

The adapter serializes only domain state. Queues, timers, the accepted journal and the owner fence are moved by the framework and are not put in the adapter payload.

Server/Play/Infrastructure/ZLink/Spots/BingoRoomSpot/BingoRoomRelocationAdapter.cs

public ValueTask<byte[]> CaptureAsync(BingoRoom spot, CancellationToken cancellationToken)
{
    cancellationToken.ThrowIfCancellationRequested();
    var state = spot.CaptureRelocationState();
    return ValueTask.FromResult(
        JsonSerializer.SerializeToUtf8Bytes(
            new Payload(
                state.Settings.RoomName,
                state.Settings.Mode,
                state.Settings.RequiredPlayers,
                state.Settings.MaxDrawNumber,
                state.Settings.Purpose,
                state.Settings.ObservedRoomId,
                Convert.ToBase64String(state.State.ToByteArray())
            )
        )
    );
}

In the implementations with relocation enabled, the room reserves relocation-ready in the turn where a round's pushes and publish have finished — that call sits at the end of the timer handler shown above. The unit of a move and the point of no return are covered by Relocation.

11. Running and Verifying

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

framework/languages/dotnet/samples/Bingo/run_sample.sh

The client asserts the authentication results, the matching state, the push order and the reward notify, and prints bingo=completed at the end. The runner counts the lifecycle lines in the server logs and prints bingo-placement=completed. The checks and the exact log strings are set by the Bingo scenario.