Skip to content

Commit 7cd2197

Browse files
committed
Frontend redesign + LLM sprite chatter; backend chatter endpoint
UI - Title is now the city switcher: a glass column-select (Pixelify titles); the active city shows in the title button, clicking opens the dropdown of cities. - "whole city" button moved directly under the title (no more overlap). - News bubble: same opacity as the rest of the chrome; click to expand the full set of articles (date · headline · summary) that are informing the residents. - Loading bar spans the status bubble width, sits underneath it with spacing. - Bigger, more readable chrome; emojis removed throughout. - About card: notes the five cities now supported + a build credit. Per-resident LLM chatter (replaces hardcoded SF phrases) - New POST /branches/:bid/chatter: one short, in-character present-tense thought per requested resident, batched into a single Sonnet call (Engine::chatter). - Frontend requests chatter only for the sprites currently on screen and caches it, so calls stay sparse. Falls back to a city-neutral local pool while it loads — fixing SF chatter ("Karl the Fog", Muni, the bay) leaking into other cities.
1 parent bd8dd79 commit 7cd2197

7 files changed

Lines changed: 354 additions & 90 deletions

File tree

crates/sim-core/src/api.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ pub fn router(state: AppState) -> Router {
8484
.route("/branches/:bid", get(branch_status))
8585
.route("/branches/:bid", delete(delete_branch))
8686
.route("/branches/:bid/agents", get(branch_agents))
87+
.route("/branches/:bid/chatter", post(branch_chatter))
8788
.route("/branches/:bid/poll", post(branch_poll))
8889
.route("/branches/:bid/predict-market", post(predict_market))
8990
.route("/branches/:bid/stream", get(branch_stream))
@@ -452,6 +453,31 @@ async fn branch_agents(State(st): State<AppState>, Path(bid): Path<String>, Quer
452453
})).into_response()
453454
}
454455

