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.
How this session obtains its consensus node. The default ConsensusPlacement.SessionOwned constructs the learner-awaiting-promotion node — today's behaviour. ConsensusPlacement.preBuilt hands the bootstrap a caller-constructed node (e.g. a test double) that the admission wait then observes unchanged. Must seat AuthoritySeating.SessionPeers — see gameHost.
Optional gossip-flood wrapper for the broadcast plane (presence + app); null (default) gives a flat mux. When non-null it must match the host's overlay so both sides speak the same broadcast envelope; Raft/heartbeat stay below the flood (#1370). See gameNode.
Throws
if placement does not seat AuthoritySeating.SessionPeers.
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 = TEST_WEDGE_BACKSTOP) {
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),
// clock is required (no wall-clock default); production callers pass the system clock.
clock = { Clock.System.now() },
)
}
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.
// Subscribe before the sender broadcasts: delivery is best-effort (`replay = 0`), so a
// frame sent while nobody is collecting is dropped and this receiver waits forever (#2289).
val incoming = async { joiner.appChannel("chat").incoming.first() }
runCurrent()
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()
}