kuilt Help

Getting started

Connect peers, share data, then add strict ordering only where needed. This page walks through that in four steps, adding one kuilt module at a time without changing your app code.

Step 1: Two peers over WebSocket

Why this step: validate send/receive first, before layering shared state or ordering.

Start with the smallest useful milestone: two peers exchanging frames over one Seam.

// build.gradle.kts dependencies { implementation(platform("us.tractat.kuilt:kuilt-bom:VERSION")) implementation("us.tractat.kuilt:kuilt-websocket") }

Server (JVM/Android)

embeddedServer(Netty, port = 8080) { install(WebSockets) val server = KtorServerLoom(application, path = "/live", selfPeerId = PeerId("server")) scope.launch { while (isActive) { val seam: Seam = server.nextLink() // suspends until a client connects scope.launch { handle(seam) } } } }.start(wait = true)

Client (any platform)

val seam: Seam = KtorClientLoom(HttpClient { install(WebSockets) }).join( WebSocketAdvertisement("ws://localhost:8080/live", PeerId("server"), displayName = "alice") )

Both peers now use the same Seam API. This is your transport base; the rest of this page layers shared data and ordering on top.

// Same API on every peer: scope.launch { seam.incoming.collect { println(it.decodeToString()) } } seam.broadcast("hello".encodeToByteArray()) seam.close()

Step 2: Add a chat (replication)

Why this step: transport moves bytes; replication keeps shared state consistent across peers.

Now make shared state converge. For chat, that means everyone sees the same message list in the same order, even with concurrent sends. Add kuilt-crdt and use Rga — a replicated list where concurrent insertions are ordered deterministically:

// build.gradle.kts implementation("us.tractat.kuilt:kuilt-crdt")
/** * Rga-backed live chat over a [us.tractat.kuilt.core.Seam] via [Quilter]. * * [us.tractat.kuilt.crdt.Rga] gives a total order for concurrent inserts, so * every peer sees the same message list. Append a message by inserting after * the last element; collect `state` to render the live chat log. */ @Suppress("unused") internal fun sampleRgaChatReplicator() = runTest( StandardTestDispatcher(), timeout = 5.seconds, ) { val loom = InMemoryLoom() val seamAlice = loom.host(Pattern("chat")) val seamBob = loom.join(InMemoryTag("bob")) val cfg = QuilterConfig(expectVirtualTime = true) val msgSer = QuiltMessage.serializer(Rga.wireSerializer(serializer<String>())) val alice = ReplicaId(seamAlice.selfId.value) val bob = ReplicaId(seamBob.selfId.value) val aliceChat = Quilter( replica = alice, seam = seamAlice, initial = Rga.empty<String>(), messageSerializer = msgSer, scope = backgroundScope, config = cfg, ) val bobChat = Quilter( replica = bob, seam = seamBob, initial = Rga.empty<String>(), messageSerializer = msgSer, scope = backgroundScope, config = cfg, ) kotlinx.coroutines.delay(1) // Send a message — appended to the shared list, propagated to all peers. fun sendMessage(rep: Quilter<Rga<String>>, replicaId: ReplicaId, text: String) { val current = rep.state.value val (_, op) = current.insertAfter(replicaId, RgaId.HEAD, text) rep.apply(Patch(Rga.empty<String>().apply(op))) } sendMessage(aliceChat, alice, "hello from alice") sendMessage(bobChat, bob, "hello from bob") kotlinx.coroutines.delay(10) // Render the live chat log — both peers converge to the same sequence. assertEquals(aliceChat.state.value.toList(), bobChat.state.value.toList()) assertEquals(2, aliceChat.state.value.toList().size) }

Replication

Step 3: Add tic-tac-toe (consensus and leadership)

Why this step: some decisions can't be merged — they need one agreed order.

Tic-tac-toe moves are an ordered log: both players must agree on exactly who moved where, in order. That's not mergeable — it requires consensus. Add kuilt-raft and kuilt-game:

// build.gradle.kts implementation("us.tractat.kuilt:kuilt-raft") implementation("us.tractat.kuilt:kuilt-game")
/** * [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) }

Consensus and leader election

Step 4: Run on more platforms

Why this step: portability is a Loom swap — your app logic stays unchanged.

Your chat/game logic above only depends on seam.broadcast, seam.incoming, and seam.peers. Swap the Loom that produced the Seam and keep the rest of the code unchanged:

val loom: Loom = when { isApple -> MultipeerPeerLinkFactory(displayName = "alice", serviceType = "com.example.app") isAndroid -> NearbyLoom(api = GmsNearbyApi(context), serviceId = "com.example.app") isBrowser -> WebRTCPeerLinkFactory(signaling = WebSocketSignalingChannel(wsUrl), room = "myroom") else -> KtorClientLoom(httpClient) } val seam: Seam = loom.join(tag)

Connections

Testing

Replace any Loom with InMemoryLoom for fast, network-free tests:

@Test fun `chat messages converge`() = runTest { val loom = InMemoryLoom() val alice = loom.host(Pattern("alice")) val bob = loom.join(InMemoryTag("bob")) // wire up replicators, send a message, assert bob sees it... }

InMemoryLoom is a conforming fabric, so tests written against it can run unchanged with a real loom.

Last modified: 22 June 2026