gameJoin

suspend fun CoroutineScope.gameJoin(seam: Seam, storage: RaftStorage = InMemoryRaftStorage(), raftConfig: RaftConfig = RaftConfig(), joinAdmissionTimeout: Duration = DEFAULT_JOIN_ADMISSION_TIMEOUT, identity: ClientIdentity = ClientIdentity.Auto): GameSession

Join a game session over seam hosted by exactly one gameHost. Starts as a non-voting learner and waits until the host admits this peer as a voter. Returns the local GameSession (its GameSession.node is the admitted follower) once admitted.

Roster-full detection. If the host has already filled all peerCount seats and published an admission-closed signal, gameJoin throws RosterFullException instead of suspending indefinitely. This is the host-authoritative path: the signal travels over the same presence channel as the duplicate-host check, so it converges deterministically under virtual time.

Backstop timeout. If neither admission nor an admission-closed signal arrives within joinAdmissionTimeout, gameJoin throws JoinTimeoutException — distinct from RosterFullException so callers can tell "host gone / crashed" from "roster was full". The backstop is a fallback for real-fabric scenarios (host crashed mid-handshake, network partition); the structural signal is the primary path. Under virtual time the backstop fires promptly.

Internal multiplexing. gameJoin wraps seam in a MuxSeam and routes Raft traffic on channel tag 1, lobby presence on channel tag 2, and the application-envelope NamedMux on channel tag 3 — matching gameHost's channels — so both sides communicate on compatible frames. The caller passes a plain Seam; muxing is internal. Ride extra application traffic over GameSession.appChannel.

Presence announcement. Before awaiting admission, this peer announces itself as a non-host participant on the presence channel. This is what lets gameHost's duplicate-host convergence wait observe contact with each connected joiner instead of blocking on it until the timeout elapses.

Do not collect seam.incoming after calling this (ADR-034 single-collection).

Parameters

storage

Durable Raft state. Defaults to InMemoryRaftStorage.

raftConfig

Timing and behaviour parameters. Tests pass RaftConfig(expectVirtualTime = true) (D4).

joinAdmissionTimeout

Upper bound on waiting for the host to either admit this peer or signal admission-closed. If this bound expires before either signal arrives, gameJoin throws JoinTimeoutException. The default is sized to clear a typical WAN round-trip and a full Raft election cycle; lower it in test scenarios where you want the backstop to fire quickly under virtual time.

identity

How this joiner obtains its Raft §8 dedup id. ClientIdentity.Auto (default) mints a per-incarnation auto id (at-least-once forwarding, no cross-crash dedup). A durable joiner passes ClientIdentity.Durable with a stable id it persists itself and replays the same requestId on TurnSequencer.propose after a restart. See us.tractat.kuilt.raft.ClientSessionTable.

Throws

if the host has already filled all seats and this peer is not in the final voter set.

if neither admission nor a roster-full signal arrives within joinAdmissionTimeout.

Samples

runTest(StandardTestDispatcher(), timeout = 5.seconds) {
    val loom = InMemoryLoom()
    val hostSeam = loom.host(Pattern("tic-tac-toe"))
    val joinSeam = loom.join(InMemoryTag("player-2"))

    // Launch concurrently: gameHost suspends while admitting joiners;
    // gameJoin suspends until the host promotes it to voter.
    val hostDeferred = async {
        backgroundScope.gameHost(
            hostSeam,
            peerCount = 2,
            raftConfig = RaftConfig(expectVirtualTime = true),
        )
    }
    val joinDeferred = async {
        backgroundScope.gameJoin(
            joinSeam,
            raftConfig = RaftConfig(expectVirtualTime = true),
        )
    }

    val host = hostDeferred.await()
    val joiner = joinDeferred.await()

    // Both nodes are voters. propose() may be called on any node —
    // followers forward to the leader transparently.
    val hostGame = TurnSequencer(host.node, Int.serializer())
    val joinerGame = TurnSequencer(joiner.node, Int.serializer())

    val move = hostGame.propose(1)
    assertEquals(1, move.action)

    // Any node may propose; the joiner's call is forwarded to the host (leader).
    val joinerMove = joinerGame.propose(2)
    assertEquals(2, joinerMove.action)

    // Ride an application channel (chat, cursors, …) over the same fabric as consensus.
    val incoming = async { joiner.appChannel("chat").incoming.first() }
    host.appChannel("chat").broadcast(byteArrayOf(0x68, 0x69)) // "hi"
    assertEquals(2, incoming.await().payloadSize)

    // Collect committed turns on any node in the game loop:
    // scope.launch {
    //     joinerGame.events.collect { event ->
    //         when (event) {
    //             is TurnEvent.Committed -> applyMove(event.indexed.index, event.indexed.action)
    //             is TurnEvent.Reset -> resetStateMachine(event.snapshot)
    //         }
    //     }
    // }

    // Tear the session down when done (stops the node, then closes the fabric).
    host.close()
    joiner.close()
}