gameHost

suspend fun CoroutineScope.gameHost(seam: Seam, peerCount: Int, returnAt: ReturnPolicy = ReturnPolicy.FullMembership, allowSpectators: Boolean = false, maxSpectators: Int = 0, storage: RaftStorage = InMemoryRaftStorage(), raftConfig: RaftConfig = RaftConfig(), livenessConfig: HeartbeatConfig? = null, clock: () -> Instant = { Clock.System.now() }, hostDeclarationTimeout: Duration = DEFAULT_HOST_DECLARATION_TIMEOUT, identity: ClientIdentity = ClientIdentity.Auto): GameSession

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

peerCount

Total number of voters (including the host) the cluster must reach.

returnAt

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.

allowSpectators

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.

maxSpectators

Maximum number of spectators to admit. Ignored when allowSpectators is false. Once this cap is reached, additional gameSpectate calls throw SpectatorsClosedException. Must be ≥ 0.

storage

Durable Raft state. Defaults to InMemoryRaftStorage.

raftConfig

Timing and behaviour parameters. Tests pass RaftConfig(expectVirtualTime = true) — this is the only supported path to virtual-time execution (D4).

livenessConfig

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

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.

hostDeclarationTimeout

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.

identity

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 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()
}