456+
#[derive(serde::Deserialize)]
457+
struct ChatterReq {
458+
#[serde(default)]
459+
ids: Vec<u32>,
460+
}
461+
462+
/// Ambient sprite chatter for the residents currently on screen — one short,
463+
/// in-character LLM thought per requested resident, batched into a single call.
464+
/// The UI asks only for visible sprites and caches the result, so this is sparse.
465+
async fn branch_chatter(
466+
State(st): State<AppState>,
467+
Path(bid): Path<String>,
468+
Json(req): Json<ChatterReq>,
469+
) -> impl IntoResponse {
470+
let (ctx, _bs) = match find_branch(&st, &bid) {
471+
Some(x) => x,
472+
None => return (StatusCode::NOT_FOUND, Json(json!({"error":"branch not found"}))).into_response(),
473+
};
474+
let ids: Vec<u32> = req.ids.into_iter().take(16).collect();
475+
let pairs = st.engine.chatter(&ctx.population, &ids).await;
476+
let map: serde_json::Map<String, Value> =
477+
pairs.into_iter().map(|(id, t)| (id.to_string(), Value::String(t))).collect();
478+
Json(json!({ "chatter": map })).into_response()
479+
}
480+
455481
async fn branch_poll(State(st): State<AppState>, Path(bid): Path<String>, Json(req): Json<Value>) -> impl IntoResponse {
456482
let (ctx, _bs) = match find_branch(&st, &bid) {
457483
Some(x) => x,

crates/sim-core/src/predict.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,76 @@ p_yes is a probability between 0 and 1. Be realistic and calibrated to {city_nam
482482
let delta = after.p_yes - baseline.p_yes;
483483
Ok((baseline, after, delta))
484484
}
485+
486+
/// Ambient sprite chatter: one short, in-character present-tense thought per
487+
/// requested resident, in a single batched LLM call. Sparse by design — the
488+
/// UI only asks for the handful of residents currently on screen and caches
489+
/// the results, so this is cheap. Returns (agent_id, thought) pairs; ids the
490+
/// model drops are simply omitted (the UI keeps its fallback for those).
491+
pub async fn chatter(&self, pop: &Population, ids: &[u32]) -> Vec<(u32, String)> {
492+
let people: Vec<(u32, &str)> = ids
493+
.iter()
494+
.filter_map(|&id| pop.agents.get(id as usize).map(|a| (id, a.persona.as_str())))
495+
.collect();
496+
if people.is_empty() {
497+
return vec![];
498+
}
499+
let sys = format!(
500+
"You voice the private inner monologue of real {city} residents for an ambient \
501+
city simulation. For each resident, write the one short thought running through their head \
502+
right now as they go about an ordinary day — first person, present tense, at most 9 words, \
503+
specific and true to exactly who they are (their age, job, neighborhood, money pressures, \
504+
family, and values). Make each distinct and human; vary the mood; some mundane, some hopeful, \
505+
some worried. No names, no hashtags, no surrounding quotes. \
506+
Respond with STRICT JSON only: [{{\"i\":<index>,\"t\":\"<thought>\"}}].",
507+
city = pop.profile.prompt_name,
508+
);
509+
let mut user = String::from("Residents:\n");
510+
for (idx, (_id, prose)) in people.iter().enumerate() {
511+
user.push_str(&format!("{idx}. {prose}\n"));
512+
}
513+
let model = Model::parse("claude-sonnet-4-6");
514+
let max_tokens = (people.len() as u32 * 48 + 256).min(2400);
515+
// best-effort: a failed call just means the UI keeps its local fallback.
516+
let text = match self.client.complete(model, &sys, &user, max_tokens).await {
517+
Ok(t) => t,
518+
Err(_) => return vec![],
519+
};
520+
parse_chatter(&text, &people)
521+
}
522+
}
523+
524+
/// Parse the `[{"i":n,"t":"..."}]` chatter response, mapping each index back to
525+
/// its agent id. Tolerant of code fences / surrounding prose.
526+
fn parse_chatter(text: &str, people: &[(u32, &str)]) -> Vec<(u32, String)> {
527+
let start = match text.find('[') {
528+
Some(i) => i,
529+
None => return vec![],
530+
};
531+
let end = match text.rfind(']') {
532+
Some(i) if i > start => i,
533+
_ => return vec![],
534+
};
535+
let arr: serde_json::Value = match serde_json::from_str(&text[start..=end]) {
536+
Ok(v) => v,
537+
Err(_) => return vec![],
538+
};
539+
let mut out = Vec::new();
540+
if let Some(items) = arr.as_array() {
541+
for it in items {
542+
let idx = it.get("i").and_then(|v| v.as_u64()).map(|v| v as usize);
543+
let thought = it.get("t").and_then(|v| v.as_str());
544+
if let (Some(idx), Some(t)) = (idx, thought) {
545+
if let Some((id, _)) = people.get(idx) {
546+
let t = t.trim().trim_matches('"').trim();
547+
if !t.is_empty() {
548+
out.push((*id, t.to_string()));
549+
}
550+
}
551+
}
552+
}
553+
}
554+
out
485555
}
486556

487557
#[cfg(test)]

frontend/index.html

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,17 @@
2020
<canvas id="map"></canvas>
2121

2222
<div id="ui">
23-
<!-- top-left: title (pixel font, all black) + city switcher -->
24-
<div class="title-row">
25-
<div id="title" class="title">sim francisco</div>
26-
<div id="city-switcher" class="city-switcher hidden" role="group" aria-label="Choose a city"></div>
23+
<!-- top-left: the title IS the city switcher — a glass column of city titles -->
24+
<div id="title-select" class="title-select">
25+
<button id="title-btn" class="title title-btn" type="button"
26+
aria-haspopup="listbox" aria-expanded="false" aria-label="Switch city">
27+
<span id="title-current" class="title-current">sim francisco</span>
28+
<svg class="title-caret" viewBox="0 0 24 24" width="17" height="17" aria-hidden="true">
29+
<path fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"
30+
stroke-linejoin="round" d="M6 9l6 6 6-6" />
31+
</svg>
32+
</button>
33+
<div id="title-menu" class="title-menu hidden" role="listbox" aria-label="Cities"></div>
2734
</div>
2835

2936
<!-- simulation status: city · residents · knowledge-clock (top-right) -->
@@ -89,6 +96,7 @@
8996
<div class="about-h">The pitch</div>
9097
<p>What if you could predict how your customers responded to releases before they went out into the real world? Or if you could get a signal on who will win an election?</p>
9198
<p>We simulated the population of San Francisco distributed across real US Census data and achieved near-parity with these types of real-world events.</p>
99+
<p>It now spans <b>five US cities</b> — San Francisco plus <b>neu york</b> (New York City), <b>synth la</b> (Los Angeles), <b>cybercago</b> (Chicago), and <b>simami</b> (Miami) — each its own Census-sampled population. Switch between them from the title in the top-left.</p>
92100

93101
<div class="about-h">Historically predictive</div>
94102
<p>How do we know if our simulation of SF is accurate? The honest test needs a model whose knowledge cutoff <i>predates</i> the event, so it can't have just memorized the outcome. The older Claude models with a 2023 cutoff have since been <b>retired</b> — so for this leakage-free backtest we ran <b>GPT-4o</b> (knowledge cutoff <b>October 2023</b>) and asked the twin city to predict results it had never seen. (The live app you're using runs the current <b>Claude Sonnet 4.6</b>.)</p>
@@ -108,6 +116,10 @@
108116
<div class="about-h">The future</div>
109117
<p>All of decision-making is predicated on our understanding of causality. How will the choices we make influence the world? How will people react?</p>
110118
<p>To enable (first) SF, and then the world, to understand the causal nature of reality, we present <span class="about-name">sim francisco</span>.</p>
119+
120+
<div class="about-credit">
121+
Built by <b>Tejas Prabhune</b> and <b>Tanmayi Dasari</b> · 2nd place, <b>Claude Build Day</b> (June 13, 2026).
122+
</div>
111123
</div>
112124

113125
<!-- character inspector (tap a character when zoomed in) -->

frontend/src/api.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,15 @@ export const getCities = () => req("/cities", { timeout: 12000 });
4545
export const getNews = (city) =>
4646
req(`/cities/${encodeURIComponent(city)}/news`, { timeout: 10000 });
4747

48+
// Ambient LLM chatter for the residents currently on screen (sparse + batched).
49+
// Returns { chatter: { "<agentId>": "<thought>", ... } }.
50+
export const getChatter = (branchId, ids) =>
51+
req(`/branches/${encodeURIComponent(branchId)}/chatter`, {
52+
method: "POST",
53+
body: { ids },
54+
timeout: 15000,
55+
});
56+
4857
// Classify a free-form question for a city before polling. Returns either
4958
// { supported:true, framing, question, description, options } or
5059
// { supported:false, reason, examples }.

frontend/src/app.js

Lines changed: 101 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ import * as api from "./api.js";
1717
const $ = (id) => document.getElementById(id);
1818
const els = {
1919
canvas: $("map"),
20-
citySwitcher: $("city-switcher"),
20+
titleSelect: $("title-select"),
21+
titleBtn: $("title-btn"),
22+
titleCurrent: $("title-current"),
23+
titleMenu: $("title-menu"),
2124
status: $("status"),
2225
newsBubble: $("news-bubble"),
2326
boot: $("boot"),
@@ -54,12 +57,27 @@ const state = {
5457
cities: [], // [{slug, display, bbox, ...}] from GET /cities
5558
city: null, // the active city object (falls back to a synthetic "sf")
5659
switching: false, // true while a city swap is re-creating the simulation
60+
news: [], // the active city's recent articles (expandable bubble)
61+
newsExpanded: false, // whether the news bubble is showing all of them
5762
};
5863

5964
// fallback city when /cities is unavailable — keeps the single-city SF behavior.
6065
const SF_FALLBACK = { slug: "sf", display: "sim francisco", bbox: { ...MAP.bbox }, default: true };
6166
const citySlug = () => state.city?.slug || "sf";
6267

68+
// fetch LLM chatter for the residents now on screen (sparse, batched, best-effort)
69+
async function requestChatter(ids) {
70+
if (!state.mainBranch || !ids?.length) return;
71+
const branch = state.mainBranch;
72+
try {
73+
const data = await api.getChatter(branch, ids);
74+
if (branch !== state.mainBranch) return; // city swapped mid-flight — drop it
75+
const ch = data?.chatter || {};
76+
for (const [id, text] of Object.entries(ch)) map.setThought(Number(id), text);
77+
} catch { /* best-effort: residents keep their neutral fallback thought */ }
78+
}
79+
map.onNeedChatter = requestChatter;
80+
6381
const isBusy = () => state.phase === "waiting" || state.phase === "reveal";
6482
const inputOpen = () => els.ask.dataset.state === "input";
6583

@@ -91,7 +109,7 @@ async function boot() {
91109
if (cities.length) {
92110
state.cities = cities;
93111
initial = cities.find((c) => c.default) || cities[0];
94-
buildCitySwitcher();
112+
buildTitleSelect();
95113
}
96114
} catch (err) {
97115
console.warn("city catalog unavailable, falling back to SF:", err);
@@ -108,9 +126,10 @@ async function loadCity(city) {
108126
MAP.base = `assets/${city.slug}_tiles.png`;
109127
if (city.bbox) MAP.bbox = { ...city.bbox };
110128
map.setBase(MAP.base);
111-
syncActiveChip();
129+
syncActiveTitle();
112130

113131
els.status.textContent = `waking ${city.display}…`;
132+
hide(els.newsBubble); // clear the previous city's news while loading
114133
show(els.boot); setBoot(0.06);
115134
try {
116135
const sim = await api.createSimulation({ city: city.slug });
@@ -123,10 +142,11 @@ async function loadCity(city) {
123142
if (!agents.length) throw new Error("no agents returned");
124143
map.setAgents(agents);
125144
state.residents = agents.length;
145+
map.setSim(city.slug, state.mainBranch); // scope ambient chatter to this city + branch
126146
setBoot(1);
127-
setTimeout(() => hide(els.boot), 450); // let the bar finish, then fade out
128147
setIdleStatus();
129-
loadNews(city.slug); // fill the fleeting news bubble (best-effort)
148+
// let the bar finish, fade it out, then surface the news in its place (no overlap)
149+
setTimeout(() => { hide(els.boot); loadNews(city.slug); }, 450);
130150
state.phase = "idle";
131151
} catch (err) {
132152
console.error(err);
@@ -146,7 +166,7 @@ function setIdleStatus() {
146166
const kd = state.city?.knowledge_date;
147167
// the clock = the date up to which the residents know the news (their knowledge cutoff)
148168
const clock = kd
149-
? `<span class="status-clock">🕐 residents know the news up to ${escapeHtml(fmtDate(kd))}</span>`
169+
? `<span class="status-clock">residents know the news up to ${escapeHtml(fmtDate(kd))}</span>`
150170
: "";
151171
if (window.innerWidth < 560) {
152172
els.status.innerHTML = `${n} residents`; // compact on phones
@@ -162,42 +182,96 @@ function fmtDate(iso) {
162182
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
163183
}
164184

165-
// fetch the city's recent news into the fleeting bubble (best-effort)
185+
// fetch the city's recent news into the bubble (best-effort); click to expand all
166186
async function loadNews(slug) {
187+
state.newsExpanded = false;
167188
try {
168189
const data = await api.getNews(slug);
169-
const arts = (data.articles || []).slice(0, 3);
170-
if (!arts.length) { hide(els.newsBubble); return; }
171-
els.newsBubble.innerHTML =
172-
`<span class="news-head">📰 informing the residents</span>` +
173-
arts.map((a) => `<span class="news-item">${escapeHtml(a.headline)}</span>`).join("");
190+
state.news = data.articles || [];
191+
if (!state.news.length) { hide(els.newsBubble); return; }
192+
renderNews();
174193
show(els.newsBubble);
175194
} catch {
195+
state.news = [];
176196
hide(els.newsBubble);
177197
}
178198
}
179199

180-
// ── city switcher ─────────────────────────────────────────────────────────
181-
function buildCitySwitcher() {
182-
els.citySwitcher.innerHTML = state.cities.map((c) =>
183-
`<button class="city-chip" type="button" data-slug="${escapeHtml(c.slug)}"
184-
aria-pressed="false">${escapeHtml(c.display)}</button>`
200+
// render the news bubble in its current (collapsed / expanded) state
201+
function renderNews() {
202+
const arts = state.news;
203+
if (!arts.length) { hide(els.newsBubble); return; }
204+
els.newsBubble.dataset.expanded = state.newsExpanded ? "true" : "false";
205+
const caret = arts.length > 1
206+
? `<svg class="news-toggle" viewBox="0 0 24 24" width="13" height="13" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" d="M6 9l6 6 6-6"/></svg>`
207+
: "";
208+
const head = `<span class="news-head"><span>informing the residents</span>${caret}</span>`;
209+
const body = state.newsExpanded
210+
? arts.map((a) =>
211+
`<div class="news-art">` +
212+
(a.date ? `<span class="news-art-date">${escapeHtml(fmtDate(a.date))}</span>` : "") +
213+
`<span class="news-art-head">${escapeHtml(a.headline)}</span>` +
214+
(a.summary ? `<span class="news-art-sum">${escapeHtml(a.summary)}</span>` : "") +
215+
`</div>`
216+
).join("")
217+
: arts.slice(0, 3).map((a) => `<span class="news-item">${escapeHtml(a.headline)}</span>`).join("");
218+
els.newsBubble.innerHTML = head + body;
219+
}
220+
221+
// click the bubble to expand it to all the news updating the residents (and back)
222+
els.newsBubble.addEventListener("click", () => {
223+
if (!state.news.length) return;
224+
state.newsExpanded = !state.newsExpanded;
225+
renderNews();
226+
});
227+
228+
// ── title-select: the title itself is the city switcher ────────────────────
229+
function buildTitleSelect() {
230+
els.titleMenu.innerHTML = state.cities.map((c) =>
231+
`<button class="title-option" type="button" role="option" data-slug="${escapeHtml(c.slug)}"
232+
aria-selected="false">${escapeHtml(c.display)}</button>`
185233
).join("");
186-
els.citySwitcher.querySelectorAll(".city-chip").forEach((btn) => {
187-
btn.addEventListener("click", () => onSelectCity(btn.dataset.slug));
234+
els.titleMenu.querySelectorAll(".title-option").forEach((btn) => {
235+
btn.addEventListener("click", () => { closeTitleMenu(); onSelectCity(btn.dataset.slug); });
188236
});
189-
show(els.citySwitcher);
237+
syncActiveTitle();
190238
}
191239

192-
// reflect the active city + lock the chips while a swap is in flight
193-
function syncActiveChip() {
194-
els.citySwitcher.querySelectorAll(".city-chip").forEach((btn) => {
195-
const active = btn.dataset.slug === citySlug();
196-
btn.setAttribute("aria-pressed", active ? "true" : "false");
240+
function titleMenuOpen() { return els.titleBtn.getAttribute("aria-expanded") === "true"; }
241+
function openTitleMenu() {
242+
if (state.cities.length <= 1 || state.switching) return;
243+
show(els.titleMenu);
244+
els.titleBtn.setAttribute("aria-expanded", "true");
245+
}
246+
function closeTitleMenu() {
247+
hide(els.titleMenu);
248+
els.titleBtn.setAttribute("aria-expanded", "false");
249+
}
250+
function toggleTitleMenu() { titleMenuOpen() ? closeTitleMenu() : openTitleMenu(); }
251+
252+
// reflect the active city in the title button + the menu; lock while swapping
253+
function syncActiveTitle() {
254+
const city = state.cities.find((c) => c.slug === citySlug());
255+
els.titleCurrent.textContent = city?.display || state.city?.display || "sim francisco";
256+
els.titleMenu.querySelectorAll(".title-option").forEach((btn) => {
257+
btn.setAttribute("aria-selected", btn.dataset.slug === citySlug() ? "true" : "false");
197258
btn.disabled = state.switching;
198259
});
260+
els.titleBtn.disabled = state.switching || state.cities.length <= 1;
199261
}
200262

263+
// title-button toggles the dropdown; click-away / Escape close it
264+
els.titleBtn.addEventListener("click", (e) => {
265+
e.stopPropagation();
266+
if (!els.titleBtn.disabled) toggleTitleMenu();
267+
});
268+
document.addEventListener("click", (e) => {
269+
if (titleMenuOpen() && !els.titleSelect.contains(e.target)) closeTitleMenu();
270+
});
271+
document.addEventListener("keydown", (e) => {
272+
if (e.key === "Escape" && titleMenuOpen()) closeTitleMenu();
273+
});
274+
201275
async function onSelectCity(slug) {
202276
if (state.switching || slug === citySlug()) return;
203277
const city = state.cities.find((c) => c.slug === slug);
@@ -215,12 +289,12 @@ async function onSelectCity(slug) {
215289

216290
state.switching = true;
217291
state.phase = "booting";
218-
syncActiveChip();
292+
syncActiveTitle();
219293
try {
220294
await loadCity(city);
221295
} finally {
222296
state.switching = false;
223-
syncActiveChip();
297+
syncActiveTitle();
224298
}
225299
}
226300
// keep the status text right-sized across orientation changes

0 commit comments

Comments
 (0)