gameNode
Constructs a RaftNode over seam for a session whose full voter roster is already known to every peer (e.g. from matchmaking). Every peer builds the identical ClusterConfig.ofVoters and Raft's own election picks the leader — symmetric, no pre-Raft coordination step required.
This is the roster-given bootstrap path: call it when every participating peer's identity is known before the session starts. For the appoint-the-host path (dynamic join without a fixed roster), see gameHost/gameJoin.
Internal multiplexing. gameNode wraps seam in a MuxSeam so Raft traffic (channel tag 1) and the application-envelope NamedMux (channel tag 3) share the one underlying Seam. This costs Raft one extra tag byte per frame versus an unmuxed seam — the accepted price of a uniform GameSession return type and ride-along app channels across all three bootstrap paths. Drive consensus through GameSession.node; ride extra traffic over GameSession.appChannel.
Do not collect seam.incoming after calling this. Once the returned GameSession is running, the internal MuxSeam is the sole consumer of seam.incoming (ADR-034 single-collection). A second collector races the Raft engine and drops messages, causing silent liveness failures.
Parameters
The Seam connecting this peer to the rest of the cluster. This peer's identity (Seam.selfId) must appear in voterIds.
The full set of voter NodeIds for the cluster. Every peer must pass the same set; Raft's election then picks one leader symmetrically.
Durable Raft state (term, vote, log). Defaults to InMemoryRaftStorage (non-durable, suitable for short-lived sessions or tests). Inject a persistent implementation for crash-recovery.
Timing and behaviour parameters. Production callers use the default RaftConfig (real-clock, expectVirtualTime = false). Tests pass RaftConfig(expectVirtualTime = true) — this is the only supported path to virtual-time execution; gameNode deliberately does not expose expectVirtualTime as its own parameter (D4).
How this peer 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 peer 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.
Where this session's consensus authority lives. The default ConsensusPlacement.SessionOwned seats voterIds directly — today's roster-given behaviour. ConsensusPlacement.serverCore seats a fixed voter core instead (all core nodes vote; a non-core caller rides as a learner, admitted by the core leader — the voterIds roster is superseded by the core). ConsensusPlacement.preBuilt hands the session a caller-constructed node (e.g. a test double) — storage/raftConfig are then unused for node construction.
Optional gossip-flood wrapper for the broadcast plane only. When null (the default — LAN, roster-given, in-memory), the session is flat: Raft, presence, and the app NamedMux all ride one mux directly over seam, byte-identical to the pre-overlay layout. When non-null (e.g. { starOverlay(it, random, clock) } for a room star, { policyOverlay(it, …) } for a federated core), the flood is confined to the BROADCAST_CHANNEL sub-mux and Raft stays below it — the commit-safety layering of #1370, which keeps a crafted GossipFrame from laundering a forged consensus from. The lambda runs on the bootstrap CoroutineScope so a started overlay (starOverlay/policyOverlay call .start(this)) is tied to the session's lifetime.
Throws
if this peer's NodeId is not in voterIds (under AuthoritySeating.SessionPeers placements only — a AuthoritySeating.CoreVoters caller may legitimately be a non-voting player).
Samples
runTest(StandardTestDispatcher(), timeout = TEST_WEDGE_BACKSTOP) {
val loom = InMemoryLoom()
val seam1 = loom.host(Pattern("my-game"))
val seam2 = loom.join(InMemoryTag("player-2"))
val id1 = NodeId(seam1.selfId.value)
val id2 = NodeId(seam2.selfId.value)
val voterIds = setOf(id1, id2)
// Both peers call gameNode with the same voter set. Raft elects one leader.
val session1 = backgroundScope.gameNode(seam1, voterIds, raftConfig = RaftConfig(expectVirtualTime = true))
val session2 = backgroundScope.gameNode(seam2, voterIds, raftConfig = RaftConfig(expectVirtualTime = true))
// Both sessions are live. Drive the game through TurnSequencer over either node:
// propose() is forwarded to the leader transparently by Raft.
// For a concrete propose + committed example see sampleGameHostJoin.
TurnSequencer(session1.node, Int.serializer())
TurnSequencer(session2.node, Int.serializer())
// Ride named application channels (chat, cursors, …) over the same fabric.
//
// Subscribe BEFORE the sender broadcasts. [GameSession.appChannel] delivery is
// best-effort (`replay = 0`), so a frame sent while nobody is collecting is dropped,
// not queued — and this receiver would then wait forever. A real app subscribes once
// at startup, long before any peer sends; under `runTest`'s `StandardTestDispatcher`
// the `async` below is merely *queued*, so `runCurrent()` is what lets it reach
// `incoming` and subscribe first (#2289).
val chatIncoming = async { session2.appChannel("chat").incoming.first() }
runCurrent()
session1.appChannel("chat").broadcast(byteArrayOf(0x68, 0x69)) // "hi"
assertEquals(2, chatIncoming.await().payloadSize)
session1.close()
session2.close()
}