kuilt Help

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