← Design notes

Room language — one tongue per room, faithfullyLayers A + B live · 2026-06-21

Every room has a target language — detected from the Smart-selector's Describe box, or chosen in the new-chat picker's more settings (which defaults to your account language). The aim: every persona speaks that language, while staying completely themselves. Most do. A few don't — strongly language-anchored figures (Confucius always reaches for Chinese), or any host in a target the model finds hard (Japanese). This is how we fixed it without making the system prompt more fragile.

Strategy in one line: reduce the conflict so personas usually hit the target on their own, then catch the rare miss with a faithful translation. The invariant: a language slip must DEGRADE, never mislead — a host who can't hold the room's language is shown in their own words plus a faithful translation, never dropped, never silently off-target.
Layers A + B — shipped, validated & deployed 2026-06-21 Both layers are now live in production on chat.xbbapp.com (deployed 2026-06-21). The prompt reframe is live (the "voice ≠ language" principle, the whole-language-anchor override, and the schema fix + lang field). Re-running the two reported failures: a Japanese opening went 1/4 → 4/4 (Plato, Confucius and Stan Lee all now speak Japanese), and in a French room Confucius now speaks French while keeping his 正名 ("rectify the names") doctrine. Layer B is now shipped too — the persisted translate net: cross-script slips get a faithful translation under a hairline that survives reopen, is saveable to Notes, and supports go-to-line (validated end-to-end, ZH→FR).
Layer B was dead · fixed 2026-07-29For as long as multi-user has existed, Layer B reached exactly one turn per room — the opening greeting. _translate_divergent hangs off Room._call, and _call has two callers: the kickoff, and Room.say() — which nothing has called since the batched turn became the door. The live path is enqueue_humandrain_turn, and it resolved every turn without ever asking the net. Nobody noticed because the failure is silent and looks exactly like a persona holding the room's language correctly.

