A Pen by Motionharvest on CodePen.
Created
February 7, 2026 04:09
-
-
Save motionharvest/e0aa93b8a347fd438c43de1fd9eb308d to your computer and use it in GitHub Desktop.
Turn based Conversation Simulation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width,initial-scale=1" /> | |
| <title>Turn Queue Prototype</title> | |
| <style> | |
| button.raised { | |
| outline: 2px solid #ff3b3b; | |
| outline-offset: 2px; | |
| } | |
| :root { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; } | |
| body { margin: 0; padding: 16px; background: #0b0c10; color: #e9eef5; } | |
| .wrap { max-width: 980px; margin: 0 auto; display: grid; gap: 12px; } | |
| .card { | |
| background: rgba(255,255,255,0.06); | |
| border: 1px solid rgba(255,255,255,0.10); | |
| border-radius: 14px; | |
| padding: 14px; | |
| } | |
| .buttons { display: flex; gap: 10px; flex-wrap: wrap; } | |
| button { | |
| position: relative; | |
| appearance: none; | |
| border: 0; | |
| cursor: pointer; | |
| padding: 12px 16px; | |
| border-radius: 12px; | |
| background: rgba(255,255,255,0.12); | |
| color: #e9eef5; | |
| font-weight: 600; | |
| } | |
| button.speaking { | |
| background: #1f8f4a; | |
| box-shadow: 0 0 0 2px rgba(40,200,120,0.35); | |
| } | |
| button.hand::after { | |
| content: "✋"; | |
| position: absolute; | |
| top: -6px; | |
| right: -6px; | |
| font-size: 16px; | |
| } | |
| button:disabled { opacity: 0.45; cursor: not-allowed; } | |
| .mono { font-family: ui-monospace, Menlo, Consolas, monospace; } | |
| .phase { | |
| display: inline-flex; | |
| padding: 6px 10px; | |
| border-radius: 999px; | |
| background: rgba(255,255,255,0.10); | |
| font-weight: 600; | |
| } | |
| .log { | |
| max-height: 300px; | |
| overflow: auto; | |
| background: rgba(0,0,0,0.25); | |
| border-radius: 12px; | |
| padding: 10px; | |
| } | |
| .logline { | |
| margin: 0; | |
| padding: 4px 0; | |
| border-bottom: 1px dashed rgba(255,255,255,0.08); | |
| } | |
| /* Put id="outer" on your main wrapper. If you don't, it will apply to body instead. */ | |
| .reply-window { | |
| outline: 2px solid #2b7cff; | |
| outline-offset: 6px; | |
| border-radius: 14px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="wrap"> | |
| <div class="card"> | |
| <div class="buttons"> | |
| <button id="btnEcho">Echo</button> | |
| <button id="btnChronos">Chronos</button> | |
| <button id="btnWren">Wren</button> | |
| <button id="btnReset" style="margin-left:auto;">Reset</button> | |
| </div> | |
| </div> | |
| <div class="card mono"> | |
| Phase: <span id="phase"></span> | | |
| Speaker: <span id="speaker"></span> | | |
| Branch: <span id="branchNo"></span> | | |
| Reply: <span id="replyNo"></span> | |
| </div> | |
| <div class="card"> | |
| <div class="log" id="log"></div> | |
| </div> | |
| </div> | |
| <script> | |
| (() => { | |
| const USERS = ["Echo", "Chronos", "Wren"]; | |
| const TURN = 3000; // speaking duration | |
| const REPLY = 1000; // reply window duration | |
| const MAX_HANDS = 2; | |
| // branches[branchId] = { speakerAtTime, startedAt, replies:[...], pendingCount } | |
| let branches = []; | |
| // FIFO queue for next turns: { user, createdAt } | |
| let handQueue = []; | |
| // Priority reply intents: { from, branch, reply, createdAt } | |
| let pendingReplies = []; | |
| // active hands per user = items in handQueue + items in pendingReplies | |
| let activeHands = new Map(USERS.map(u => [u, 0])); | |
| // Phase starts idle | |
| let phase = "IDLE"; // "IDLE" | "SPEAKING" | "REPLY_WINDOW" | |
| let phaseEnds = 0; | |
| // Current speaker (null unless SPEAKING) | |
| let currentSpeaker = null; | |
| // UI pointers | |
| let processing = { type: "idle", branch: -1, reply: -1, speaker: null }; | |
| // ---------- UI ---------- | |
| const $ = (id) => document.getElementById(id); | |
| const btns = { | |
| Echo: $("btnEcho"), | |
| Chronos: $("btnChronos"), | |
| Wren: $("btnWren"), | |
| }; | |
| // Set this to the container you want to turn blue during REPLY_WINDOW. | |
| // If you already have a wrapper element, give it id="outer" in HTML. | |
| const outerEl = $("outer") || document.body; | |
| function log(msg) { | |
| const el = $("log"); | |
| if (!el) return; | |
| const p = document.createElement("p"); | |
| p.className = "logline mono"; | |
| p.textContent = msg; | |
| el.prepend(p); | |
| } | |
| function updateButtons() { | |
| USERS.forEach((u) => { | |
| const b = btns[u]; | |
| if (!b) return; | |
| // green only while SPEAKING, and only for the current speaker | |
| const isSpeakingNow = phase === "SPEAKING" && currentSpeaker !== null && u === currentSpeaker; | |
| b.classList.toggle("speaking", isSpeakingNow); | |
| // hand emoji + red outline when they have any active hands | |
| const raised = activeHands.get(u) > 0; | |
| b.classList.toggle("hand", raised); | |
| b.classList.toggle("raised", raised); | |
| // disable only when at max hands | |
| b.disabled = activeHands.get(u) >= MAX_HANDS; | |
| }); | |
| } | |
| function updateOuter() { | |
| // blue outline when in REPLY_WINDOW | |
| outerEl.classList.toggle("reply-window", phase === "REPLY_WINDOW"); | |
| } | |
| function updateUI() { | |
| if ($("phase")) $("phase").textContent = phase; | |
| if ($("speaker")) $("speaker").textContent = (phase === "SPEAKING" && currentSpeaker) ? currentSpeaker : "—"; | |
| if ($("branchNo")) $("branchNo").textContent = processing.branch >= 0 ? processing.branch : "—"; | |
| if ($("replyNo")) $("replyNo").textContent = processing.reply >= 0 ? processing.reply : "—"; | |
| updateButtons(); | |
| updateOuter(); | |
| } | |
| // ---------- Branch lifecycle ---------- | |
| function createBranchForSpeaker(speaker) { | |
| const id = branches.length; | |
| branches.push({ | |
| speakerAtTime: speaker, | |
| startedAt: Date.now(), | |
| replies: [], | |
| pendingCount: 0, | |
| }); | |
| return id; | |
| } | |
| function clearBranchIfResolved(branchId) { | |
| const b = branches[branchId]; | |
| if (!b) return; | |
| if (b.pendingCount === 0 && b.replies.length > 0) { | |
| branches[branchId] = { ...b, replies: [], pendingCount: 0 }; | |
| log(`branches[${branchId}] resolved -> replies cleared`); | |
| } | |
| } | |
| function addReplyIntent(fromUser, branchId) { | |
| const b = branches[branchId]; | |
| const replyIndex = b.replies.length; | |
| const createdAt = Date.now(); | |
| b.replies.push({ from: fromUser, createdAt, kind: "during-speaking" }); | |
| b.pendingCount += 1; | |
| pendingReplies.push({ from: fromUser, branch: branchId, reply: replyIndex, createdAt }); | |
| activeHands.set(fromUser, activeHands.get(fromUser) + 1); | |
| log(`${fromUser}: reply-intent -> branches[${branchId}].replies[${replyIndex}]`); | |
| } | |
| function resolveReplyIntent(replyObj) { | |
| const b = branches[replyObj.branch]; | |
| if (!b) return; | |
| b.pendingCount = Math.max(0, b.pendingCount - 1); | |
| clearBranchIfResolved(replyObj.branch); | |
| } | |
| // ---------- Lower hand removes their spot ---------- | |
| function lowerHand(user) { | |
| // remove most-recent queued hand (LIFO within that user's items) | |
| for (let i = handQueue.length - 1; i >= 0; i--) { | |
| if (handQueue[i].user === user) { | |
| handQueue.splice(i, 1); | |
| activeHands.set(user, Math.max(0, activeHands.get(user) - 1)); | |
| log(`${user}: hand down (removed from queue)`); | |
| return true; | |
| } | |
| } | |
| // remove most-recent reply-intent | |
| for (let i = pendingReplies.length - 1; i >= 0; i--) { | |
| if (pendingReplies[i].from === user) { | |
| const removed = pendingReplies.splice(i, 1)[0]; | |
| activeHands.set(user, Math.max(0, activeHands.get(user) - 1)); | |
| resolveReplyIntent(removed); | |
| log(`${user}: hand down (canceled reply-intent on branch ${removed.branch})`); | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| // ---------- Priority selection ---------- | |
| function pickNextTalkerOrIdle() { | |
| // replies first (oldest first) | |
| if (pendingReplies.length) { | |
| pendingReplies.sort((a, b) => a.createdAt - b.createdAt); | |
| const r = pendingReplies.shift(); | |
| activeHands.set(r.from, Math.max(0, activeHands.get(r.from) - 1)); | |
| processing = { type: "reply", branch: r.branch, reply: r.reply, speaker: r.from }; | |
| // resolve as we begin processing it | |
| resolveReplyIntent(r); | |
| return r.from; | |
| } | |
| // then queued hands (FIFO) | |
| if (handQueue.length) { | |
| const item = handQueue.shift(); | |
| activeHands.set(item.user, Math.max(0, activeHands.get(item.user) - 1)); | |
| processing = { type: "turn", branch: -1, reply: -1, speaker: item.user }; | |
| return item.user; | |
| } | |
| processing = { type: "idle", branch: -1, reply: -1, speaker: null }; | |
| return null; | |
| } | |
| // ---------- Transitions ---------- | |
| function startSpeakingWithSpeaker(speaker) { | |
| currentSpeaker = speaker; | |
| phase = "SPEAKING"; | |
| phaseEnds = Date.now() + TURN; | |
| const b = createBranchForSpeaker(currentSpeaker); | |
| processing = { type: "turn", branch: b, reply: -1, speaker: currentSpeaker }; | |
| log(`SPEAKING -> speaker=${currentSpeaker} (branch ${b})`); | |
| updateUI(); | |
| } | |
| function enterReplyWindow() { | |
| // No one talks during reply window | |
| currentSpeaker = null; | |
| phase = "REPLY_WINDOW"; | |
| phaseEnds = Date.now() + REPLY; | |
| log("Phase -> REPLY_WINDOW (no speaking)"); | |
| updateUI(); | |
| } | |
| function goIdle() { | |
| currentSpeaker = null; | |
| phase = "IDLE"; | |
| phaseEnds = 0; | |
| processing = { type: "idle", branch: -1, reply: -1, speaker: null }; | |
| log("Room is idle."); | |
| updateUI(); | |
| } | |
| // ---------- Click behavior ---------- | |
| function onUserClick(user) { | |
| // REPLY_WINDOW: decide whether to respond (toggle raise/lower) | |
| if (phase === "REPLY_WINDOW") { | |
| const isRed = activeHands.get(user) > 0; | |
| if (isRed) { | |
| const ok = lowerHand(user); | |
| if (!ok) log(`${user}: nothing to remove`); | |
| } else { | |
| if (activeHands.get(user) >= MAX_HANDS) { | |
| log(`${user}: blocked (max hands)`); | |
| updateUI(); | |
| return; | |
| } | |
| handQueue.push({ user, createdAt: Date.now() }); | |
| activeHands.set(user, activeHands.get(user) + 1); | |
| log(`${user}: hand raised (queued)`); | |
| } | |
| updateUI(); | |
| return; | |
| } | |
| // SPEAKING: reply-intent | |
| if (phase === "SPEAKING") { | |
| if (activeHands.get(user) >= MAX_HANDS) { | |
| log(`${user}: blocked (max hands)`); | |
| return; | |
| } | |
| const branchId = processing.type === "turn" ? processing.branch : -1; | |
| if (branchId < 0) { | |
| const b = createBranchForSpeaker(user); | |
| processing = { type: "turn", branch: b, reply: -1, speaker: user }; | |
| } | |
| addReplyIntent(user, processing.branch); | |
| updateUI(); | |
| return; | |
| } | |
| // IDLE: raise hand and wake room | |
| if (activeHands.get(user) >= MAX_HANDS) { | |
| log(`${user}: blocked (max hands)`); | |
| return; | |
| } | |
| handQueue.push({ user, createdAt: Date.now() }); | |
| activeHands.set(user, activeHands.get(user) + 1); | |
| log(`${user}: hand raised (queued)`); | |
| if (phase === "IDLE") { | |
| const next = pickNextTalkerOrIdle(); | |
| if (next !== null) startSpeakingWithSpeaker(next); | |
| } | |
| updateUI(); | |
| } | |
| // ---------- Tick loop ---------- | |
| function tick() { | |
| if (phase === "IDLE") return; | |
| if (Date.now() < phaseEnds) return; | |
| if (phase === "SPEAKING") { | |
| // stop speaking -> reply window (no one speaks) | |
| enterReplyWindow(); | |
| return; | |
| } | |
| // end of REPLY_WINDOW -> pick next or idle | |
| const next = pickNextTalkerOrIdle(); | |
| if (next === null) { | |
| goIdle(); | |
| return; | |
| } | |
| // Start speaking with the chosen person | |
| startSpeakingWithSpeaker(next); | |
| } | |
| // ---------- Wire up ---------- | |
| btns.Echo?.addEventListener("click", () => onUserClick("Echo")); | |
| btns.Chronos?.addEventListener("click", () => onUserClick("Chronos")); | |
| btns.Wren?.addEventListener("click", () => onUserClick("Wren")); | |
| $("btnReset")?.addEventListener("click", () => location.reload()); | |
| // ---------- Boot ---------- | |
| log("Boot -> IDLE (raise a hand to start)"); | |
| updateUI(); | |
| setInterval(() => { | |
| tick(); | |
| updateUI(); | |
| }, 100); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment