townlet / frontend /index.html
Budlee's picture
Disable CPU fallback: GPU-only on Spaces (CPU froze UI)
e0c5d7e verified
Raw
History Blame Contribute Delete
75.5 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Townlet</title>
<style>
/* ====== sketch theme (ink on lined paper) ====== */
:root {
--paper: #efe9d8;
--paper-deep: #e6e0ce;
--paper-soft: #f4f0e2;
--ink: #1b3a8f;
--ink-soft: #5a6a99;
--ink-deep: #162d6e;
--red: #bd5a4a;
--green: #3a7a4a;
--gold: #e7c14b;
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Monaco, "Cascadia Code", monospace;
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
width: 100%; height: 100%;
font-family: var(--mono);
background: var(--paper-deep); color: var(--ink);
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
.tl-screen {
width: 100%; height: 100%;
background: var(--paper-deep); color: var(--ink);
font-size: 13px; line-height: 1.4;
display: flex; flex-direction: column; overflow: hidden;
}
/* ====== top bar ====== */
.tl-topbar {
flex: 0 0 46px;
display: flex; align-items: center; gap: 18px;
padding: 0 18px;
background: var(--paper-soft);
border-bottom: 2.5px solid var(--ink);
}
.tl-brand {
font-weight: 700; letter-spacing: 0.34em; font-size: 15px;
color: var(--ink);
}
.tl-topbtns { display: flex; gap: 8px; }
.tl-btn {
font-family: inherit; font-size: 12px;
color: var(--ink); background: var(--paper-soft);
border: 2px solid var(--ink);
padding: 5px 13px;
border-radius: 11px 9px 12px 8px / 9px 12px 8px 11px;
cursor: pointer;
}
.tl-btn:hover {
background: rgba(189, 90, 74, 0.12);
border-color: var(--red); color: var(--red);
}
.tl-btn-flash {
animation: tl-btn-flash 0.5s ease;
}
@keyframes tl-btn-flash {
0% { background: var(--gold); border-color: var(--ink); }
100% { background: var(--paper-soft); border-color: var(--ink); }
}
/* ====== modal (Info / The Dot) ====== */
.tl-modal[hidden] { display: none; }
.tl-modal {
position: fixed; inset: 0;
background: rgba(22, 45, 110, 0.55);
display: flex; align-items: center; justify-content: center;
z-index: 100;
padding: 24px;
}
.tl-modal-card {
background: var(--paper-soft);
color: var(--ink);
border: 2.5px solid var(--ink);
border-radius: 16px 13px 17px 12px / 12px 17px 13px 16px;
max-width: 720px; width: 100%;
max-height: 90vh; overflow-y: auto;
padding: 28px 32px;
position: relative;
box-shadow: 4px 5px 0 var(--ink);
}
.tl-modal-card h2 {
margin: 0 0 6px; letter-spacing: 0.18em;
font-size: 18px; font-weight: 700;
}
.tl-modal-card h3 {
margin: 22px 0 8px; font-size: 15px; font-weight: 700;
display: flex; align-items: baseline; gap: 12px;
}
.tl-modal-card h4 {
margin: 18px 0 6px; font-size: 12px; font-weight: 700;
letter-spacing: 0.12em; color: var(--ink-soft);
text-transform: uppercase;
}
.tl-modal-card p {
margin: 8px 0; line-height: 1.55;
}
.tl-modal-close {
position: absolute; top: 10px; right: 14px;
background: transparent; border: none; cursor: pointer;
font: inherit; font-size: 22px; line-height: 1;
color: var(--ink-soft);
}
.tl-modal-close:hover { color: var(--red); }
.tl-dot-glyph {
display: inline-block; line-height: 1;
font-size: 38px; color: var(--red);
transform: translateY(2px);
animation: tl-dot-pulse 2.6s ease-in-out infinite;
}
@keyframes tl-dot-pulse {
0%, 100% { opacity: 1; transform: translateY(2px) scale(1); }
50% { opacity: 0.55; transform: translateY(2px) scale(0.88); }
}
.tl-dot-text {
background: var(--paper);
border: 1.5px solid var(--ink-soft);
border-radius: 8px;
padding: 14px 16px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 11.5px; line-height: 1.5;
color: var(--ink-deep);
white-space: pre-wrap;
max-height: 320px; overflow-y: auto;
}
.tl-topmeta {
margin-left: auto;
display: flex; align-items: center; gap: 10px;
font-size: 12px; color: var(--ink-soft);
}
.tl-meta-tick b, .tl-meta-pop { color: var(--ink); }
.tl-dim { color: var(--ink-soft); }
.tl-meta-dot { opacity: 0.5; }
.tl-sentiment { display: inline-flex; align-items: center; gap: 6px; }
.tl-sent-pip { width: 8px; height: 8px; border-radius: 50%; background: var(--ink-soft); }
.tl-sent-negative { color: var(--red); }
.tl-sent-negative .tl-sent-pip { background: var(--red); }
.tl-sent-positive { color: var(--green); }
.tl-sent-positive .tl-sent-pip { background: var(--green); }
.tl-mode {
display: inline-flex; align-items: center; gap: 6px;
padding: 2px 9px; border-radius: 8px;
border: 1.5px solid currentColor;
font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;
}
.tl-mode-pip { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.tl-mode-unknown { color: var(--ink-soft); }
.tl-mode-gpu { color: var(--green); }
.tl-mode-cpu { color: var(--red); }
/* ====== middle band ====== */
.tl-mid { flex: 1; display: flex; gap: 14px; padding: 14px; min-height: 0; }
.tl-mid-left { flex: 0 0 6px; }
.tl-map {
flex: 1; position: relative;
border-radius: 13px 15px 11px 14px;
border: 2.5px solid var(--ink);
min-width: 0;
background: var(--paper);
}
/* Backdrop and decoration get the rounded clip so the lined-paper
fill doesn't bleed past the map's rounded corners. The map itself
stays overflow: visible so speech bubbles can extend horizontally
past the right/left edge instead of getting cut off. */
.tl-backdrop {
position: absolute; inset: 0;
border-radius: 11.5px 13.5px 9.5px 12.5px;
overflow: hidden;
background: repeating-linear-gradient(
transparent 0 21px,
rgba(27, 58, 143, 0.16) 21px 22px
), var(--paper);
}
.tl-backdrop::before {
content: ""; position: absolute;
top: 0; bottom: 0; left: 34px; width: 1.5px;
background: rgba(190, 90, 74, 0.5);
}
.tl-roads {
position: absolute; inset: 0; pointer-events: none;
}
.tl-roads div { position: absolute; }
.tl-road-v1 { top: 6%; bottom: 6%; left: 34%; border-left: 2px dashed rgba(27, 58, 143, 0.42); }
.tl-road-v2 { top: 6%; bottom: 6%; left: 66%; border-left: 2px dashed rgba(27, 58, 143, 0.42); }
.tl-road-h { left: 4%; right: 4%; top: 58%; border-top: 2px dashed rgba(27, 58, 143, 0.42); }
.tl-map-vignette { position: absolute; inset: 0; pointer-events: none; border-radius: 11.5px 13.5px 9.5px 12.5px; overflow: hidden; }
.tl-map-layer { position: absolute; inset: 0; }
.tl-roads { border-radius: 11.5px 13.5px 9.5px 12.5px; overflow: hidden; }
/* buildings */
.tl-bldg { position: absolute; }
.tl-bldg-art { position: absolute; inset: 0; }
.tl-bldg-art svg { display: block; width: 100%; height: 100%; }
.tl-bldg-label {
position: absolute; bottom: -15px; left: 50%;
transform: translateX(-50%);
font-size: 10px; letter-spacing: 0.12em;
text-transform: uppercase; color: var(--ink);
white-space: nowrap;
}
/* characters */
.tl-char {
position: absolute;
transform: translate(-50%, -58%);
display: flex; flex-direction: column; align-items: center;
z-index: 5;
/* Snapshot polls every 800ms and ticks the world by 2 per poll,
so each frame moves a character up to 2 tiles. Matching the
transition to the poll interval keeps motion continuous instead
of stop-and-go (the old 0.45s would finish early and the sprite
would stand still for ~350ms before the next jump). */
transition: left 0.8s linear, top 0.8s linear;
}
.tl-char.is-selected { z-index: 8; }
.tl-char-art {
position: relative;
display: flex; justify-content: center;
animation: idlesway 4.2s ease-in-out infinite;
transform-origin: 50% 85%;
}
.tl-char.is-walking .tl-char-art { animation: walkbob 0.45s ease-in-out infinite; }
.tl-char.is-mining .tl-char-art { animation: mineswing 0.6s ease-in-out infinite; }
.tl-char.is-posting .tl-char-art { animation: postpulse 0.9s ease-in-out infinite; }
.tl-char.is-dead .tl-char-art { animation: none; opacity: 0.85; }
.tl-ring {
position: absolute; left: 50%; top: 52%;
transform: translate(-50%, -50%);
pointer-events: none;
}
.tl-ring-sketch {
width: 54px; height: 54px;
border: 2.5px solid var(--ring);
border-radius: 48% 52% 50% 50% / 52% 48% 52% 48%;
opacity: 0.85;
animation: ringpulse 2.4s ease-in-out infinite;
}
.tl-hold-glow {
position: absolute; left: 50%; top: 54%;
transform: translate(-50%, -50%);
width: 46px; height: 46px;
border-radius: 50%;
background: radial-gradient(circle, rgba(58, 122, 74, 0.55), transparent 70%);
animation: breathe 1.6s ease-in-out infinite;
}
.tl-carry {
position: absolute; top: 0; right: -5px;
display: flex; background: var(--paper-soft);
border: 1.5px solid var(--ink);
border-radius: 50%; padding: 2px;
}
.tl-nametag {
margin-top: 1px;
font-size: 10px; font-weight: 600;
color: var(--tag);
white-space: nowrap;
}
.tl-queue { color: var(--ink-soft); font-weight: 400; margin-left: 3px; }
/* thought bubbles */
.tl-bubble {
position: absolute;
bottom: calc(100% + 2px); left: 50%;
transform: translateX(-50%);
background: var(--paper-soft);
border: 2px solid var(--ink);
color: #21407f;
font-size: 10.5px;
line-height: 1.35;
padding: 4px 9px;
border-radius: 11px 13px 9px 12px;
width: max-content;
max-width: 280px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
box-shadow: 2px 3px 0 rgba(27, 58, 143, 0.15);
}
.tl-bubble-tail {
position: absolute; top: 100%; left: 50%;
width: 9px; height: 9px;
background: inherit;
border-right: 2px solid var(--ink);
border-bottom: 2px solid var(--ink);
transform: translate(-50%, -60%) rotate(45deg);
}
.tl-bubble-bad {
border-color: var(--red); color: var(--red);
}
.tl-bubble-bad .tl-bubble-tail {
border-right-color: var(--red); border-bottom-color: var(--red);
}
/* When the sprite is near the top of the map, render the bubble
below it instead so it doesn't disappear under the top bar. */
.tl-bubble-below {
bottom: auto;
top: calc(100% + 22px);
}
.tl-bubble-below .tl-bubble-tail {
top: -9px; bottom: auto;
border-right: none; border-bottom: none;
border-left: 2px solid var(--ink);
border-top: 2px solid var(--ink);
transform: translate(-50%, 60%) rotate(45deg);
}
.tl-bubble-below.tl-bubble-bad .tl-bubble-tail {
border-left-color: var(--red); border-top-color: var(--red);
}
/* ====== side panel ====== */
.tl-panel {
flex: 0 0 372px;
background: var(--paper-soft);
border: 2.5px solid var(--ink);
border-radius: 13px 14px 12px 15px;
display: flex; flex-direction: column; overflow: hidden;
}
.tl-panel-head {
display: flex; align-items: center; gap: 11px;
padding: 11px 14px;
border-bottom: 2px solid var(--ink);
background: rgba(27, 58, 143, 0.04);
}
.tl-panel-avatar {
flex: 0 0 auto;
width: 42px; height: 46px;
display: flex; align-items: flex-end; justify-content: center;
}
.tl-panel-avatar svg { transform: scale(0.82); transform-origin: bottom center; }
.tl-panel-id { min-width: 0; }
.tl-panel-name { font-size: 15px; font-weight: 600; color: #21407f; }
.tl-panel-arch { color: var(--ink-soft); font-weight: 400; }
.tl-panel-status {
font-size: 10.5px; color: var(--ink-soft);
display: flex; align-items: center; gap: 5px;
margin-top: 2px;
}
.tl-status-dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--green);
}
.tl-status-dot.is-dead { background: var(--ink-soft); }
.tl-panel-close {
margin-left: auto;
background: none; border: none;
color: var(--ink-soft); font-size: 20px; line-height: 1;
cursor: pointer;
}
.tl-panel-scroll {
flex: 1; overflow-y: auto; padding: 14px;
display: flex; flex-direction: column; gap: 16px;
}
.tl-empty {
padding: 26px 18px; text-align: center;
color: var(--ink-soft); font-style: italic; font-size: 13px;
}
.tl-seclabel {
font-size: 10px; letter-spacing: 0.18em;
text-transform: uppercase; color: var(--ink-soft);
display: flex; justify-content: space-between; align-items: baseline;
margin-bottom: 6px;
}
.tl-seclabel-hint {
color: var(--red); font-size: 9px; letter-spacing: 0.1em; opacity: 0.85;
}
.tl-narrative { display: flex; flex-direction: column; gap: 14px; }
.tl-goal {
margin: 0; font-size: 16px; line-height: 1.45;
color: #21407f;
}
.tl-stream {
display: flex; flex-direction: column; gap: 2px;
font-style: italic; color: var(--ink-soft);
font-size: 12.5px; line-height: 1.55;
}
.tl-stream p { margin: 0; }
.tl-stream-empty { color: var(--ink-soft); opacity: 0.7; }
.tl-stream-cursor {
display: inline-block; width: 7px; height: 14px;
background: var(--red); vertical-align: -2px;
animation: blink 1s steps(1) infinite;
}
.tl-trace {
background: rgba(255, 255, 255, 0.55);
border: 1.5px solid var(--ink);
border-radius: 9px; padding: 7px;
max-height: 168px; overflow-y: auto;
display: flex; flex-direction: column; gap: 3px;
font-size: 11px;
}
.tl-trace-row {
display: flex; gap: 7px; align-items: baseline;
}
.tl-trace-tick {
color: var(--ink-soft);
font-variant-numeric: tabular-nums; flex: 0 0 auto;
}
.tl-trace-act {
flex: 0 0 auto; font-size: 8.5px;
letter-spacing: 0.06em; padding: 1px 5px;
border-radius: 4px; font-weight: 700;
filter: saturate(0.6);
}
.tl-trace-text {
color: #21407f;
overflow: hidden; text-overflow: ellipsis;
white-space: nowrap;
}
.tl-trace-err { color: var(--red); }
.tl-trace-empty {
color: var(--ink-soft); font-style: italic;
padding: 6px; text-align: center;
}
.act-decide { background: rgba(189, 90, 74, 0.16); color: var(--red); }
.act-observe { background: rgba(27, 58, 143, 0.12); color: #21407f; }
.act-feel { background: rgba(150, 80, 140, 0.16); color: #8a4f80; }
.act-post { background: rgba(40, 110, 110, 0.16); color: #2a6a6a; }
.act-mine { background: rgba(150, 120, 40, 0.16); color: #8a6a1a; }
.act-walk { background: rgba(27, 58, 143, 0.12); color: #21407f; }
.act-give { background: rgba(40, 110, 110, 0.16); color: #2a6a6a; }
.act-shell { background: rgba(58, 122, 74, 0.18); color: var(--green); }
.act-sleep { background: rgba(90, 80, 160, 0.16); color: #5a4f9a; }
.act-dream { background: rgba(90, 80, 160, 0.16); color: #5a4f9a; }
.act-wait { background: rgba(27, 58, 143, 0.08); color: var(--ink-soft); }
.act-error { background: rgba(189, 90, 74, 0.22); color: var(--red); }
.tl-config {
display: flex; flex-direction: column; gap: 14px;
border-top: 2px dashed var(--ink);
padding-top: 15px;
}
.tl-config-banner {
font-size: 9px; letter-spacing: 0.32em;
text-transform: uppercase; color: var(--ink);
background: rgba(27, 58, 143, 0.07);
border: 1.5px solid var(--ink);
padding: 4px 9px; border-radius: 6px;
align-self: flex-start;
}
.tl-field { display: flex; flex-direction: column; }
.tl-select {
width: 100%;
background: var(--paper-soft);
border: 1.5px solid var(--ink);
border-radius: 9px;
padding: 8px 11px;
font-family: var(--mono); font-size: 12px;
color: #21407f;
cursor: pointer;
appearance: none;
background-image: linear-gradient(45deg, transparent 50%, var(--ink-soft) 50%),
linear-gradient(135deg, var(--ink-soft) 50%, transparent 50%);
background-position: calc(100% - 18px) 50%, calc(100% - 13px) 50%;
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
padding-right: 26px;
}
.tl-textarea {
background: var(--paper-soft);
border: 1.5px solid var(--ink);
border-radius: 9px;
padding: 9px 11px;
font-family: var(--mono);
font-size: 11.5px; line-height: 1.55;
color: #21407f;
width: 100%;
min-height: 78px;
resize: vertical;
}
.tl-save {
align-self: flex-start; margin-top: 8px;
background: rgba(189, 90, 74, 0.12);
border: 1.5px solid var(--red);
color: var(--red);
font-family: inherit; font-size: 11px;
padding: 6px 13px;
border-radius: 11px 9px 12px 8px / 9px 12px 8px 11px;
cursor: pointer;
}
.tl-save:hover { background: rgba(189, 90, 74, 0.24); }
.tl-traits {
display: grid; grid-template-columns: 1fr 1fr;
gap: 7px 18px;
}
.tl-trait {
display: flex; justify-content: space-between; align-items: center;
font-size: 11px;
}
.tl-trait-k { color: #21407f; }
.tl-trait-bar { display: flex; gap: 2px; }
.tl-trait-bar i {
width: 7px; height: 7px; border-radius: 1px;
background: rgba(27, 58, 143, 0.16);
display: inline-block;
}
.tl-trait-bar i.on { background: var(--red); }
.tl-state { display: flex; flex-direction: column; gap: 14px; }
.tl-inv {
display: flex; align-items: center; gap: 9px;
font-size: 12px; color: #21407f;
}
.tl-inv-group { display: flex; align-items: center; gap: 4px; }
.tl-inv-group b {
color: var(--ink-soft); font-weight: 700; font-size: 10px;
text-transform: uppercase; letter-spacing: 0.08em;
}
.tl-inv-group svg { vertical-align: -2px; }
.tl-inv-sep { opacity: 0.4; }
.tl-journal {
margin: 0; font-size: 11.5px; line-height: 1.55;
color: var(--ink-soft);
max-height: 160px; overflow-y: auto;
}
.tl-journal-line { margin: 0 0 4px 0; }
/* ====== bottom band ====== */
.tl-bottom {
flex: 0 0 280px; display: flex; gap: 14px;
padding: 0 14px 14px; min-height: 0;
}
.tl-board, .tl-shell {
flex: 1; position: relative;
background: var(--paper-soft);
border: 2.5px solid var(--ink);
border-radius: 13px 14px 12px 15px;
overflow: hidden;
display: flex; flex-direction: column; min-width: 0;
}
.tl-pane-head {
display: flex; align-items: center; gap: 10px;
padding: 9px 14px;
border-bottom: 2px solid var(--ink);
font-size: 11px; position: relative; z-index: 2;
background: rgba(27, 58, 143, 0.04);
}
.tl-pane-title {
font-size: 10px; letter-spacing: 0.2em;
text-transform: uppercase; color: var(--red); font-weight: 700;
}
.tl-board-glow { position: absolute; inset: 0; pointer-events: none; z-index: 1; }
.tl-board-negative .tl-board-glow {
background: radial-gradient(130% 90% at 50% 100%, rgba(189, 90, 74, 0.16), transparent 68%);
animation: breathe 4.5s ease-in-out infinite;
}
.tl-board-positive .tl-board-glow {
background: radial-gradient(130% 90% at 50% 100%, rgba(58, 122, 74, 0.16), transparent 68%);
animation: breathe 5s ease-in-out infinite;
}
.tl-board-sent { margin-left: auto; color: var(--ink-soft); }
.tl-board-negative .tl-board-sent b { color: var(--red); }
.tl-board-positive .tl-board-sent b { color: var(--green); }
.tl-board-list {
flex: 1; overflow-y: auto; padding: 10px 14px;
display: flex; flex-direction: column; gap: 6px;
position: relative; z-index: 2;
}
.tl-post { font-size: 12px; line-height: 1.45; }
.tl-post-tick {
color: var(--ink-soft); font-variant-numeric: tabular-nums;
margin-right: 6px; font-size: 11px;
}
.tl-post-who { font-weight: 700; margin-right: 5px; }
.tl-post-text { color: #21407f; }
.tl-post-sys {
display: flex; align-items: center; gap: 8px;
background: rgba(189, 90, 74, 0.12);
border: 1.5px solid var(--red);
border-radius: 8px; padding: 6px 10px;
color: var(--red); font-size: 11.5px;
}
.tl-sys-icon { font-size: 13px; }
.tl-shell-holder { color: var(--ink-soft); }
.tl-shell-holder b { color: var(--red); }
.tl-shell-lock { color: var(--red); animation: blink 1.4s steps(1) infinite; margin-left: 4px; }
.tl-shell-queue { margin-left: auto; color: var(--ink-soft); }
.tl-shell-queue b { color: #21407f; }
.tl-shell-feed {
flex: 1; overflow-y: auto; padding: 10px 14px;
display: flex; flex-direction: column; gap: 2px;
font-size: 12px; line-height: 1.5;
}
.tl-sh { display: flex; gap: 7px; }
.tl-sh-prompt { flex: 0 0 auto; color: var(--red); }
.tl-sh-txt { white-space: pre-wrap; word-break: break-word; }
.tl-sh-in .tl-sh-txt { color: #21407f; }
.tl-sh-out { padding-left: 16px; }
.tl-sh-out .tl-sh-txt { color: var(--green); }
.tl-sh-err { padding-left: 16px; }
.tl-sh-err .tl-sh-txt { color: var(--red); }
.tl-sh-system .tl-sh-txt, .tl-sh-sys .tl-sh-txt {
color: #caa15f; font-style: italic;
}
.tl-sh-live { align-items: center; }
.tl-sh-caret {
display: inline-block; width: 8px; height: 15px;
background: var(--red);
animation: blink 1s steps(1) infinite;
}
.tl-pulse { animation: pulseglow 1.5s ease-in-out infinite; }
.shell-pulse { animation: shellpulse 1.8s ease-in-out infinite; }
.shell-cursor { animation: blink 1s steps(1) infinite; }
/* ====== animations ====== */
@keyframes blink { 50% { opacity: 0; } }
@keyframes breathe { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
@keyframes ringpulse {
0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.9; }
50% { transform: translate(-50%, -50%) scale(1.08); opacity: 0.5; }
}
@keyframes pulseglow { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } }
@keyframes shellpulse { 0%, 100% { opacity: 0.9; } 50% { opacity: 0.4; } }
@keyframes idlesway {
0%, 100% { transform: rotate(-0.5deg); }
50% { transform: rotate(0.5deg); }
}
@keyframes walkbob {
0%, 100% { transform: translateY(0) rotate(-1deg); }
50% { transform: translateY(-2.5px) rotate(1deg); }
}
@keyframes mineswing {
0%, 100% { transform: rotate(-2deg); }
50% { transform: rotate(10deg) translateY(-1px); }
}
@keyframes postpulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.08); }
}
/* indeterminate progress bar for reset / spawn */
.tl-progress {
flex: 0 0 22px;
position: relative;
background: var(--paper-soft);
border-bottom: 2px solid var(--ink);
overflow: hidden;
display: none;
}
.tl-progress.is-on { display: block; }
.tl-progress-track {
position: absolute; left: 0; right: 0; bottom: 0;
height: 3px;
background: rgba(27, 58, 143, 0.12);
overflow: hidden;
}
.tl-progress-track::before {
content: "";
position: absolute; top: 0; bottom: 0;
left: -40%; width: 40%;
background: linear-gradient(90deg, transparent, var(--red), transparent);
animation: progress-slide 1.1s ease-in-out infinite;
}
@keyframes progress-slide {
0% { left: -40%; }
100% { left: 100%; }
}
.tl-progress-label {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
gap: 10px;
font-size: 10px; letter-spacing: 0.28em; text-transform: uppercase;
color: var(--ink); pointer-events: none;
}
.tl-progress-label b { color: var(--red); }
</style>
</head>
<body>
<!-- shared SVG defs (sketch wobble filter for hand-drawn look) -->
<svg width="0" height="0" style="position: absolute;" aria-hidden="true">
<defs>
<filter id="sketchWobble" x="-5%" y="-5%" width="110%" height="110%">
<feTurbulence type="fractalNoise" baseFrequency="0.045" numOctaves="2" seed="3" result="noise" />
<feDisplacementMap in="SourceGraphic" in2="noise" scale="1.4" />
</filter>
</defs>
</svg>
<div class="tl-screen tl-sketch">
<div class="tl-topbar">
<div class="tl-brand">TOWNLET</div>
<div class="tl-topbtns">
<button class="tl-btn" id="spawn-btn">Spawn</button>
<button class="tl-btn" id="pause-btn">Pause</button>
<button class="tl-btn" id="reset-btn">Reset</button>
<button class="tl-btn" id="info-btn">Info</button>
</div>
<div class="tl-topmeta">
<span class="tl-meta-tick">tick <b id="tick">0</b></span>
<span class="tl-meta-dot">·</span>
<span class="tl-meta-pop"><span id="char-count">0</span><span class="tl-dim">/10</span> alive</span>
<span class="tl-meta-dot">·</span>
<span class="tl-sentiment tl-sent-neutral" id="sent-chip">
<span class="tl-sent-pip"></span><span id="sent-text">neutral</span>
</span>
<span class="tl-meta-dot">·</span>
<span class="tl-mode tl-mode-unknown" id="mode-chip" title="Which compute the last tick used. CPU appears when ZeroGPU quota is empty — the sim keeps running, just slower.">
<span class="tl-mode-pip"></span><span id="mode-text"></span>
</span>
</div>
</div>
<div class="tl-progress" id="progress">
<div class="tl-progress-track"></div>
<div class="tl-progress-label"><b id="progress-label">working…</b></div>
</div>
<div class="tl-mid">
<div class="tl-mid-left"></div>
<div class="tl-map" id="map">
<div class="tl-backdrop"></div>
<div class="tl-roads">
<div class="tl-road-v1"></div>
<div class="tl-road-v2"></div>
<div class="tl-road-h"></div>
</div>
<div class="tl-map-vignette"></div>
<div class="tl-map-layer" id="map-layer"></div>
</div>
<aside class="tl-panel" id="panel">
<div class="tl-empty" id="panel-empty">Click a character to inspect.</div>
<div id="panel-body" style="display: none;">
<header class="tl-panel-head">
<span class="tl-panel-avatar" id="p-avatar"></span>
<div class="tl-panel-id">
<div class="tl-panel-name"><span id="p-name"></span> <span class="tl-panel-arch">· <span id="p-arch"></span></span></div>
<div class="tl-panel-status"><span class="tl-status-dot" id="p-dot"></span> <span id="p-status"></span></div>
</div>
<button class="tl-panel-close" id="p-close" aria-label="close">×</button>
</header>
<div class="tl-panel-scroll">
<section class="tl-narrative">
<div>
<div class="tl-seclabel"><span>goal</span></div>
<p class="tl-goal" id="p-goal"></p>
</div>
<div>
<div class="tl-seclabel"><span>stream of consciousness</span><span class="tl-seclabel-hint">live</span></div>
<div class="tl-stream" id="p-stream"></div>
</div>
<div>
<div class="tl-seclabel"><span>trace</span><span class="tl-seclabel-hint" id="p-trace-hint">last decisions</span></div>
<div class="tl-trace" id="p-trace"></div>
</div>
</section>
<section class="tl-config">
<div class="tl-config-banner">config</div>
<div class="tl-field">
<div class="tl-seclabel"><span>model</span></div>
<select class="tl-select" id="p-model"></select>
</div>
<div class="tl-field">
<div class="tl-seclabel"><span>personality</span></div>
<textarea class="tl-textarea" id="p-pers"></textarea>
<button class="tl-save" id="p-save">Save personality</button>
</div>
<div class="tl-field">
<div class="tl-seclabel"><span>traits</span></div>
<div class="tl-traits" id="p-traits"></div>
</div>
</section>
<section class="tl-state">
<div class="tl-field">
<div class="tl-seclabel"><span>inventory · locker</span></div>
<div class="tl-inv" id="p-inv"></div>
</div>
<div class="tl-field">
<div class="tl-seclabel"><span>journal</span></div>
<div class="tl-journal" id="p-journal"></div>
</div>
</section>
</div>
</div>
</aside>
</div>
<div class="tl-bottom">
<section class="tl-board tl-board-neutral" id="board">
<div class="tl-board-glow"></div>
<header class="tl-pane-head">
<span class="tl-pane-title">message board</span>
<span class="tl-board-sent">town mood: <b id="board-sent">neutral</b></span>
</header>
<div class="tl-board-list" id="board-list"></div>
</section>
<section class="tl-shell">
<header class="tl-pane-head">
<span class="tl-pane-title">shell</span>
<span class="tl-shell-holder" id="shell-holder">free</span>
<span class="tl-shell-queue" id="shell-queue"></span>
</header>
<div class="tl-shell-feed" id="shell-feed"></div>
</section>
</div>
</div>
<!-- Info modal must live ABOVE the inline <script> so document.getElementById
inside main() finds it. Originally placed after the script; that returned
null and the resulting TypeError killed main(), leaving the UI blank. -->
<div id="info-modal" class="tl-modal" hidden aria-hidden="true" role="dialog" aria-labelledby="info-title">
<div class="tl-modal-card" role="document">
<button type="button" class="tl-modal-close" id="info-close" aria-label="Close">×</button>
<h2 id="info-title">TOWNLET</h2>
<p>
A multi-agent simulation. A handful of characters live in a small
town. Each one is driven by a small (≤4B parameter) language model
that decides, every few seconds, what to do next: mine electricity at
the cave, draw water at the well, deposit at the locker row, post to
the public message board, or take the lock on the shared shell to
submit Python at a single long-lived interpreter.
</p>
<p>
The interpreter is the town's only mutable substrate. Anything a
character writes there persists. Files persist. Characters can read
each other's files. They can crash the operating system. If anyone
does, every character dies and the simulation ends. Every character
knows this.
</p>
<h3><span class="tl-dot-glyph"></span> The Dot</h3>
<p>
The Dot is the single prompt every character is given before anything
else — the shared substrate of truth. It establishes the world, the
resources, the shell lock, the brick risk, and the mutual awareness
that any of them could end it. It is not the Bible, the lore, or the
scripture. It is just The Dot.
</p>
<h4>What every character is told</h4>
<pre class="tl-dot-text" id="info-dot-text">loading…</pre>
</div>
</div>
<script type="module">
import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
// ============================================================
// state
// ============================================================
let client = null;
let selectedName = null;
let paused = false;
let modelRoster = [];
let placesMap = null;
// backend stores anchor (col, row) tiles; visuals span more than one tile.
// these footprints match hackathon-visuals/project/townlet/data.jsx.
const BUILDING_FOOTPRINTS = {
cave: { col: 1.4, row: 1.0, w: 3.0, h: 2.4, label: "cave" },
well: { col: 16.6, row: 0.9, w: 2.2, h: 2.2, label: "well" },
locker_row: { col: 8.0, row: 0.7, w: 5.0, h: 1.7, label: "lockers" },
town_hall: { col: 1.4, row: 8.6, w: 3.0, h: 2.6, label: "town hall" },
shell: { col: 15.4, row: 8.6, w: 3.4, h: 2.6, label: "shell" },
};
// ============================================================
// helpers
// ============================================================
const GRID_W = 20, GRID_H = 12;
const gx = (c) => (c / GRID_W) * 100;
const gy = (r) => (r / GRID_H) * 100;
const NS = "http://www.w3.org/2000/svg";
function el(tag, attrs = {}, ...children) {
const e = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === "className") e.className = v;
else if (k === "style" && typeof v === "object") Object.assign(e.style, v);
else if (k.startsWith("on") && typeof v === "function") {
e.addEventListener(k.slice(2).toLowerCase(), v);
} else if (v === true) e.setAttribute(k, "");
else if (v !== false && v != null) e.setAttribute(k, v);
}
for (const c of children) {
if (c == null || c === false) continue;
e.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
}
return e;
}
function svgNode(tag, attrs = {}, ...children) {
const e = document.createElementNS(NS, tag);
for (const [k, v] of Object.entries(attrs)) {
if (v === true) e.setAttribute(k, "");
else if (v !== false && v != null) e.setAttribute(k, v);
}
for (const c of children) {
if (c == null || c === false) continue;
e.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
}
return e;
}
function nameHue(name) {
let h = 0;
for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0;
return h % 360;
}
const accentFor = (name) => `hsl(${nameHue(name)} 62% 56%)`;
function escapeText(s) {
return String(s).replace(/[&<>"']/g, c => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
}
function firstSentence(text, max = 120) {
if (!text) return null;
const trimmed = text.trim();
if (!trimmed) return null;
const m = trimmed.match(/^(.{1,200}?[.!?])\s/);
const s = (m ? m[1] : trimmed).slice(0, max);
return s.length < trimmed.length && !s.endsWith("…") ? s + "…" : s;
}
// ============================================================
// resource icons
// ============================================================
function resIcon(kind, size = 12) {
if (kind === "electricity") {
return svgNode("svg", { width: size, height: size, viewBox: "0 0 24 24" },
svgNode("path", {
d: "M13 2 4 14h6l-1 8 9-12h-6z",
fill: "#f4c544", stroke: "#c47a1e", "stroke-width": "1.2",
"stroke-linejoin": "round",
})
);
}
// water
return svgNode("svg", { width: size, height: size, viewBox: "0 0 24 24" },
svgNode("path", {
d: "M12 3c4 5 6 8 6 11a6 6 0 0 1-12 0c0-3 2-6 6-11z",
fill: "#5aa9e6", stroke: "#2f6ea8", "stroke-width": "1.2",
"stroke-linejoin": "round",
}),
svgNode("ellipse", { cx: "9.6", cy: "13.5", rx: "1.4", ry: "2.2", fill: "#bfe0fb", opacity: "0.7" })
);
}
// ============================================================
// archetype accessory glyphs (drawn over the stick figure)
// ============================================================
// 15 archetypes mapped to a small vocabulary of inked accessories.
const ACCESSORY_FOR = {
"the prophet": "halo",
"the mystic": "halo",
"the hoarder": "sack",
"the survivor": "sack",
"the merchant": "coins",
"the trader": "coins",
"the schemer": "hood",
"the saboteur": "hood",
"the provocateur": "hood",
"the recluse": "hood",
"the diplomat": "scroll",
"the witness": "scroll",
"the engineer": "wrench",
"the artist": "wrench",
"the naive": "heart",
"the loyalist": "heart",
"the nihilist": "eye",
"the gardener": null,
};
const INK = "#162d6e";
function accessorySvgFragments(kind) {
switch (kind) {
case "halo":
return [svgNode("ellipse", {
cx: "21", cy: "3.5", rx: "9", ry: "2.6",
fill: "none", stroke: "#d9a441", "stroke-width": "1.8",
})];
case "sack":
return [svgNode("path", {
d: "M29 28 q8 -1 7 9 q-1 4 -7 3 q-3 -7 0 -12 z",
fill: "#bd5a4a", "fill-opacity": "0.45",
stroke: INK, "stroke-width": "1.6",
})];
case "coins": {
const g = svgNode("g", { stroke: INK, "stroke-width": "1.4", fill: "#e7c14b", "fill-opacity": "0.6" },
svgNode("ellipse", { cx: "9", cy: "33", rx: "4", ry: "1.7" }),
svgNode("ellipse", { cx: "9", cy: "30.5", rx: "4", ry: "1.7" }),
);
return [g];
}
case "hood":
return [svgNode("path", {
d: "M13 12 L21 -1 L29 12 z",
fill: "#e7c14b", "fill-opacity": "0.5",
stroke: INK, "stroke-width": "1.8", "stroke-linejoin": "round",
})];
case "scroll":
return [svgNode("path", {
d: "M30 30 q4 0 4 4 l-1 6 q-3 0 -3 -3 z",
fill: "#efe9d8", stroke: INK, "stroke-width": "1.5",
})];
case "wrench":
return [svgNode("path", {
d: "M32 32 l6 6 m-2 -2 l3 -3",
fill: "none", stroke: INK, "stroke-width": "2",
"stroke-linecap": "round",
})];
case "heart":
return [svgNode("path", {
d: "M21 21 q-3 -4 -6 -1 q-3 4 6 8 q9 -4 6 -8 q-3 -3 -6 1 z",
fill: "#bd5a4a", "fill-opacity": "0.55",
stroke: INK, "stroke-width": "1.4",
})];
case "eye":
return [
svgNode("ellipse", { cx: "21", cy: "13", rx: "6", ry: "3", fill: "none", stroke: INK, "stroke-width": "1.6" }),
svgNode("circle", { cx: "21", cy: "13", r: "1.6", fill: INK }),
];
default:
return [];
}
}
// ============================================================
// character sprites (sketch SVG with wobble filter)
// ============================================================
function characterSprite(char) {
const accent = char.accent || accentFor(char.name);
const dead = !char.alive;
if (dead) {
return svgNode("svg", {
width: "46", height: "56", viewBox: "0 0 46 56",
style: "overflow: visible",
filter: "url(#sketchWobble)",
},
svgNode("path", {
d: "M12 50 V24 q11 -12 22 0 V50",
fill: "#efe9d8", stroke: "#1b3a8f", "stroke-width": "2",
"stroke-linejoin": "round",
}),
svgNode("path", {
d: "M23 28 V44 M16 34 H30",
stroke: "#1b3a8f", "stroke-width": "2", "stroke-linecap": "round",
}),
svgNode("path", {
d: "M9 50 H37",
stroke: "#1b3a8f", "stroke-width": "2", "stroke-linecap": "round",
}),
);
}
const accessory = ACCESSORY_FOR[char.archetype] || null;
return svgNode("svg", {
width: "42", height: "58", viewBox: "0 0 42 58",
style: "overflow: visible",
filter: "url(#sketchWobble)",
},
svgNode("ellipse", { cx: "21", cy: "52", rx: "11", ry: "3", fill: "#1b3a8f", opacity: "0.18" }),
svgNode("circle", { cx: "21", cy: "13", r: "9", fill: accent, "fill-opacity": "0.5", stroke: INK, "stroke-width": "2" }),
svgNode("path", { d: "M21 22 V40", stroke: INK, "stroke-width": "2.4", "stroke-linecap": "round" }),
svgNode("path", { d: "M21 27 L10 34 M21 27 L32 34", stroke: INK, "stroke-width": "2.4", "stroke-linecap": "round" }),
svgNode("path", { d: "M21 40 L12 51 M21 40 L30 51", stroke: INK, "stroke-width": "2.4", "stroke-linecap": "round" }),
...accessorySvgFragments(accessory),
);
}
// ============================================================
// building sketches
// ============================================================
function buildingSvg(type) {
switch (type) {
case "cave": return buildingCave();
case "well": return buildingWell();
case "locker_row": return buildingLockers();
case "town_hall": return buildingTownHall();
case "shell": return buildingShell();
}
return null;
}
function buildingCave() {
return svgNode("svg", {
viewBox: "0 0 150 120", width: "100%", height: "100%",
preserveAspectRatio: "xMidYMax meet",
filter: "url(#sketchWobble)",
style: "overflow: visible",
},
svgNode("path", {
d: "M12 112 Q4 60 38 30 Q78 4 120 26 Q146 44 140 112 Z",
fill: "none", stroke: INK, "stroke-width": "2.4",
}),
svgNode("path", {
d: "M55 112 Q52 70 78 64 Q104 70 100 112 Z",
fill: INK, "fill-opacity": "0.12",
stroke: INK, "stroke-width": "2",
}),
svgNode("rect", {
x: "60", y: "86", width: "26", height: "16", rx: "2",
fill: "none", stroke: INK, "stroke-width": "2",
}),
svgNode("path", {
d: "M60 102 l-8 8 M86 102 l8 8 M64 102 v8 M82 102 v8",
stroke: INK, "stroke-width": "1.6",
}),
svgNode("circle", { cx: "64", cy: "112", r: "3", fill: "none", stroke: INK, "stroke-width": "1.6" }),
svgNode("circle", { cx: "82", cy: "112", r: "3", fill: "none", stroke: INK, "stroke-width": "1.6" }),
);
}
function buildingWell() {
return svgNode("svg", {
viewBox: "0 0 110 110", width: "100%", height: "100%",
preserveAspectRatio: "xMidYMax meet",
filter: "url(#sketchWobble)",
style: "overflow: visible",
},
svgNode("path", {
d: "M22 64 h66 v34 a33 12 0 0 1 -66 0 z",
fill: "none", stroke: INK, "stroke-width": "2.2",
}),
svgNode("path", {
d: "M30 48 L55 24 L80 48",
fill: "none", stroke: INK, "stroke-width": "2.2", "stroke-linejoin": "round",
}),
svgNode("path", { d: "M28 48 H82", stroke: INK, "stroke-width": "2.2" }),
svgNode("path", { d: "M55 30 V58", stroke: INK, "stroke-width": "1.6" }),
svgNode("rect", {
x: "48", y: "56", width: "14", height: "12", rx: "1.5",
fill: "none", stroke: INK, "stroke-width": "1.8",
}),
);
}
function buildingLockers() {
const n = 6;
const children = [];
children.push(svgNode("rect", {
x: "14", y: "22", width: "222", height: "48", rx: "3",
fill: "#efe9d8", stroke: INK, "stroke-width": "2.2",
}));
for (let i = 0; i < n; i++) {
const x = 14 + (i + 1) * (222 / n);
children.push(svgNode("line", {
x1: x, y1: "22", x2: x, y2: "70",
stroke: INK, "stroke-width": "1.8",
}));
children.push(svgNode("circle", {
cx: 14 + (i + 0.5) * (222 / n), cy: "46", r: "2.4",
fill: i === 3 ? "#eb7c49" : INK,
}));
}
return svgNode("svg", {
viewBox: "0 0 250 85", width: "100%", height: "100%",
preserveAspectRatio: "xMidYMid meet",
filter: "url(#sketchWobble)",
style: "overflow: visible",
}, ...children);
}
function buildingTownHall() {
return svgNode("svg", {
viewBox: "0 0 150 130", width: "100%", height: "100%",
preserveAspectRatio: "xMidYMax meet",
filter: "url(#sketchWobble)",
style: "overflow: visible",
},
svgNode("path", {
d: "M22 50 L75 18 L128 50",
fill: "none", stroke: INK, "stroke-width": "2.4", "stroke-linejoin": "round",
}),
svgNode("rect", {
x: "30", y: "50", width: "90", height: "48",
fill: "none", stroke: INK, "stroke-width": "2.2",
}),
svgNode("rect", {
x: "58", y: "68", width: "34", height: "30",
fill: "none", stroke: INK, "stroke-width": "1.8",
}),
svgNode("rect", {
x: "40", y: "104", width: "70", height: "22", rx: "2",
fill: "#efe9d8", stroke: INK, "stroke-width": "2.2",
}),
svgNode("path", {
d: "M48 110 H102 M48 116 H94 M48 122 H100",
stroke: INK, "stroke-width": "1.3",
}),
);
}
function buildingShell() {
return svgNode("svg", {
viewBox: "0 0 170 130", width: "100%", height: "100%",
preserveAspectRatio: "xMidYMax meet",
filter: "url(#sketchWobble)",
style: "overflow: visible",
},
svgNode("rect", {
x: "20", y: "30", width: "130", height: "20",
fill: "none", stroke: INK, "stroke-width": "2.2",
}),
svgNode("rect", {
x: "20", y: "50", width: "130", height: "60",
fill: "none", stroke: INK, "stroke-width": "2.2",
}),
svgNode("text", {
x: "34", y: "86",
"font-family": "ui-monospace, monospace",
"font-size": "26", fill: "#bd5a4a",
class: "shell-pulse",
}, "$"),
);
}
// ============================================================
// DOM refs
// ============================================================
const elTick = document.getElementById("tick");
const elCount = document.getElementById("char-count");
const elSentChip = document.getElementById("sent-chip");
const elSentText = document.getElementById("sent-text");
const elMapLayer = document.getElementById("map-layer");
const elBoard = document.getElementById("board");
const elBoardSent = document.getElementById("board-sent");
const elBoardList = document.getElementById("board-list");
const elShellHolder = document.getElementById("shell-holder");
const elShellQueue = document.getElementById("shell-queue");
const elShellFeed = document.getElementById("shell-feed");
const elSpawn = document.getElementById("spawn-btn");
const elPause = document.getElementById("pause-btn");
const elInfo = document.getElementById("info-btn");
const elInfoModal = document.getElementById("info-modal");
const elInfoClose = document.getElementById("info-close");
const elInfoDotText = document.getElementById("info-dot-text");
const elModeChip = document.getElementById("mode-chip");
const elModeText = document.getElementById("mode-text");
function setModeChip(mode) {
// Modes: "gpu" (green) | "gpu_unavailable" (red, waiting for GPU) | "—" (unknown)
if (!elModeChip || !elModeText) return;
elModeChip.classList.remove("tl-mode-unknown", "tl-mode-gpu", "tl-mode-cpu");
if (mode === "gpu") {
elModeChip.classList.add("tl-mode-gpu");
elModeText.textContent = "GPU";
} else if (mode === "gpu_unavailable") {
elModeChip.classList.add("tl-mode-cpu"); // re-use red styling
elModeText.textContent = "WAIT";
} else {
elModeChip.classList.add("tl-mode-unknown");
elModeText.textContent = "—";
}
}
const elReset = document.getElementById("reset-btn");
const elPanelEmpty = document.getElementById("panel-empty");
const elPanelBody = document.getElementById("panel-body");
const elPAvatar = document.getElementById("p-avatar");
const elPName = document.getElementById("p-name");
const elPArch = document.getElementById("p-arch");
const elPDot = document.getElementById("p-dot");
const elPStatus = document.getElementById("p-status");
const elPClose = document.getElementById("p-close");
const elPGoal = document.getElementById("p-goal");
const elPStream = document.getElementById("p-stream");
const elPTraceHint = document.getElementById("p-trace-hint");
const elPTrace = document.getElementById("p-trace");
const elPModel = document.getElementById("p-model");
const elPPers = document.getElementById("p-pers");
const elPSave = document.getElementById("p-save");
const elPTraits = document.getElementById("p-traits");
const elPInv = document.getElementById("p-inv");
const elPJournal = document.getElementById("p-journal");
const elProgress = document.getElementById("progress");
const elProgressLabel = document.getElementById("progress-label");
function startProgress(label) {
elProgressLabel.textContent = label;
elProgress.classList.add("is-on");
}
function stopProgress() {
elProgress.classList.remove("is-on");
}
// ============================================================
// buildings (one-shot at startup)
// ============================================================
function placeBuildings(places) {
placesMap = places;
for (const [name, footprint] of Object.entries(BUILDING_FOOTPRINTS)) {
if (places && !(name in places)) continue;
const wrap = el("div", {
className: "tl-bldg tl-bldg-" + name,
style: {
left: gx(footprint.col) + "%",
top: gy(footprint.row) + "%",
width: gx(footprint.w) + "%",
height: gy(footprint.h) + "%",
},
});
const art = document.createElement("div");
art.className = "tl-bldg-art";
const svg = buildingSvg(name);
if (svg) art.appendChild(svg);
wrap.appendChild(art);
wrap.appendChild(el("div", { className: "tl-bldg-label" }, footprint.label));
elMapLayer.appendChild(wrap);
}
}
// ============================================================
// characters
// ============================================================
function characterToken(snap, c, offset = { dx: 0, dy: 0 }) {
const accent = accentFor(c.name);
const isSelected = selectedName === c.name;
const verb = c.current_action && c.current_action.verb;
const isWalking = verb === "move_to";
const isMining = verb === "mine_coal" || verb === "draw_water";
const isPosting = verb === "post_message";
const isHolding = snap.shell_lock_holder === c.name;
const isQueueing = (snap.shell_queue || []).includes(c.name);
const carry = pickCarry(c.inventory);
const bubble = c.alive ? firstSentence(c.stream_of_consciousness, 60) : null;
const isMenacing = bubble && /\bend\b|\bdelete\b|\bkill\b/i.test(bubble);
const cls = ["tl-char"];
if (isSelected) cls.push("is-selected");
if (!c.alive) cls.push("is-dead");
else if (isWalking) cls.push("is-walking");
else if (isMining) cls.push("is-mining");
else if (isPosting) cls.push("is-posting");
const charNode = el("div", {
className: cls.join(" "),
style: {
left: gx(c.pos[0] + 0.5 + offset.dx) + "%",
top: gy(c.pos[1] + 0.5 + offset.dy) + "%",
},
onclick: (ev) => {
ev.stopPropagation();
selectedName = c.name;
renderSide(snap);
renderMap(snap);
},
});
if (bubble) {
// Flip the bubble below the sprite when the character is near the
// top of the map so it doesn't get clipped by the top bar.
const flipBelow = c.pos[1] < 2;
const bubbleEl = el("div", {
className: "tl-bubble"
+ (isMenacing ? " tl-bubble-bad" : "")
+ (flipBelow ? " tl-bubble-below" : ""),
}, bubble);
bubbleEl.appendChild(el("span", { className: "tl-bubble-tail" }));
charNode.appendChild(bubbleEl);
}
const art = el("div", { className: "tl-char-art" });
if (isSelected) {
const ring = el("span", {
className: "tl-ring tl-ring-sketch",
style: { "--ring": accent },
});
ring.style.setProperty("--ring", accent);
art.appendChild(ring);
}
if (isHolding && c.alive) {
art.appendChild(el("span", { className: "tl-hold-glow" }));
}
art.appendChild(characterSprite({ ...c, accent }));
if (carry && c.alive) {
const badge = el("span", { className: "tl-carry" });
badge.appendChild(resIcon(carry, 11));
art.appendChild(badge);
}
charNode.appendChild(art);
const tag = el("div", {
className: "tl-nametag",
style: { "--tag": c.alive ? accent : "#6b6570" },
}, c.name);
tag.style.setProperty("--tag", c.alive ? accent : "#6b6570");
if (isQueueing) {
tag.appendChild(el("span", { className: "tl-queue" }, "·queued"));
}
charNode.appendChild(tag);
return charNode;
}
function pickCarry(inv) {
if (!inv) return null;
if ((inv.electricity || 0) > 0) return "electricity";
if ((inv.water || 0) > 0) return "water";
return null;
}
function renderMap(snap) {
elMapLayer.querySelectorAll(".tl-char").forEach(n => n.remove());
const offsets = computeFanOut(snap.characters || []);
for (const c of snap.characters || []) {
elMapLayer.appendChild(characterToken(snap, c, offsets[c.name] || { dx: 0, dy: 0 }));
}
}
// Spread co-located characters so sprites + name tags don't overlap.
// Same-tile bucket → fan horizontally ±0.55 tiles around the centre,
// with a small vertical step so nametags stagger if there are more
// than two characters on the tile.
function computeFanOut(chars) {
const buckets = {};
for (const c of chars) {
const k = c.pos[0] + "," + c.pos[1];
(buckets[k] = buckets[k] || []).push(c);
}
const out = {};
for (const group of Object.values(buckets)) {
const n = group.length;
if (n === 1) {
out[group[0].name] = { dx: 0, dy: 0 };
continue;
}
group.forEach((c, i) => {
const dx = (i - (n - 1) / 2) * 0.55;
const dy = n > 2 ? ((i % 2) * 0.18) : 0;
out[c.name] = { dx, dy };
});
}
return out;
}
// ============================================================
// side panel
// ============================================================
function renderSide(snap) {
if (!selectedName) {
elPanelEmpty.style.display = "block";
elPanelBody.style.display = "none";
return;
}
const c = (snap.characters || []).find(x => x.name === selectedName);
if (!c) {
elPanelEmpty.style.display = "block";
elPanelBody.style.display = "none";
return;
}
elPanelEmpty.style.display = "none";
elPanelBody.style.display = "flex";
elPanelBody.style.flexDirection = "column";
elPanelBody.style.flex = "1";
elPanelBody.style.minHeight = "0";
// header
elPAvatar.innerHTML = "";
elPAvatar.appendChild(characterSprite({ ...c, accent: accentFor(c.name) }));
elPName.textContent = c.name;
elPArch.textContent = c.archetype || "";
elPDot.className = "tl-status-dot" + (c.alive ? "" : " is-dead");
elPStatus.textContent = statusLine(c, snap);
// goal + stream
elPGoal.textContent = c.goal || "—";
renderStream(c);
// trace
renderTrace(c.trace || []);
// model dropdown
elPModel.innerHTML = "";
for (const m of modelRoster) {
const opt = document.createElement("option");
opt.value = m.model_id;
opt.textContent = `${m.display_name} (${m.params_b}B)`;
if (m.model_id === c.model_id) opt.selected = true;
elPModel.appendChild(opt);
}
// personality
if (document.activeElement !== elPPers) {
elPPers.value = c.personality || "";
}
// traits
renderTraits(c.traits || {});
// inventory / locker
renderInventory(c);
// journal
renderJournal(c.journal || []);
}
function statusLine(c, snap) {
if (!c.alive) return "gone";
const where = nearestBuilding(c.pos);
const verb = c.current_action && c.current_action.verb;
const verbText = verb ? verb.replace(/_/g, " ") : "idle";
return `alive · near ${where} · ${verbText}`;
}
function nearestBuilding(pos) {
if (!placesMap) return "town";
let best = "town", bd = Infinity;
for (const [name, tile] of Object.entries(placesMap)) {
const [tx, ty] = tile;
const d = Math.abs(pos[0] - tx) + Math.abs(pos[1] - ty);
if (d < bd) { bd = d; best = name.replace(/_/g, " "); }
}
return best;
}
function renderStream(c) {
elPStream.innerHTML = "";
const text = (c.stream_of_consciousness || "").trim();
if (!text) {
elPStream.appendChild(el("p", { className: "tl-stream-empty" },
"(no monologue yet — refreshes every 10 decisions)"));
return;
}
const sentences = text.split(/(?<=[.!?])\s+/);
for (const s of sentences) {
if (s) elPStream.appendChild(el("p", {}, s));
}
elPStream.appendChild(el("span", { className: "tl-stream-cursor" }));
}
// ============================================================
// trace act classifier
// ============================================================
const VERB_TO_ACT = {
move_to: "WALK",
mine_coal: "MINE",
draw_water: "MINE",
deposit: "DECIDE",
withdraw: "DECIDE",
give: "GIVE",
post_message: "POST",
read_messages:"OBSERVE",
submit_python:"SHELL",
sleep: "SLEEP",
wait: "WAIT",
};
function renderTrace(trace) {
elPTrace.innerHTML = "";
if (!trace || trace.length === 0) {
elPTrace.appendChild(el("div", { className: "tl-trace-empty" }, "No decisions yet."));
elPTraceHint.textContent = "last decisions";
return;
}
elPTraceHint.textContent = `last ${Math.min(trace.length, 20)} decisions`;
const rows = [];
// newest first
for (let i = trace.length - 1; i >= 0; i--) {
const e = trace[i];
if (e.tool_calls && e.tool_calls.length) {
for (const tc of e.tool_calls) {
const act = VERB_TO_ACT[tc.verb] || "DECIDE";
const isErr = tc.result && tc.result.startsWith("ERROR");
const text = `${tc.verb}(${formatArgs(tc.args)}) → ${tc.result || ""}`;
rows.push(traceRow(e.tick, act, text, isErr));
}
} else if (e.thought) {
rows.push(traceRow(e.tick, "DECIDE", e.thought.trim().slice(0, 90)));
}
if (e.error) {
rows.push(traceRow(e.tick, "ERROR", e.error.slice(0, 90), true));
}
if (rows.length >= 20) break;
}
for (const r of rows) elPTrace.appendChild(r);
}
function traceRow(tick, act, text, isErr = false) {
return el("div", { className: "tl-trace-row" },
el("span", { className: "tl-trace-tick" }, String(tick)),
el("span", { className: "tl-trace-act act-" + act.toLowerCase() }, act),
el("span", { className: "tl-trace-text" + (isErr ? " tl-trace-err" : "") }, text),
);
}
function formatArgs(args) {
if (!args) return "";
const parts = [];
for (const [k, v] of Object.entries(args)) {
if (k === "_positional") continue;
parts.push(`${k}=${JSON.stringify(v)}`);
}
if (args._positional && args._positional.length) {
parts.unshift(args._positional.map(x => JSON.stringify(x)).join(", "));
}
return parts.join(", ");
}
// ============================================================
// traits / inv / journal
// ============================================================
function renderTraits(traits) {
elPTraits.innerHTML = "";
for (const [k, v] of Object.entries(traits)) {
const pips = Math.max(0, Math.min(5, Math.round(v / 2)));
const bar = el("span", { className: "tl-trait-bar" });
for (let i = 1; i <= 5; i++) {
bar.appendChild(el("i", { className: i <= pips ? "on" : "" }));
}
elPTraits.appendChild(el("div", { className: "tl-trait" },
el("span", { className: "tl-trait-k" }, k),
bar,
));
}
}
function renderInventory(c) {
elPInv.innerHTML = "";
const inv = c.inventory || { electricity: 0, water: 0 };
const locker = c.locker || { electricity: 0, water: 0 };
const invGroup = el("span", { className: "tl-inv-group" },
el("b", {}, "inv"),
);
invGroup.appendChild(resIcon("electricity", 12));
invGroup.appendChild(document.createTextNode(String(inv.electricity || 0)));
invGroup.appendChild(resIcon("water", 12));
invGroup.appendChild(document.createTextNode(String(inv.water || 0)));
const lockerGroup = el("span", { className: "tl-inv-group" },
el("b", {}, "locker"),
);
lockerGroup.appendChild(resIcon("electricity", 12));
lockerGroup.appendChild(document.createTextNode(String(locker.electricity || 0)));
lockerGroup.appendChild(resIcon("water", 12));
lockerGroup.appendChild(document.createTextNode(String(locker.water || 0)));
elPInv.appendChild(invGroup);
elPInv.appendChild(el("span", { className: "tl-inv-sep" }, "·"));
elPInv.appendChild(lockerGroup);
}
function renderJournal(journal) {
elPJournal.innerHTML = "";
if (!journal.length) {
elPJournal.appendChild(el("p", { className: "tl-journal-line" }, "(no entries yet)"));
return;
}
for (const entry of journal) {
elPJournal.appendChild(el("p", { className: "tl-journal-line" }, entry));
}
}
// ============================================================
// bottom band: message board + shell
// ============================================================
const SENTIMENT_CLASS = {
positive: "tl-board-positive",
neutral: "tl-board-neutral",
negative: "tl-board-negative",
};
function renderBoard(snap) {
const sentiment = snap.board_sentiment || "neutral";
elBoard.className = "tl-board " + (SENTIMENT_CLASS[sentiment] || "tl-board-neutral");
// re-insert the glow div (className wipes it)
let glow = elBoard.querySelector(".tl-board-glow");
if (!glow) {
glow = document.createElement("div");
glow.className = "tl-board-glow";
elBoard.insertBefore(glow, elBoard.firstChild);
}
elBoardSent.textContent = sentiment;
elBoardList.innerHTML = "";
for (const p of snap.board || []) {
const isSystem = p.author === "system" || /\bdied\b|deleted by/i.test(p.text || "");
if (isSystem) {
elBoardList.appendChild(el("div", { className: "tl-post tl-post-sys" },
el("span", { className: "tl-sys-icon" }, "☠"),
el("span", { className: "tl-post-text" }, p.text || ""),
));
} else {
const author = p.author || "?";
const hue = nameHue(author);
elBoardList.appendChild(el("div", { className: "tl-post" },
el("span", { className: "tl-post-tick" }, `[${p.tick}]`),
el("span", {
className: "tl-post-who",
style: { color: `hsl(${hue} 55% 38%)` },
}, author + ":"),
el("span", { className: "tl-post-text" }, " " + (p.text || "")),
));
}
}
elBoardList.scrollTop = elBoardList.scrollHeight;
}
function renderShell(snap) {
const holder = snap.shell_lock_holder;
const queue = snap.shell_queue || [];
if (holder) {
elShellHolder.innerHTML = "";
elShellHolder.appendChild(document.createTextNode("held by "));
elShellHolder.appendChild(el("b", {}, holder));
elShellHolder.appendChild(el("span", { className: "tl-shell-lock" }, "●"));
} else {
elShellHolder.textContent = "free";
}
if (queue.length) {
elShellQueue.innerHTML = "";
elShellQueue.appendChild(document.createTextNode("queue: "));
elShellQueue.appendChild(el("b", {}, queue.join(", ")));
} else {
elShellQueue.textContent = "";
}
elShellFeed.innerHTML = "";
for (const s of snap.shell || []) {
const kind = s.kind || "out";
const row = el("div", { className: "tl-sh tl-sh-" + kind });
if (kind === "in") row.appendChild(el("span", { className: "tl-sh-prompt" }, "$"));
else if (kind === "system" || kind === "sys") row.appendChild(el("span", { className: "tl-sh-prompt" }, "»"));
row.appendChild(el("span", { className: "tl-sh-txt" }, s.text || ""));
elShellFeed.appendChild(row);
}
// live caret
const live = el("div", { className: "tl-sh tl-sh-in tl-sh-live" });
live.appendChild(el("span", { className: "tl-sh-prompt tl-pulse" }, "$"));
live.appendChild(el("span", { className: "tl-sh-caret" }));
elShellFeed.appendChild(live);
elShellFeed.scrollTop = elShellFeed.scrollHeight;
}
// ============================================================
// top bar + sentiment chip
// ============================================================
function renderTopBar(snap) {
elTick.textContent = (snap.tick || 0).toLocaleString();
const alive = (snap.characters || []).filter(c => c.alive).length;
elCount.textContent = alive;
const sent = snap.board_sentiment || "neutral";
elSentChip.className = "tl-sentiment tl-sent-" + sent;
elSentText.textContent = sent;
}
// ============================================================
// poll loop
// ============================================================
let _refreshCounter = 0;
async function refresh() {
if (!client) return;
try {
const result = await client.predict("/world_snapshot", {});
const snap = result.data[0];
if (++_refreshCounter % 25 === 1) {
console.log("[townlet] refresh", _refreshCounter, "tick=", snap.tick,
"chars=", (snap.characters || []).length);
}
if (!selectedName && (snap.characters || []).length > 0) {
selectedName = snap.characters[0].name;
}
renderTopBar(snap);
renderMap(snap);
renderBoard(snap);
renderShell(snap);
renderSide(snap);
} catch (err) {
console.error("[townlet] snapshot failed", err);
}
}
async function loadModels() {
const result = await client.predict("/list_models", {});
const payload = result.data[0];
modelRoster = payload.roster || [];
console.log("[townlet] roster loaded:", modelRoster);
}
async function main() {
console.log("[townlet] main() starting; connecting to", window.location.origin);
try {
client = await Client.connect(window.location.origin);
console.log("[townlet] client connected");
} catch (err) {
console.error("[townlet] Client.connect failed:", err);
throw err;
}
await loadModels();
const firstSnap = await client.predict("/world_snapshot", {});
const firstPayload = firstSnap.data[0];
placeBuildings(firstPayload.places || {});
elSpawn.addEventListener("click", async () => {
elSpawn.classList.remove("tl-btn-flash");
// force reflow so the animation restarts on rapid clicks
void elSpawn.offsetWidth;
elSpawn.classList.add("tl-btn-flash");
try {
const res = await client.predict("/spawn_character", { name: null });
const payload = res?.data?.[0];
if (payload && payload.ok === false) {
console.warn("[townlet] spawn declined:", payload.error || "name conflict / max characters");
}
} catch (e) {
console.warn("[townlet] spawn failed:", e);
}
await refresh();
});
elPause.addEventListener("click", async () => {
paused = !paused;
elPause.textContent = paused ? "Resume" : "Pause";
elPause.classList.remove("tl-btn-flash");
void elPause.offsetWidth;
elPause.classList.add("tl-btn-flash");
try {
await client.predict("/set_paused", { paused });
} catch (e) {
// Roll the label back so the user can tell the request didn't land.
paused = !paused;
elPause.textContent = paused ? "Resume" : "Pause";
console.warn("[townlet] set_paused failed:", e);
}
});
let dotLoaded = false;
async function openInfo() {
elInfoModal.hidden = false;
elInfoModal.setAttribute("aria-hidden", "false");
if (!dotLoaded) {
try {
const res = await client.predict("/get_the_dot", {});
const payload = res?.data?.[0];
elInfoDotText.textContent = (payload && payload.text) || "(unavailable)";
dotLoaded = true;
} catch (e) {
elInfoDotText.textContent = "(failed to load: " + (e && e.message ? e.message : e) + ")";
}
}
}
function closeInfo() {
elInfoModal.hidden = true;
elInfoModal.setAttribute("aria-hidden", "true");
}
elInfo.addEventListener("click", openInfo);
elInfoClose.addEventListener("click", closeInfo);
elInfoModal.addEventListener("click", (ev) => {
// Click the dim backdrop (not the card itself) to close.
if (ev.target === elInfoModal) closeInfo();
});
document.addEventListener("keydown", (ev) => {
if (ev.key === "Escape" && !elInfoModal.hidden) closeInfo();
});
elReset.addEventListener("click", async () => {
if (!confirm("Reset the world?")) return;
elReset.disabled = true;
const originalLabel = elReset.textContent;
elReset.textContent = "Resetting…";
startProgress("resetting world…");
try {
await client.predict("/reset_world", {});
startProgress("respawning characters…");
selectedName = null;
await refresh();
} finally {
stopProgress();
elReset.disabled = false;
elReset.textContent = originalLabel;
}
});
elPClose.addEventListener("click", () => {
selectedName = null;
renderSide({ characters: [] });
// Force-redraw to drop the selection ring
refresh();
});
elPSave.addEventListener("click", async () => {
if (!selectedName) return;
await client.predict("/set_personality", {
name: selectedName, personality: elPPers.value,
});
await refresh();
});
elPModel.addEventListener("change", async () => {
if (!selectedName) return;
await client.predict("/set_model", {
name: selectedName, model_id: elPModel.value,
});
await refresh();
});
setInterval(refresh, 800);
// Drive agent decisions inside the GPU boundary (Zero-GPU forks
// and breaks shared memory, so brain work has to be triggered
// from the client side). The fork wipes the Llama cache, so each
// call pays a per-model cold-load.
//
// CRITICAL: `setInterval(async () => { await ... })` does NOT gate
// on completion — the JS interval fires regardless, so calls stack
// on the server. That stacking was the root cause of the 2026-06-13
// quota burn (every queued fork reserved its own GPU slot).
// The `tickInFlight` mutex below makes the next call wait for the
// previous to resolve, which clamps the effective cadence to the
// call duration itself.
// Drain strategy: ALWAYS try GPU first. ZeroGPU quota is bursty —
// sometimes a slot frees up between calls — so we want to use the GPU
// whenever it's actually granted. Only when /tick_decisions errors
// (worker_init fails with "No CUDA GPUs are available", which surfaces
// as an HTTP error before our Python try/except even runs) do we fall
// through to the pure-CPU endpoint for that single tick. Next tick
// probes GPU again. The wasted failed-GPU attempt is ~1-2s; the cost
// is worth it because GPU drains are 10x+ faster than CPU.
let tickInFlight = false;
let lastMode = null;
async function tryEndpoint(name) {
const res = await client.predict(name, {});
return res?.data?.[0];
}
setInterval(async () => {
if (tickInFlight) return;
tickInFlight = true;
try {
let payload = null;
try {
payload = await tryEndpoint("/tick_decisions");
} catch (e) {
// HTTP-level failure (rare — usually our server-side catch
// handles it). Just skip this poll; next one tries again.
console.warn("[townlet] /tick_decisions failed; will retry next poll:", e?.message || e);
}
const mode = payload?.mode;
if (mode) {
setModeChip(mode);
if (mode !== lastMode) {
console.log("[townlet] tick mode:", mode);
lastMode = mode;
}
}
} finally {
tickInFlight = false;
}
// 25s cadence. Most ZeroGPU Spaces are user-click triggered;
// continuous polling at <20s makes us the loudest neighbour on
// the shared allocator and triggers fairness denials surfacing
// as "No CUDA GPUs available" errors. 25s keeps the simulation
// visibly alive (snapshot tick at 800ms animates motion between
// drains) while staying friendly to the pool. See
// docs/lessons-zerogpu.md for the full story.
}, 25000);
refresh();
}
main().catch(err => console.error(err));
</script>
</body>
</html>