It now runs in drain_turn too, at the same position it holds in _call — after the turn resolves, before the history append. That position is not cosmetic: it is what criterion 11 rests on, because every matcher(the act call's target, <reveal>斑马</reveal>, the uniqueness invariant)must have read the AUTHORED reply first. And the room's addresses ride through masked, so a card keeps its name in a translated sentence — measured live below.

The price, measured 2026-07-29(five real calls on deepseek-v4-flash, the shape Layer B actually sees — a persona's on-stage turn in the wrong script):
0.98s mean · 0.96s median · 1.15s max, and $0.000022 a call — $0.02 per thousand divergent turns. The money is nothing. The second is not, because it is spent holding the room lock: the net runs inside the turn assembly, so a divergent reply pays its round trip before anyone else in the room can tap, vote or post. And a persona anchored to another language diverges on every turn, not occasionally. The timeout came down from 60s to 12s for the same reason — a hung translator used to be a minute-long freeze of the whole room, which was survivable while this only ever ran at the kickoff and is not now.

So it ships with a console switch(admin → Settings → Room language; MAD_LAYER_B overrides), read live at the moment a reply is resolved. And every call is now booked — a translate event carrying its wall clock, script pair, model and cost, off-stage. It never was, which was fine at one call per room and is not fine at one per turn.

⚠ And the live path found a hole in the mask within the hour. room_addresses() reads the TABLE, and on the turn that ARMS a card the table does not have it yet — the appliers run after the turn is recorded. So the one reply guaranteed to name a brand-new address, the one introducing it, was the one reply with nothing masked: s33 came backIt's set. First move, Head start — both are on, you can roll anytime.for two cards called 先手 and Head start. The fix looks ahead as well as around: the titles the reply is about to mint are harvested from the parsed blocks — shape-free, keeping any string the reply itself contains, because a harvester that knew all ten extractors' tuple and dict shapes would be a second copy of the router. Re-measured over six runs: every run that armed a card kept its addresses; the only survivor is a run that armed nothing and narrated a phantom, where there was no address to protect.

The mask, working, in one of the five:行,那就这么定了。先手已经关掉了,暗查还开着…came back asAlright, it's settled then. 先手 is already turned off, 暗查 is still on.Two cards, named in an English sentence, addressable next turn. Unmasked, the same translator turns 「先手」into First move.

1 · What actually bites

We measured every room with a target language — 46 rooms, 710 on-stage host lines — grading each line against the room's target. The raw number is 90% compliant, but that hides two things, both important for the fix.

TargetRoomsLinesIn targetNote
English2112485%many "misses" are the panel correctly following a guest who wrote another language
Chinese2256493%the model's strong language; when guest + target agree, ~100%
Japanese2862%even with a native-Japanese peer present, others default to English — looks like model capability
French11450%**under-counted by the detector; an Italian test later ran clean for everyone

First: most "non-compliance" is correct — the spec lets the panel follow the guest, so a host switching to the guest's language is the feature working, not a bug. When target and guest agree, compliance is ~100%. Second: the real defect is small and concentrated:

root cause The prompt has ~6 signals pulling toward each persona's own language — the megaprompt's "immutable soul" and "speak in each host's voice", the full profile loaded per host (Confucius: "the attested unit is the Chinese … never an English sentence as though he said it in English"), the tool schema's text = "in their own voice and language", and the verbatim replay of prior turns — versus ~2 toward the room language (one directive block, buried after the profiles). Piling louder room-language commands onto the weak side just makes the prompt contradict itself → more fragile. So we subtract the conflict first, then add a narrow net.

2 · The target language

Set once, then it tracks the live conversation:

effectiveTarget(turn) = the guest's current language ← if they've clearly switched else room.language ← Describe-box detection, or "more settings"

The Smart selector identifies the Describe box's main language at convene time and stores it as room.language — and that can be any language (Italian and French rooms already work), not only the four the manual dropdown offers. On a convene it's pre-filled into the picker's "more settings" Language dropdown (added as an option when it's outside the four), so what you see matches the room you'll get — still overridable. Every persona is graded against effectiveTarget, so a host who correctly followed the guest is never counted — or treated — as a miss.

3 · Layer A — stop contradicting (prompt reframe)

Three subtractive edits. No persona profiles change; nothing gets louder.

WherePullsChange
system-prompt.md (rule 7)own language+1 principle: voice ≠ language (below)
language_directive()room languageextend the override from dialects to whole-language anchors
panel tool text fieldown languageremove the contradiction + add a lang self-report
Voice is not language. The profile fixes a host's voice — diction, idiom, the lines they'd quote in the original. The language a turn is written in is the room's, and follows the guest if they switch. Keep every bit of the voice in that language; quoting a canonical line in its original script and then carrying it in the room's language is faithful, not a breach. A host's native tongue never overrides the room's language for their own speech.
schema fix The single live contradiction — the panel tool literally tells the model each host speaks in its own language:
text: "what this host says, in their own voice and language"
text: "what this host says — their voice, written in the room's language"
+ lang: "the language you wrote THIS reply in (honest self-report)"
That lang field is the keystone: it turns the schema from a contradiction into the detector, and gives exact compliance numbers.

And language_directive gains a clause for figures whose profile anchors a whole language: "Your profile may pin a native tongue ('quote the Chinese', 'speaks Japanese'). That shapes your voice and what you quote — not the room's language. Quote a canonical line in its original script when your profile calls for it, then carry it in {language}."

4 · Layer B — translate on divergence (the net)

Layer A makes most fragile cases land on their own. For the residual — a host who still slips — the server translates that one line, as a last resort:

1
Model turnllm

The panel tool returns each reply with who, text, and a self-reported lang.

2
Effective targetserver

The guest's current language if they've switched, else the room's stored language.

3
Per-reply checkserver

lang matches the target — or the guest's language? → keep as-is. Otherwise → translate.norm() folds synonyms (Chinese / 中文 / 简体 → zh) + a CJK-script sanity check

4
Translate — only on a missllm

A cheap call renders the line into the target, faithfully: preserving markdown, fenced code, math, and any verbatim original-language quote. Misses are the rare tail, so cost stays bounded.

Render, store, rememberclient

The bubble shows original ─ hairline ─ translation, and the line is stored with both halves — so it reappears intact every time the room is reopened. The model's working memory keeps only the target version, so the room language self-reinforces.

How a translated line renders

ConfuciusTeacher of Lu
客随主便。既然贵客以意大利语相邀,老朽自当从命——名不正则言不顺。

Mi adeguo all'ospite. Poiché il nobile ospite invita in italiano, questo vecchio obbedirà — se i nomi non sono retti, le parole non seguono.

One bubble: the host's own words on top, a hairline, then the faithful translation into the room language below — all formatting (markdown, code, math) preserved on both sides. The hairline is the only divider; the labels above are for this writeup only.

two stores, on purpose The translation is generated once and saved with the line, so it's persistent — it shows on every reopen and is never re-translated (which keeps the text byte-stable). Two separate records: the display record (transcript + event log → replay) keeps both the original and the translation; the model's working memory keeps only the target version, so the other hosts see one room language and the slip doesn't snowball.

Saved lines & "go to line" work on the translation

Because the translation is real, persisted text inside the same bubble — one line, one data-lid — the existing notebook features work on it unchanged:

This is why the translation is generated once and stored, not re-made on load: a re-translation could drift by a word, and a saved note would no longer match. Persist-once keeps the highlight exact.

5 · What changes where

FileChangeLayer
prototype/meta/system-prompt.md+1 "voice ≠ language" principle (rule 7)A
lib/run_room.pylanguage_directive reframe · schema text desc + new lang · panel_tool_descA
lib/run_room.pynew translate_to() (once, server-side) · turn pipeline: effective-target → divergence → translate → attach orig/orig_lang · persist them in the transcript + event log so replay rebuilds the bubble; model history keeps only the translationB
lib/room-ui.htmlrender orig + hairline + text on live turns and replay; .bub-rule CSS · save-line + go-to-line tokenize/flash across both halvesB
persona profilesnone — the reframe handles the anchored figures centrally

6 · Rollout & how we measured it

Two stages, so the lift was attributable and we didn't over-engineer:

The compliance probe lives in rooms-dev/_probe/ (gitignored); the lang field removes its blind spot on Latin-script languages, so before/after is measured, not eyeballed.

7 · Open risks

source records: lib/run_room.py (directive · schema · pipeline) · prototype/meta/system-prompt.md (the megaprompt) · lib/room-ui.html (the bubble render) · rooms-dev/_probe/ (the compliance probe, gitignored)

-ish · room language · shipped & deployed 2026-06-21 · see also Harness · Style Guide · Studio