One dropdown, one bug, and the afternoon we almost shipped it broken.
๐ Introduction
The Version 4 launch of Limn Engine had everything โ a new particle system, audio management, a slick preview. What it didn't have was a way for the playground to actually find the damn file.
We'd already built epic.js โ the new core script with particle systems and audio management baked in. That part felt great. That part felt like progress, like something to post about. But none of it mattered if the playground editor couldn't even find it. A developer could select "Version 4" from the menu, hit run, cross their fingers... and the preview would just keep loading the old engine. No error. No warning. Just the wrong file, running like nothing happened. Very smooth criminal behaviour from our own code.
That's the kind of bug that scares us more than any crash โ the one where nothing looks broken, so you almost sign off on it and go rest.
This is the story of how we fixed it, what we got wrong the first time, and what's still genuinely unfinished about it โ because pretending otherwise would defeat the point of writing this at all.
The one-line summary: We upgraded the Limn Engine playground editor to detect and load Version 4 โ and learned the hard way that "it works for us" and "it works" are not the same thing.
๐ The Problem Nobody Would Notice Until It Was Too Late
The playground editor doesn't ask any server which engine version you want โ no config file, no manifest, no backend deciding for you. It simply looks at the page, finds a <select> element, reads whatever text is sitting inside it, and tries to match that text to a script. That's the whole idea. Fast, simple, zero dependencies.
But that convenience comes with a price. If the scanner doesn't recognize a version string, it doesn't shout โ it doesn't crash. It just quietly keeps whatever it last loaded, which is exactly how we almost shipped V4 broken. We selected it, hit run, saw the canvas render... and only after staring longer than we'd like to admit did we realize it was the old engine playing dress-up as the new one. Nobody would've known unless they checked very carefully.
What this taught us: the bugs that don't announce themselves are the ones that reach production. We started treating a suspiciously calm preview as a warning, not a relief.
๐งต Teaching the Scanner to See V4
Here's the loop that walks the dropdowns on the page โ the actual fix, straight from the runn() function that powers the "Run" button:
let engineScriptFile = "epic.js";
let allDropdowns = document.querySelectorAll('select');
let versionDropdown = null;
for (let select of allDropdowns) {
let text = select.textContent.toUpperCase();
// Updated search check to recognize V2, V3, and V4
if (text.includes('V2') || text.includes('V3') || text.includes('V4') || select.value.toUpperCase().includes('V')) {
versionDropdown = select;
break;
}
}
Before we touched it, that condition stopped politely at V3, like V4 never existed. Every dropdown labeled "Version 4" was invisible to the scanner โ not broken, not flagged, just completely skipped.
Adding V4 to that condition was one line. Finding out it was missing took an entire evening, because our first assumption was that the routing was wrong โ not that the dropdown was never being found in the first place.
Future-proofing note we haven't shipped yet: hardcoding each version string like this means every new release needs another || clause. A cleaner version โ worth doing before V5 โ is a simple array:
const VERSIONS = ['V2', 'V3', 'V4', 'V5'];
if (VERSIONS.some(v => text.includes(v)) ||
select.value.toUpperCase().includes('V')) {
versionDropdown = select;
}
Still on the todo list. Mentioning it here so future-us can't pretend we didn't know.
๐ฏ Routing the Version to the Right File
Once the scanner can actually see the dropdown, something decides which script file to load:
if (versionDropdown) {
let selectedVersion = versionDropdown.value.toLowerCase();
if (selectedVersion.includes('v2')) {
engineScriptFile = "tcjsgame-v2.js";
} else if (selectedVersion.includes('v3')) {
engineScriptFile = "tcjsgame-v3.js";
} else if (selectedVersion.includes('v4')) {
engineScriptFile = "epic.js";
}
}
Notice engineScriptFile starts life as "epic.js" before this block even runs โ so if no dropdown is found at all, or nothing matches, the playground defaults to V4 anyway rather than falling back to an older version. That's not fallback logic. That's a variable we forgot to reset, quietly doing the right thing by pure accident โ the coding equivalent of surviving a disaster because you happened to fall asleep in the safest corner of the room.
What this taught us: sometimes your "fallback behavior" isn't something you designed โ it's whatever your default variable happens to be. Worth checking that it's the safe default, not just a default, before you accidentally get credit for engineering discipline you didn't actually apply.
๐ผ๏ธ The Part the Article Almost Skipped: What Actually Happens on "Run"
Once the right script file is picked, here's what the rest of runn() actually does โ and it's more interesting than just "load a script tag":
It checks if you're not even using the engine at all. Before any of the version-routing logic runs, the function checks whether your editor code looks like raw HTML:
let hasHtmlTags = /<[a-z][\s\S]*>/i.test(userEditorCode) || userEditorCode.includes('<!DOCTYPE');
if (hasHtmlTags && iframe) {
iframe.srcdoc = userEditorCode;
return;
}
If you paste a full HTML document into the editor, the playground doesn't fight you about it. It just shrugs, decides "ah, you don craft your own thing," and runs your raw HTML straight through โ like a landlord who stops asking questions the moment rent shows up on time. No engine, no version dropdown, no opinions. We hadn't thought to mention this branch in earlier drafts, but it's real, and a reader who hits it without knowing it's there will absolutely think something is broken.
Console output gets piped from the sandboxed iframe back to the editor UI. The generated preview document overrides console.log so that anything the user's game code logs shows up in a console panel in the parent editor, not just buried in the iframe's own devtools:
const _customLog = console.log;
console.log = function(...args) {
_customLog.apply(console, args);
const joinedArgs = args.map(arg =>
typeof arg === 'object' ? JSON.stringify(arg) : arg
).join(' ');
if (window.parent && window.parent.document) {
const consoleDisplay = window.parent.document.getElementById('editor-console-logs');
if (consoleDisplay) {
const line = window.parent.document.createElement('div');
line.textContent = joinedArgs;
consoleDisplay.appendChild(line);
consoleDisplay.scrollTop = consoleDisplay.scrollHeight;
}
}
};
That's genuinely a nice touch for a browser playground. Your console.log doesn't stay trapped in iframe jail โ it climbs the fence, walks over to the parent window, and reports what it saw like a very well-behaved snitch.
Runtime errors get caught and reported automatically. If the user's code throws, the playground catches it and posts the error message to a webhook so we can see what's breaking for real users without waiting for a bug report:
try {
${userEditorCode}
} catch(err) {
console.log("Engine Runtime Error: " + err.message);
// error message dispatched to an internal alerts channel
}
We're intentionally not showing the actual webhook wiring here โ it's a plain fetch call with a URL baked in, and an earlier draft of this article almost pasted that URL straight into the code block. We caught it before publishing, but only just. Publishing your own alert webhook URL in a public tutorial is the developer equivalent of posting a photo of your house key on Twitter with the caption "come design my kitchen." So: fixing that properly (move it behind a real backend endpoint instead of calling a webhook straight from client-side code) is next on the list, and until it's done, the URL stays out of anything we publish. Worth remembering if you're building something similar: a "just post to Discord" error reporter is fine for a personal project, but the moment the code that contains the URL is public โ like, say, in a blog post โ the URL is public too.
โ The One We Actually Went Back and Fixed
Talk is cheap, so before publishing this version, we went and closed the loudest gap: the missing timeout. Here's what actually shipped.
First, the script tag inside the generated iframe gets an id and real onload/onerror handlers, plus a timeout in case the browser never fires either event:
<script id="engineScript" src="./${engineScriptFile}"></script>
<script>
const ENGINE_LOAD_TIMEOUT_MS = 5000;
const engineTimeout = setTimeout(() => {
reportEngineLoadFailure('Engine timed out after 5s โ check your connection or the script URL.');
}, ENGINE_LOAD_TIMEOUT_MS);
const engineScriptTag = document.getElementById('engineScript');
engineScriptTag.onload = () => {
clearTimeout(engineTimeout);
};
engineScriptTag.onerror = () => {
clearTimeout(engineTimeout);
reportEngineLoadFailure('Engine script failed to load โ 404, CDN issue, or bad URL.');
};
function reportEngineLoadFailure(message) {
if (window.parent && window.parent.document) {
const banner = window.parent.document.getElementById('engine-error-banner');
if (banner) {
banner.textContent = "โ ๏ธ " + message;
banner.style.display = 'block';
}
}
}
</script>
Second, runn() itself resets the banner at the start of every run, so a stale error from three runs ago doesn't sit there haunting someone who already fixed the problem:
let errorBanner = document.getElementById('engine-error-banner');
if (errorBanner) {
errorBanner.style.display = 'none';
errorBanner.textContent = '';
}
Same trick as the console piping from earlier โ the sandboxed iframe reaches out to window.parent.document to update a banner that lives in the actual editor UI, not buried where nobody will see it.
Is this everything? No โ the dropdown-renaming gap and the "which engine actually loaded" confirmation are both still open, and we're not going to pretend otherwise twice in one post. But this is the difference between a paragraph promising a fix and a code block that is the fix. One down.
๐ฉน What's Still Actually Missing
Here's the part we got wrong last time we wrote about this: we described hardening we hadn't actually built yet โ a load timeout, an error banner, a fallback config object. Only the first two of those existed only as a paragraph back then. We fixed the timeout and banner for real, above. The rest still doesn't exist, and we're not going to pretend it does.
What the current runn() function still does not handle, as far as we can tell from the code itself:
| Gap | Current reality |
|---|---|
| Dropdown label gets renamed without updating the matching logic | Still pure string matching, no fallback config object. If someone changes "Version 4" to something the scanner doesn't recognize, we're back to square one. |
| Confirming which engine actually loaded | The console piping shows game output, not engine version. There's no console.log confirming "epic.js (v4) loaded" anywhere in the real code โ the new banner only fires on failure, not on success. |
One honest caveat: we're describing the script-loading path specifically, not every corner of the editor โ there's still some iframe styling elsewhere in runn() we haven't traced all the way through. If something down that path already touches error handling in a way we missed, consider this table mostly right instead of fully right. We'd rather flag the uncertainty than state it with false confidence.
Two down from three. All of them are still worth naming, which is a more honest place to leave this post than claiming they're all done.
๐ What If V4 Just... Doesn't Work?
If epic.js fails to load or throws early, there's currently no automatic fallback to V3 โ the developer has to manually reselect it from the dropdown. Combined with the missing timeout above, a broken V4 load can currently look identical to "the playground is just loading forever," with no clear signal to the user about what's actually wrong.
๐ฃ๏ธ Honesty About the Trade-offs
String-matching a dropdown label is not how a "serious" IDE would do this. Bigger tools use config manifests, package registries, versioned APIs โ systems that don't fall apart just because someone renamed a label without checking the routing logic.
We know. We built it this way anyway, because Limn Engine is still small enough that a full config system would be more machinery than the actual problem deserves โ for now. But we won't pretend the trade-off isn't there. If this editor grows past what it is today, string-matching is the first thing on the list to retire โ right alongside the webhook that needs to move server-side.
What this taught us: "good enough for now" is a real engineering decision, not laziness in disguise โ as long as you say it out loud instead of hiding it in the commit message. And "we fixed it" should mean the code changed, not just that we wrote a paragraph saying it did.
๐ What You've Learned
| Concept | How It Applies |
|---|---|
| Silent failure | A missing condition doesn't crash โ it quietly does the wrong thing |
| Default values as accidental fallbacks | Check whether your variable's default is actually the safe choice, not just a choice |
| Secrets in client-side code | Anything a browser can fetch, a reader can copy โ webhook URLs and API keys don't belong in code that ships to the client |
| Write down what's still broken | A devlog that only shows the fix and skips the gaps isn't more impressive, it's just less useful |
๐ Final Thoughts
The routing fix that shipped was one line of code. Writing honestly about everything around that line โ the parts that still don't work โ took a lot longer, and mattered more.
If you're building anything that reads the DOM to make a decision โ a dropdown, a data attribute, a class name โ assume it will fail quietly before it ever fails loudly. And if you're going to write about your own fix, resist describing the version of it you meant to build instead of the one that's actually running.
Draw your game into existence โ and make sure your dropdown knows which version to use. We're still working on the rest.
๐ Come Say Hi
- ๐ Try Limn Engine live (Version 4): limn-engine-doc.vercel.app
- ๐ป Source & issues: GitHub
- ๐ฌ Join the community: Discord
Your turn: what's your most embarrassing version-mismatch bug? Drop it in the comments โ we promise ours isn't the worst one out there. ๐ฎ

Top comments (0)