Skip to content

Instantly share code, notes, and snippets.

@motionharvest
Created February 7, 2026 04:09
Show Gist options
  • Select an option

  • Save motionharvest/e0aa93b8a347fd438c43de1fd9eb308d to your computer and use it in GitHub Desktop.

Select an option

Save motionharvest/e0aa93b8a347fd438c43de1fd9eb308d to your computer and use it in GitHub Desktop.
Turn based Conversation Simulation
<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