kuilt Help

Consensus

Two players, one next turn. The game needs everyone to agree on who goes first. Consensus gives the group one shared order of moves.

The group chooses a leader to order decisions; each device keeps a copy. If the leader goes away, the group can choose another.

When to use it

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. Wait until you have applied through it — awaitRead(applied) does both

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: var applied = 0L scope.launch { node.committed.collect { committed -> when (committed) { is Committed.Entry -> { applyToStateMachine(committed.entry.command); applied = committed.entry.index } is Committed.Internal -> applied = committed.index // raft's own bookkeeping: nothing to apply is Committed.Install -> { resetStateMachineTo(committed.snapshot.state); applied = committed.snapshot.throughIndex } } } } // 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 = TEST_WEDGE_BACKSTOP) { 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), // clock is required (no wall-clock default); production callers pass the system clock. clock = { Clock.System.now() }, ) } 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. // Create the receiving channel before broadcasting: an existing view buffers frames, // but frames for an unopened name are not replayed. runCurrent() lets async create it. val incoming = async { joiner.appChannel("chat").incoming.first() } runCurrent() 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

A node remembers a few things between runs: which round of voting it is on, who it voted for, and the list of agreed decisions. Lose those and it can accidentally vote twice, which is the one thing the whole algorithm is built to prevent. So in production that memory has to survive a restart.

DurableStoreRaftStorage does that, on every platform, in one line — DurableStoreRaftStorage.open(FileChannelDurableStore(nodeDirectory)).

Give it whichever durable store the platform provides — a directory on a phone or a server, a database in a browser — and hand the result to raftNode(...). One store per node: two nodes sharing a directory overwrite each other's memory.

InMemoryRaftStorage is the alternative, for tests and for peers that are happy to rejoin from scratch — it keeps nothing across a restart.

Under the hood the adapter keeps three records (raft/meta, raft/log, raft/snapshot), commits each one before it believes it, and rewrites the whole log on every append. That last part is why it suits a bounded log — a game, a room, a small cluster that takes snapshots — rather than a high-throughput one. A consumer with a different medium, or a log too big to rewrite, implements RaftStorage itself and checks it against RaftStorageConformanceSuite.

02 October 2026