A devlog by Neeraj Kotwani, who builds every game on Elipar.
I was packaging five games for itch.io. That means stripping out anything that only makes sense on elipar.games and then checking each one still works somewhere hostile, so I ran them in a cross-origin frame with storage switched off, which is roughly what a third-party embed looks like.
Four were fine. Word Vault was a blank rectangle.
Not null. Throws.
The line was near the top of the file, working out which difficulty you last played:
MODES[localStorage.getItem(MODE_KEY)] || DEFAULT_MODE
Which reads as careful. There's a fallback. If there's nothing stored you get the default.
But when a browser has decided this page may not use storage, localStorage.getItem doesn't return null. Accessing localStorage at all throws a SecurityError. The fallback never runs, because the expression never finishes. And that happens in real situations, not theoretical ones: some private browsing modes, a user who has blocked site data, and third-party embedding.
Every other storage access in that file was wrapped in a try/catch. I knew the rule. I'd applied it eleven times and missed once.
Where it was mattered more than that it was
This is the bit worth internalising. An unguarded read inside a function makes that function fail. An unguarded read at the top level of a script kills the script.
This one was at module scope, so the throw happened while the file was still loading. Nothing after it ran. No setup, no event handlers, no board. The game wasn't forgetful about your difficulty setting, it was dead, and the console error goes somewhere no player will ever look.
Same one-line mistake, two completely different severities depending on which line it's on. A game that forgets your settings is a nuisance. A game that doesn't appear is not a game.
Why it survived for months
Our own frames are same-origin. Storage works. So on elipar.games this line could not fail, and it never did, and no amount of testing the site would have found it. It only became reachable the moment the game went somewhere else.
And there's a thing I need to correct in my own notes. Weeks earlier I'd seen a storage SecurityError during an unrelated test, checked whether it also appeared on the build before my change, found that it did, and written it off as an artifact of the test setup.
Which was true and not the same as harmless. The error was real. What was low was its reachability on our own domain. "Pre-existing" told me I hadn't caused it, and I let that answer a question it doesn't answer. If I'd spent thirty seconds looking at the line rather than confirming it predated me, I'd have found this in August.
A baseline comparison tells you who is responsible. It tells you nothing about whether the thing is a bug.
The same lesson, twice, in fullscreen
I can give you a second example from this codebase, because I made the matching mistake with a completely different API and then made a follow-up mistake inside the fix.
Games here have a fullscreen button. On iPhone it did nothing at all, silently. Not an error, not a refusal: nothing. iOS Safari has no fullscreen support for ordinary elements, only for video, so requestFullscreen isn't a function you can call and get rejected by. It isn't there. My code assumed the worst case was a promise that rejects, which is the "denied" case, and the actual case was "this doesn't exist".
So there's now a fallback that fakes it with CSS, pinning the frame over the whole viewport and locking the page behind it. Which worked, and introduced a better bug.
I sized that fake fullscreen container with 100vh. On iOS Safari 100vh is the height the page would have if the browser's toolbars were hidden, which they are not. So the bottom of my container sat underneath the browser's own bottom bar, and the exit button was down there, in the part of the page you cannot touch.
A player told me they were stuck in a game with no way back out, which was exactly true. I'd built a fullscreen mode with no exit on the one platform that needed the fallback in the first place. The unit that tracks the visible viewport fixes it, and the exit control also moved into the top-left corner, which is the one corner this site keeps clear on every game for its own buttons.
What I do now
Before a game goes anywhere off this site, I grep its folder for localStorage and check every hit sits inside a try. The other four were clean. It's a ten-second check and there is no clever version of it.
More generally: every browser API you treat as "returns nothing if unavailable" is worth ten seconds on what it does when it's actively denied rather than merely empty. Storage throws. Fullscreen rejects. Audio starts suspended. Clipboard throws. In each case my mental model was the empty case, and the denied case behaves differently.
Word Vault is the daily word puzzle here, free, one attempt a day. It loads in a private window now, which it turns out is how a fair number of people play it.