gameJoin
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
Durable Raft state. Defaults to InMemoryRaftStorage.
Timing and behaviour parameters. Tests pass RaftConfig(expectVirtualTime = true) (D4).
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.
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()
}