gameHost
Host a game session over seam: check for duplicate hosts, bootstrap a singleton-voter cluster, then admit each connecting peer as learner→voter until the cluster reaches the returnAt watermark, then returns a GameSession whose GameSession.node is the leader.
Return policy. With the default ReturnPolicy.FullMembership, gameHost suspends until all peerCount voters have joined before returning. With ReturnPolicy.Quorum it returns as soon as a majority (peerCount / 2 + 1) are present and continues admitting the remaining voters in the background — see ReturnPolicy.
Precondition: exactly one gameHost per session. This function detects a duplicate host via lobby presence before bootstrapping Raft and throws DuplicateHostException if another peer already declared itself host.
Deterministic duplicate-host arbitration. The check declares this peer as host on the lobby presence channel, then waits — bounded by hostDeclarationTimeout — until presence has converged with every peer connected at that moment (each peer, host or joiner, announces itself), and only then inspects the declared-host set. This replaces an earlier fixed 1 ms window that on a real fabric elapsed before any network round-trip, silently passing two concurrent hosts. When the converged set holds more than one declared host, the lowest-NodeId declarant is elected the canonical host and proceeds; every other declared host throws DuplicateHostException. Because every peer has converged on the same set, they independently agree on the winner with no extra round-trip — a genuinely simultaneous race therefore resolves to exactly one host instead of collapsing the session (the earlier behaviour, where every declarant threw). Exactly-one-gameHost remains the caller's precondition: a losing host still fails loud, and if it was needed to reach peerCount the winner blocks on it like any missing peer.
Internal multiplexing. gameHost wraps seam in a MuxSeam so that Raft traffic (channel tag 1), lobby presence traffic (channel tag 2), and the application-envelope NamedMux (channel tag 3) share the single underlying Seam without violating the ADR-034 single-collection contract. gameJoin applies the same tags so both sides communicate on matching frames. The caller passes a plain Seam in both cases — muxing is an internal implementation detail; ride extra application traffic over GameSession.appChannel.
Parameters
Total number of voters (including the host) the cluster must reach.
When to return the leader RaftNode — at ReturnPolicy.FullMembership (default) or at ReturnPolicy.Quorum. See ReturnPolicy. In ReturnPolicy.Quorum mode the remaining voters are admitted in the background, on this scope, for the life of the session — the admission door stays open until the roster reaches peerCount, so a latecomer joins whenever it connects, however late. A peer arriving once the roster is already full is the separate, deferred concern in #587.
Whether to admit peers that call gameSpectate. Disabled by default — a gameSpectate call when spectators are off is rejected immediately with SpectatorsClosedException rather than hanging. Spectators are permanent non-voting learners; see gameSpectate.
Maximum number of spectators to admit. Ignored when allowSpectators is false. Once this cap is reached, additional gameSpectate calls throw SpectatorsClosedException. Must be ≥ 0.
Durable Raft state. Defaults to InMemoryRaftStorage.
Timing and behaviour parameters. Tests pass RaftConfig(expectVirtualTime = true) — this is the only supported path to virtual-time execution (D4).
Optional configuration for per-voter HeartbeatPartitionDetectors. When non-null, gameHost launches one detector per admitted voter and — on the leader — automatically evicts a voter whose HeartbeatConfig.reconnectWindow expires without recovery (PartitionEvent.PeerLost), then re-opens the admission loop so a replacement can join. When null (the default) no liveness monitoring is performed; callers that need automatic seat reclamation must pass a HeartbeatConfig. This is an explicit opt-in because the feature carries observable timing state; omitting it for sessions that have their own membership management (e.g. gameNode) is a supported use case.
Clock for heartbeat liveness measurements. Production callers use the default (kotlin.time.Clock.System.now); tests inject a controllable clock so virtual time drives all timing without wall-clock dependency. Ignored when livenessConfig is null.
Upper bound on the presence-convergence wait before the duplicate-host check proceeds regardless. This is genuine tuning, not a functional switch: a connected-but-silent peer (or a real fabric whose round-trip exceeds the bound) only weakens detection, never disables the host. The default is sized to clear a typical WAN round-trip; raise it on high-latency fabrics, lower it where joiners are known-local.
How this host 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 host 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 peerCount< 1 or maxSpectators< 0.
if another peer on the same session already declared host.
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()
}