Consensus and Leader Election
Use kuilt-raft when every peer must agree on what happens next, in exactly the same order.
At a high level, this gives your session one current leader and a shared decision log that every node applies in lockstep.
Use it for turn order, locks, and durable workflow steps — situations where peers disagreeing would be a correctness bug, not just an inconvenience.
Under the hood this is the Raft consensus algorithm, a well-studied approach to leader election and log replication across a cluster of peers.
Transport independence
RaftTransport is a plain interface. Raft can run over your own messaging layer without any other kuilt module. The bundled SeamRaftTransport wraps a kuilt Seam for the common case:
val seam: Seam = loom.host(Pattern("raft-cluster"))
val transport = SeamRaftTransport(seam)
Implement RaftTransport directly to plug in WebRTC, gRPC, or another transport.
What's included
Feature | Notes |
|---|
Leader election + PreVote | PreVote prevents disruptive elections from partitioned nodes |
Log replication | Leader replicates entries; followers commit once quorum confirms |
Log compaction | Publish a snapshot into node.snapshots; Raft discards covered log entries and catches lagging peers up with chunked InstallSnapshot |
Dynamic membership | changeMembership() — add/remove voters via joint consensus (§6) or simple config for learner-set-only changes
|
Linearizable reads | readIndex() confirms a voter quorum at current term before returning a safe read index (§3.6/§3.7); no log write required
|
Graceful leadership transfer | transferLeadership(target) sends TimeoutNow to the target so it wins the next election without waiting for a timeout (§3.10)
|
Propose forwarding | Any peer can call propose() — non-leaders forward the proposal to the current leader and await commit, so callers need not track who the leader is |
Learner nodes | Non-voting replicas that receive all entries but never lead |
Quick start
val cluster = ClusterConfig.ofVoters(listOf(NodeId("a"), NodeId("b"), NodeId("c")))
val storage = InMemoryRaftStorage() // use persistent storage in production
val node: RaftNode = scope.raftNode(cluster, transport, storage)
// Apply committed entries on every node:
scope.launch {
node.committed.collect { committed ->
when (committed) {
is Committed.Entry -> applyToStateMachine(committed.entry.command)
is Committed.Install -> resetStateMachineTo(committed.snapshot.state)
}
}
}
// Propose from any peer — forwarded to the leader if needed:
val entry: LogEntry = node.propose("set x=1".encodeToByteArray())
raftNode is a CoroutineScope extension, so the node's lifetime is tied to the scope.
Turn-based game facade
kuilt-game provides two layers on top of RaftNode:
Bootstrap — gameHost/gameJoin/gameNode
These three functions are the recommended entry point. They wrap a plain Seam, set up internal multiplexing (Raft channel + app-envelope channel over one connection), and return a GameSession:
gameHost(seam, peerCount) — one peer per session; detects duplicate hosts, bootstraps a singleton-voter cluster, and admits each joiner until the roster reaches peerCount.
gameJoin(seam) — all other peers; announces itself, then waits to be promoted from learner to voter.
gameNode(seam, voterIds) — roster-given bootstrap: every peer passes the same voter set (known up-front, e.g. from matchmaking) and Raft elects the leader symmetrically, with no appoint-the-host step.
GameSession carries the RaftNode (for consensus) and appChannel(name) (for best-effort app traffic such as chat, cursors, or voice signalling, sharing the same fabric without a second connection):
/**
* [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()
}
TurnSequencer wraps a RaftNode and hides Raft mechanics behind a typed action/committed-stream API, so game code can focus on domain rules.
Storage
InMemoryRaftStorage is provided for tests. Production deployments need a durable implementation (SQLite, IndexedDB, or similar) to survive restarts.
Last modified: 22 June 2026