<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Engineering on Loracle Blog</title><link>https://loracle.app/blog/tags/engineering/</link><description>Recent content in Engineering on Loracle Blog</description><generator>Hugo -- 0.155.0</generator><language>en-us</language><lastBuildDate>Tue, 15 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://loracle.app/blog/tags/engineering/index.xml" rel="self" type="application/rss+xml"/><item><title>Your Table Doesn't Play in English</title><link>https://loracle.app/blog/posts/your-table-doesnt-play-in-english/</link><pubDate>Tue, 15 Sep 2026 00:00:00 +0000</pubDate><guid>https://loracle.app/blog/posts/your-table-doesnt-play-in-english/</guid><description>A German table runs a five-hour session and the recap comes back in English. We shipped that bug, and it hides in almost every tool this hobby uses.</description><content:encoded><![CDATA[<p>Picture a table in Munich. Five friends, a Thursday night, a campaign two years deep. The whole session happens in German: the threats, the bad accents, the argument about whether the paladin&rsquo;s oath covers arson. Then the recap arrives, and it reads like a BBC correspondent summarizing their game from across the channel.</p>
<p>If you play D&amp;D in German, French, Portuguese, or anything else that gets called a &ldquo;non-English market&rdquo; in a planning doc, you already know this experience. Every tool in this hobby assumes your table speaks English. Character builders, VTT interfaces, AI note-takers: English in, English out, and if your session happens to be in another language, you get whatever falls out of the machine.</p>
<p>We shipped that exact failure. This post is about the bug, the one-evening fix, and the three design decisions inside the fix that turned out to be the actual hard part.</p>
<h2 id="the-bug-we-detected-your-language-then-threw-it-away">The bug: we detected your language, then threw it away</h2>
<p>Here&rsquo;s the embarrassing shape of it. Loracle&rsquo;s transcription engine auto-detects the session&rsquo;s language. That part worked from day one: a German session produced a clean German transcript, correctly labeled <code>de</code>, sitting right there in the pipeline.</p>
<p>Then the recap stage took that German transcript and handed it to a language model with an English prompt and zero instructions about output language. So the model did what models do with mixed signals: it wrote the recap in English. Or mostly English. Sometimes it flipped a coin mid-document and your wiki got a German article with an English summary, which is somehow worse than either.</p>
<p>The data knew what language your table speaks. The pipeline ignored it. That&rsquo;s the whole bug, and I suspect it&rsquo;s the same bug inside most tools that behave this way. Language support usually exists at the transcription layer because the vendor sells it there, and then dies one function call later because nobody wrote the sentence that carries it forward.</p>
<h2 id="the-fix-took-one-evening-the-decisions-took-longer">The fix took one evening. The decisions took longer.</h2>
<p>The fix is a prompt directive, injected into every prose-generating pass in the pipeline: session recaps, entity extraction, wiki articles, the &ldquo;Previously On&rdquo; narration, narrative analysis. All five. It says, in essence: detect the dominant language of the source material and write ALL user-facing prose in that language. A German session gets a German recap. A Spanish one-shot gets a Spanish one.</p>
<p>No schema change, no feature flag, no settings toggle. Which sounds like a small ship, and in line count it was. But three decisions inside that directive are where the real product thinking lives, and each one is a place where the naive version quietly breaks.</p>
<h2 id="decision-one-structure-stays-english-prose-goes-native">Decision one: structure stays English, prose goes native</h2>
<p>A recap document has load-bearing bones. Section headings like &ldquo;The Story So Far&rdquo; get parsed by the pipeline downstream; JSON keys and entity type fields get read by code. Translate those and the machine stops understanding its own output. A French table would get a beautiful French recap that fails to render, because the parser is looking for a heading that&rsquo;s now &ldquo;L&rsquo;Histoire Jusqu&rsquo;ici.&rdquo;</p>
<p>So the directive splits the document into two layers: everything a human reads goes in the table&rsquo;s language, everything a machine reads stays pinned to English. Titles, summaries, narration, descriptions: native. Headings, keys, enums: English, exactly as specified. The reader never sees the seam.</p>
<h2 id="decision-two-never-translate-a-name">Decision two: never translate a name</h2>
<p>This one is craft, and it&rsquo;s the decision I&rsquo;d defend hardest. If your table named the villain &ldquo;Der Bleiche König,&rdquo; that&rsquo;s his name. He appears in the transcript as Der Bleiche König, the wiki article about him is written in German, and he is never, ever &ldquo;The Pale King.&rdquo;</p>
<p>Names at a table are canon in their exact spoken form. The entity graph that Loracle builds across sessions depends on recognizing the same name every time it appears; a translation pass that &ldquo;helpfully&rdquo; localizes names would fracture your cast into duplicates and betray the way your table actually talks. The rule in the prompt is absolute: entity names pass through untouched, whatever language surrounds them.</p>
<h2 id="decision-three-detect-per-session-follow-the-table">Decision three: detect per session, follow the table</h2>
<p>We could have added a campaign language setting. A dropdown, a default, a support article explaining it. We skipped it, because the transcript already tells us the truth per session, and the truth beats a setting. Tables are messy: the Brazilian group that runs one session in English because a visiting friend joined, the bilingual table that drifts. Detection follows what actually happened at your table that night. A setting only remembers what someone clicked six months ago.</p>
<h2 id="why-this-stays-rare">Why this stays rare</h2>
<p>The honest answer for why most tools don&rsquo;t do this: the market math looks bad until it doesn&rsquo;t. English-speaking players dominate the spreadsheets, i18n sounds like a translation-agency invoice, and the tables it fails are quiet about it. They just churn.</p>
<p>But TTRPG is enormous in Germany, in France, in Brazil. Those tables are recording sessions, taking notes, forgetting what happened last month, exactly like yours. The pipeline version of this feature costs a directive and some discipline about which layer speaks which language. Once your recap engine reads the session instead of assuming it, following the table&rsquo;s language stops being a feature and becomes the obvious default.</p>
<p>Your table&rsquo;s memory should sound like your table. If your paladin committed arson in German, the record of it should read that way too.</p>
<div class="loracle-callout">
<span class="callout-kicker">Spielt ihr auf Deutsch?</span>
<p><strong>Loracle</strong> records your Discord session, auto-detects the language, and writes the recap, the wiki, and the "Previously On" in it. Your NPCs keep their names. Your table keeps its voice.</p>
<p><a class="callout-cta" href="https://loracle.app">Try it with your table →</a></p>
</div>
]]></content:encoded></item><item><title>13 Ways a Recording Bot Can Lose Your Session</title><link>https://loracle.app/blog/posts/13-ways-a-recording-bot-loses-your-session/</link><pubDate>Sun, 13 Sep 2026 00:00:00 +0000</pubDate><guid>https://loracle.app/blog/posts/13-ways-a-recording-bot-loses-your-session/</guid><description>We paid an adversarial audit to attack our own recording pipeline the day we started buying traffic. It found thirteen ways to lose your table&amp;#39;s only copy of the night. Here&amp;#39;s every one.</description><content:encoded><![CDATA[<p>Your table&rsquo;s session has exactly one copy. Six people spent four hours making something that will never happen again, and the only record of it lives inside a bot that promised to be listening. If you&rsquo;re a GM deciding whether to trust a recording bot with that, you should want to know how these things fail. So here is how ours fails, all thirteen ways, because the day we started paying for traffic we commissioned an adversarial audit of our own recording pipeline with one mandate: &ldquo;from /record to /end we minimize the data we lose, always, and always package it up correctly.&rdquo;</p>
<p>The auditor traced every failure mode to the exact file and line. Four of the thirteen were bad enough that we shipped structural fixes the same night. This post is the honest version of that audit, written for the person whose session is on the line.</p>
<h2 id="where-your-audio-actually-lives">Where your audio actually lives</h2>
<p>To understand the failure modes you need the shape of the machine. When Loracle records, your audio exists in three places at any moment:</p>
<ol>
<li><strong>RAM</strong>: up to 30 seconds of raw audio per speaker, the in-flight buffer.</li>
<li><strong>Object storage</strong>: every 30 seconds, each speaker&rsquo;s buffer is encoded and uploaded as a chunk. This happens continuously all session.</li>
<li><strong>The database</strong>: a row that knows the recording exists, which machine owns it, and a heartbeat updated every 15 seconds.</li>
</ol>
<p>The design goal is that at most 30 seconds of audio is ever in a place that can die. Everything else should already be somewhere durable. Every failure mode below is a story about one of those three places, or about the handoffs between them.</p>
<h2 id="the-thirteen">The thirteen</h2>
<p><strong>1. The voice connection drops and quietly resumes.</strong> Discord&rsquo;s voice websocket hiccups, the library resumes it, and recording continues. Audio during the gap was never transmitted, so it can&rsquo;t be saved. The timeline stays honest (silence is marked as silence, so speakers don&rsquo;t drift out of sync), but those seconds are gone.</p>
<p><strong>2. Discord migrates your voice region mid-session.</strong> Same shape: a brief gap while the connection re-handshakes on the new server, then business as usual.</p>
<p><strong>3. End-to-end encryption fails mid-session.</strong> Discord&rsquo;s E2EE voice protocol can desync. We tolerate a burst of undecryptable packets, then force a recovery re-handshake, and health counters measure exactly how much was lost (typically one to two seconds per event). This one took most of July to chip down.</p>
<p><strong>4. The bot process crashes.</strong> The supervisor restarts it, the new process sees a recording row with a stale heartbeat, atomically claims it, rejoins your voice channel, and resumes from the next chunk index. You lose the in-RAM buffer plus the restart gap, bounded at roughly 30 seconds. When the session ends, the composer merges chunks from both the dead process and the new one, so the final file spans the crash.</p>
<p><strong>5. We deploy new code while you&rsquo;re recording.</strong> The old process flushes its buffers, waits for uploads, stamps the row as a handoff, and exits without closing the voice connection, deliberately leaving it dangling so the new process can adopt it within seconds. You should never notice a deploy.</p>
<p><strong>6. A server admin kicks the bot from voice.</strong> Your audio is already in storage, so it&rsquo;s safe, and the bot apologizes in the channel and marks the session interrupted so you can finish it with /end.</p>
<p><strong>7. Everyone leaves the voice channel and forgets the bot.</strong> Nothing is lost, because there&rsquo;s nothing to record. The failure here is duration accounting: a forgotten overnight session burns wall-clock time against your allowance for hours of silence. This one is on our list rather than fixed, and I&rsquo;d rather tell you that than pretend.</p>
<p><strong>8. /end races the bot&rsquo;s own disconnect.</strong> This was the nastiest finding. When you type /end, two independent pieces of code both react to the same disconnect signal: the finalizer that composes your audio, and the watchdog that flips sessions to &ldquo;interrupted&rdquo; when the bot drops unexpectedly. With the wrong timing, the watchdog won the race, and your session composed perfectly and then sat there marked interrupted, recap never delivered. Audio safe, product silently absent. A previous fix had closed one entrance to this room; the audit found three more. The structural fix gives finalization a single owner: the finalizer removes the connection from the shared state <em>before</em> disconnecting, so the watchdog&rsquo;s check fails closed. And the write that used to fail silently now logs loudly, because after this fix a silent failure genuinely indicates a bug.</p>
<p><strong>9. A chunk upload fails.</strong> Storage blip, network blip, whatever. Before the audit, this was the one true hole: the code caught the error, logged it, and moved on, and those 30 seconds were gone forever while the recording looked perfectly healthy. The audit ranked it the worst finding, the only mode that destroys audio <em>after</em> we captured it. The fix is an invariant now stated in the code: a flushed buffer is never dropped until some durable copy exists. On upload failure, chunk bytes spill to local disk and a retry queue drains them, with drains at flush, at finalize, and at the next process startup. A storage outage now delays your chunks instead of deleting them.</p>
<p><strong>10. Transcription fails after upload.</strong> Your audio is durable at this point, so this mode was always safe. The session is marked with an error you can retry, and a background sweep repairs anything stranded.</p>
<p><strong>11. Someone starts a second recording while one is running.</strong> The old session gets finalized, the new one starts, and there&rsquo;s an ugly one-second window where their bookkeeping can collide. Confusing UX, no lost audio.</p>
<p><strong>12. Your session runs long and the composer runs out of memory.</strong> The old composer decoded the entire session into RAM to stitch it. A three-hour talkative table could hit two gigabytes at exactly the moment of finalization, crash, and then crash again on retry. The most engaged tables, the ones with the longest sessions, were the most likely to hit it. The fix: composition is now a streaming concatenation (ffmpeg stitching compressed frames without decoding them), so memory use is constant whether the session ran one hour or six.</p>
<p><strong>13. One server&rsquo;s kick nuked every server&rsquo;s recording.</strong> Found in passing, the kind of bug that only bites at scale: the disconnect handler queried for <em>all</em> live recordings on the machine instead of the affected server&rsquo;s. One table&rsquo;s kick would have flipped every concurrent table&rsquo;s session to interrupted and posted apologies into all of their channels. Scoped to the affected server now.</p>
<p>There was a fourteenth finding, sort of: the health check that our host uses to auto-restart sick machines couldn&rsquo;t see the bot at all, only the web API. A hung bot would sit mute in your channel forever while every dashboard glowed green. The health endpoint now probes the bot&rsquo;s own event loop, so a hang triggers an automatic restart instead of a silent wedge. The dangerous failures are the ones that look like health.</p>
<h2 id="what-id-ask-any-recording-bot">What I&rsquo;d ask any recording bot</h2>
<p>Strip away our specifics and the audit reduces to four questions worth asking of anything you trust with your table&rsquo;s only copy:</p>
<ul>
<li><strong>How often does audio reach durable storage?</strong> If the answer is &ldquo;at the end of the session,&rdquo; a crash at hour three costs you three hours. Ours is every 30 seconds.</li>
<li><strong>What happens when an upload fails?</strong> &ldquo;We retry&rdquo; should come with a where-do-the-bytes-wait answer. Ours wait on disk.</li>
<li><strong>What happens when the process dies mid-session?</strong> Resume-from-where-it-stopped and merge-across-the-crash, or start over?</li>
<li><strong>Can the longest sessions actually finalize?</strong> Memory that grows with session length means your marathon sessions, the ones you most want recorded, are the ones most likely to fail at the finish line.</li>
</ul>
<p>The uncomfortable truth of the audit is that our capture spine was already strong and the finish line was where sessions went to die: composed audio stranded by a race condition, finalization crashing on the sessions that mattered most. Recording is a promise about the whole pipeline, from the moment someone says something great at 9:40 p.m. to the moment the <a href="/blog/posts/let-your-players-give-the-recap/">recap posts in your channel</a> the next morning. Thirteen failure modes, four structural fixes, one standing invariant. We&rsquo;ll publish the next audit too.</p>
]]></content:encoded></item></channel></rss>