Turn-based game bootstrap (kuilt-game)
kuilt-game provides a thin facade over kuilt-raft for turn-based games. It handles consensus setup, internal multiplexing, and the game-loop abstraction so you interact with typed actions rather than raw Raft machinery.
There are two bootstrap paths. Pick the one that fits how your session is established.
Roster-given — gameNode
Use this path when every participating peer's identity is known before the session starts — for example, after a matchmaking round that produces a fixed player list.
Every peer builds the identical voter set from NodeId(seam.selfId.value) values and calls gameNode. Raft's own election picks the leader symmetrically — no pre-Raft coordination step is required.
/**
* [gameNode] over an [InMemoryLoom]: roster-given bootstrap path.
*
* Every peer builds the same [NodeId] set and calls [gameNode]; Raft's own election
* picks the leader symmetrically — no pre-Raft coordination step required.
* Returns a [GameSession] immediately (no waiting for other peers).
*
* Use this path when all participating peers' identities are known before the
* session starts (e.g. from matchmaking). For the appoint-the-host path (dynamic
* join without a fixed roster) see [sampleGameHostJoin].
*/
@Suppress("unused")
internal fun sampleGameNode() = runTest(StandardTestDispatcher(), timeout = 5.seconds) {
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.
val chatIncoming = async { session2.appChannel("chat").incoming.first() }
session1.appChannel("chat").broadcast(byteArrayOf(0x68, 0x69)) // "hi"
assertEquals(2, chatIncoming.await().payloadSize)
session1.close()
session2.close()
}
gameNode returns immediately once the node is running; it does not wait for leadership or for other peers to connect. The GameSession.node is a RaftNode — Raft's election will pick one leader from the voter set, and propose() forwards transparently to whoever wins.
Appoint-the-host — gameHost/gameJoin
Use this path when the roster is not fixed at session start — for example, a lobby where peers join one by one. Exactly one peer calls gameHost; every other peer calls gameJoin.
/**
* [gameHost] and [gameJoin] over an [InMemoryLoom]: appoint-the-host bootstrap path.
*
* Exactly one peer calls [gameHost]; the rest call [gameJoin]. Both suspend until the
* cluster reaches [gameHost]'s requested [peerCount] voters, then return a [GameSession].
* Pass a plain [us.tractat.kuilt.core.Seam] in both cases — muxing (Raft channel tag 1,
* presence channel tag 2, app-envelope channel tag 3) is internal.
*
* After the calls return, drive the game through [TurnSequencer] over [GameSession.node]: any
* node may [TurnSequencer.propose]; commits are delivered in order on every node via
* [TurnSequencer.events]. Ride extra application traffic (chat, cursors, …) over
* [GameSession.appChannel]; tear the session down with [GameSession.close].
*
* **Do not collect `seam.incoming` after calling [gameHost] or [gameJoin].** Each wraps
* the seam in a [us.tractat.kuilt.core.MuxSeam] that becomes the sole consumer of
* `seam.incoming` (ADR-034 single-collection). A second collector races the Raft engine.
*/
@Suppress("unused")
internal fun sampleGameHostJoin() = 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()
}
How it works
gameHost bootstraps a singleton-voter Raft cluster, becomes its leader, then admits each connecting peer as learner → voter until the cluster reaches peerCount voters.
gameJoin announces itself on the presence channel, starts as a non-voting learner, and suspends until the host promotes it to voter. When gameJoin returns, the GameSession.node is an admitted follower.
Duplicate host detection
Exactly one peer per session must call gameHost. If two peers both call it concurrently, gameHost uses lobby presence to detect the conflict. The lowest NodeId declarant wins and proceeds as the canonical host; every other caller throws DuplicateHostException. Because every peer has converged on the same declared-host set before the arbitration runs, they agree on the winner independently — a genuinely simultaneous race resolves to exactly one host, not zero.
Return policy
By default (ReturnPolicy.FullMembership), gameHost suspends until all peerCount voters are present. Pass ReturnPolicy.Quorum to return as soon as a majority (peerCount / 2 + 1) are present and start the game without the slowest peer:
val host = backgroundScope.gameHost(
seam,
peerCount = 4,
returnAt = ReturnPolicy.Quorum,
raftConfig = RaftConfig(expectVirtualTime = true),
)
In Quorum mode, gameHost continues admitting the remaining voters in the background on the caller's scope for the life of the session — a latecomer is promoted whenever it connects, however late.
Using the GameSession
All three bootstrap paths return a GameSession carrying:
node — the local RaftNode (leader on the host, admitted follower on joiners).
appChannel(name) — named application channels sharing the same fabric as consensus (see below).
Pass a plain Seam in every case. Internal multiplexing (Raft traffic on channel tag 1, lobby presence on channel tag 2, app-envelope NamedMux on channel tag 3) is handled inside the bootstrap functions — callers must not pre-mux the seam.
Driving the game — TurnSequencer
Wrap session.node in a TurnSequencer<A> to interact with typed actions:
/**
* [TurnSequencer] for a tic-tac-toe game.
*
* Hides all Raft machinery — [propose] submits a typed action and suspends
* until a quorum commits it; [TurnSequencer.events] delivers every committed
* action ([TurnEvent.Committed]) in order on all nodes (leader and followers alike).
*
* In production, replace [FakeRaftNode] with a real `raftNode(...)` connected
* to peers over a [us.tractat.kuilt.core.Seam].
*/
@Suppress("unused")
internal fun sampleTurnSequencer() = runTest(timeout = 5.seconds) {
@Serializable data class Move(val row: Int, val col: Int)
val node = FakeRaftNode()
node.setRole(RaftRole.Leader)
val game = TurnSequencer(node, serializer<Move>())
// On every node — collect the committed turn stream.
// scope.launch { game.committed.collect { (index, move) -> applyMove(index, move) } }
// On any node — propose the local player's move (forwarded to the leader if needed).
game.propose(Move(row = 0, col = 0)) // X top-left
game.propose(Move(row = 1, col = 1)) // O centre
game.propose(Move(row = 0, col = 1)) // X top-centre
val committed = game.events
.filterIsInstance<TurnEvent.Committed<Move>>()
.map { it.indexed.action }
.take(3)
.toList()
assertEquals(listOf(Move(0, 0), Move(1, 1), Move(0, 1)), committed)
}
propose(action) submits an action from any peer — non-leaders forward the proposal to the current leader transparently. It suspends until a quorum commits the entry and returns an IndexedAction<A> carrying the action and its log index.
committed is a Flow<IndexedAction<A>> that emits every committed action in order on every node (leader and followers alike). Collect it in your game loop to drive authoritative state.
Application channels — appChannel
session.appChannel(name) returns a Seam for that name, carried over the same fabric as consensus without a second connection. Use it for best-effort traffic that lives alongside the game — chat, cursor positions, voice signalling:
val chat = session.appChannel("chat")
// On the receiver:
scope.launch { chat.incoming.collect { frame -> renderChat(frame.decodeToString()) } }
// On the sender:
chat.broadcast(message.encodeToByteArray())
Delivery is best-effort (replay = 0): a frame sent before the peer subscribes is not replayed. The application owns the entire name namespace — there are no reserved names.
Optimistic UI — SpeculativeSequencer
For responsive UIs, wrap TurnSequencer in SpeculativeSequencer to apply local actions optimistically before a quorum commits them, then roll back and replay if the committed order differs:
/**
* [SpeculativeSequencer] for a simple counter game.
*
* Optimistically applies local moves before the Raft quorum commits them, then
* rolls back and replays if the committed order differs from what was predicted.
* The [SpeculativeGame] implementation must be **pure and deterministic** — replay
* correctness depends on it.
*
* [speculativeState][SpeculativeSequencer.speculativeState] is always current (with
* optimistically applied local moves on top); the UI can observe it directly.
*/
@Suppress("unused")
internal fun sampleSpeculativeSequencer() = runTest(timeout = 5.seconds) {
// A trivially pure game: state is a list of committed integers.
val counterGame = object : SpeculativeGame<List<Int>, Int> {
override fun apply(state: List<Int>, action: Int): List<Int> = state + action
override fun snapshot(state: List<Int>): List<Int> = state.toList()
override fun restore(snapshot: List<Int>): List<Int> = snapshot.toList()
}
val node = FakeRaftNode()
node.setRole(RaftRole.Leader)
val sequencer = TurnSequencer(node, Int.serializer())
val speculative = SpeculativeSequencer(
sequencer = sequencer,
game = counterGame,
initialState = emptyList(),
scope = backgroundScope,
)
// Optimistic apply: speculativeState reflects 42 immediately, before quorum.
val proposed = async { speculative.propose(42) }
assertEquals(listOf(42), speculative.speculativeState.value)
// Once quorum confirms, pending count drops to 0.
val indexed = proposed.await()
assertEquals(42, indexed.action)
speculative.awaitConfirmedCount(1)
assertEquals(0, speculative.pendingCount)
assertEquals(listOf(42), speculative.speculativeState.value)
}
speculativeState is a StateFlow<S> that is always current — the UI can observe it directly. SpeculativeGame<S, A> is your state machine: implement apply, snapshot, and restore. apply must be pure and deterministic — replay correctness depends on it.
Teardown
GameSession.close() stops the node's loops, then closes the fabric (idempotent). This is a hard local teardown, not a graceful cluster departure. To leave cleanly, hand off leadership with RaftNode.transferLeadership() and/or remove yourself from membership with RaftNode.changeMembership() before closing.
Single-collection constraint
After any bootstrap call wraps the seam, do not collect seam.incoming directly. The internal MuxSeam is the sole consumer of the seam's incoming flow (single-collection contract). A second collector races the Raft engine and drops messages silently.
Last modified: 22 June 2026