My game has eight installs and one rating. The rating is mine.
I am telling you that first because it is the most useful number in this article, and because everything below is more interesting if you know it. This is not a success story. It is a list of things that were quietly broken in software I had written carefully, tested by hand, and shipped to the Google Play Store — and that I only found because I stopped trusting my own reading of the code and started measuring it.
Every single bug below was invisible. None of them threw. None of them showed up in a log. Several had been live for months.
What the thing is
Squishly Jumper is an endless vertical jumper — you bounce upward from platform to platform and try to beat your own height. It is:
- 17,918 lines of JavaScript across 21 files, plus 1,674 lines of CSS and one 664-line HTML file
- No engine. No Unity, no Godot, no Phaser. One
<canvas>and arequestAnimationFrameloop. - No build step. No bundler, no transpiler, no
node_modulesin the shipped output. The files that run in the browser are the files I edit. - Wrapped with Capacitor for Android. The shipped app bundle is 4.82 MB.
- 26 worlds and 20 characters, drawn entirely with Canvas 2D path calls. There is not a single sprite sheet in the project.
Written by one person, alongside a day job as a mechanical engineer.
I mention the no-engine, no-build-step part not as a boast. It is directly relevant: several of these bugs exist precisely because there was no framework to catch them, and a couple exist because hand-rolled code lets you write something that looks defensive and is actually a permanent off switch.
1. Four lines of defensive code that were always false
Every module in this project looks like this:
const Game = (function () {
/* ... */
return { resize, start, pause };
})();
And in another file:
if (window.Game && Game.resize) Game.resize(BASE_H);
That line never ran. Not once, on any device, ever.
A top-level const in a classic script goes into the global lexical environment, not onto window. Game is reachable from every other script. window.Game is undefined. So window.Game && … is permanently false — and because the whole point of the line was to be cautious, its failure mode was silence.
There were four of these. What they switched off:
The canvas never followed the window height. The CSS stage resized correctly; the drawing buffer kept whatever height it had at load. On a phone that reports its final height late — after the system bars settle, after the splash goes away — the rest stayed black. Measured on 360×800: stage 1067 design pixels, canvas 700. 34 % of the screen with no game on it.
Cloud save had been completely dead for three releases. The wait-for-login routine asked window.Leaderboard. Both branches were always false, so it always ran into its own ten-second timeout and reported "sign-in did not complete". Neither automatically nor via the explicit "Back up now" button. My last successful device test had been on the release before the regression.
The lesson I keep coming back to: a guard against something that cannot exist is indistinguishable from working code until you check. It reads as careful. It is a switch permanently in the off position, wearing a hat that says "safety".
Fixed by switching all four to typeof Modul !== 'undefined', and then reading every window.* access in the project to confirm only genuine window objects were left: Capacitor, localStorage, crypto, location.
2. The canvas was 768 pixels wide on every phone in the world
The resolution cap was written as a ratio against the fixed 480-wide design stage:
const dpr = Math.min(devicePixelRatio, dprMax); // dprMax = 1.6
Every Android phone has a devicePixelRatio of at least 1.75. So the cap always applied, and the buffer came out at 480 × 1.6 = 768 pixels wide on every device, from the cheapest 720p handset to a 1440p flagship.
The effect was exactly backwards from the intent:
| Device | Real pixels | Buffer | Factor |
|---|---|---|---|
| 720p | 720 | 768 | 1.07 — 7 % wasted on the weakest hardware |
| 1080p | 1080 | 768 | 0.71 — upscaled 1.41× |
| 1440p | 1439 | 768 | 0.53 — upscaled 1.87× |
The cap was meant to stop expensive phones from cooking themselves. It made them blurry while giving the cheap phone more work than its screen could show.
The fix is a cap expressed as an absolute target width in real device pixels, not a ratio against an arbitrary internal unit. Re-measured across six device sizes: sharpness factor 1.000 everywhere.
If you take one thing from this section: a ratio is only meaningful against a number that means something. 480 was an internal layout convention. Capping against it produced a number nobody had ever intended.
3. A feature that had never once appeared for anyone
The game draws a golden line at your personal best, with a sound, a message and a haptic buzz when you cross it. A nice moment.
const recordY = -best * 10; // score = cam / 10
The comment had been true once. After a scoring change it was not, and the correct inverse function — camFuerScore() — already existed two screens higher and was being used correctly in two other places. Just not here.
A 10,000-point record sat at camera 100,000 instead of 17,860. You would have had to score 60,362 points to see your own 10,000-point line flash by. The line, the sound, the message and the vibration were dead on every device that ever ran the game.
Nobody reported it. Of course nobody reported it. How would a player know that a thing they have never seen was supposed to exist?
After the fix, measured headlessly across four stage heights: 12 out of 12 runs trigger it, at 2,005–2,014 points for a 2,000-point record.
A stale comment is worse than no comment. It is the thing you read instead of the code.
4. 2.3 MB of splash screens that were never displayed
The Android launch theme:
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
android:background is a View attribute. It is not a window attribute. As a theme entry it does not set the window background — that is android:windowBackground, or with the AndroidX splash library, the windowSplashScreen* attributes.
So the splash image was never drawn. What users saw on launch was the default light background flashing before the dark game appeared.
Meanwhile 26 splash.png files — 2.31 MB — shipped in every single build, across every density and orientation folder, for a picture that no device ever put on screen.
Removing them, plus one icon that was in the package twice under two names, byte-identical:
App bundle: 6.69 MB → 4.82 MB. 28 % smaller.
For a free game competing for a tap from someone on mobile data, that is not nothing. And the splash now actually shows.
5. My gameplay clips were recorded at 14 frames per second
I wanted short clips for social posts. I used Android's screenrecord, drove the game with a bot, and produced them.
They looked terrible. I told the developer — me — that the problem was probably the choice of world and a bot that played too cautiously. Then I parsed the MP4 containers and counted samples per track:
| Frame rate | |
|---|---|
| My recordings | 14.2 – 15.7 fps |
| A reference clip | 30.0 fps |
The game itself was running at 43 fps. The recorder could not keep up: a debug build without R8, plus the screen capture, plus the input loop, all competing on a virtual device.
A jumping game at 14 fps looks broken. No amount of level selection fixes that.
The fix was to stop recording in real time altogether. The game exposes a manual step in test builds, so:
window.requestAnimationFrame = function () { return 0; };
The game's own loop reschedules itself through rAF. Replace it with a stub and the loop stops after the current frame without touching any state. Then drive it yourself — two 1/60 physics steps per output frame — and read the canvas each time. How long a frame takes to compute becomes irrelevant.
Result: exactly 30.0 fps, full resolution, real gameplay.
The same idea applies to anything you want a clean recording of. If you control the clock, you do not need real time.
6. The bot did not know the world was a ring
The bot that plays for those recordings had three bugs, and they are a decent miniature of how heuristics go wrong.
It ignored screen wrap. The playfield wraps horizontally — walk off the left edge, appear on the right. The bot computed Math.abs(targetX - myX). A platform at x = 460 is 440 pixels away by that measure and 40 pixels away in reality, if you go left. It consistently took the long way and arrived late.
It never asked whether a target was reachable. It scored candidates by a weighted distance. That meant tuning one number against two goals at once: bias it toward height and the bot jumped at platforms it could not reach and died; bias it toward safety and it took the easiest landing every time, often below where it had launched from. Net vertical progress: roughly zero. It survived four minutes and never cleared 3,000 points.
It re-decided every frame. What is reachable changes with every step, so the target flipped mid-flight and the bot walked left, then right, then arrived nowhere.
The rewrite computes reachability from the actual constants — gravity 0.46, horizontal max 7.4, stage 480 wide — solving for when the feet reach a given surface and how far sideways it can get in that time. Only genuinely reachable platforms are candidates. From those it takes the highest one, and it commits to that target until it lands, the platform is gone, or the maths says it no longer works.
Measured over five runs of up to 120 seconds each, same harness:
| Median score | Reached the boss | |
|---|---|---|
| Heuristic version | 1,380 | 1 of 5 |
| Reachability, no commitment | 7,552 | 1 of 5 |
| Reachability + commitment | 22,435 | 4 of 5 |
Note the middle row. Correct physics alone bought almost nothing. The target-flipping fix was worth as much as the entire rewrite. I would not have guessed that, and I did not have to — I measured all three.
7. The release that was approved and never shipped
Not a code bug. Worse, in a way.
The Play Console has a setting called managed publishing. With it on, a release that Google has approved does not go to players. It waits for a click.
My release 1.70 was approved on a Thursday. The track summary read "Active — latest release: 99 (1.70)". Everything looked finished. Players had the build from seventeen days earlier. I only had 1.70 on my own phone because I was in the internal test channel.
There is exactly one line in that console that tells the truth about what people are running, and it is "Last published on". Everything else describes intent.
8. In a browser, the shop gave everything away for free
Found last week, while preparing a playable web build of the same code.
function isNativeApp() {
const cap = window.Capacitor;
if (!cap) return false;
/* ... */
}
In a browser there is no window.Capacitor, so isNativeApp() returns false, so the billing backend falls through to 'demo' — the developer test mode, in which the purchase confirmation grants every paid item for free. Every premium character, the permanent upgrade, 400,000 coins. With a banner reading "test mode" over the top.
That is correct and useful behaviour when the browser is your development environment. It is a self-service counter the moment the same files sit on a public domain.
The web build now sets a flag that pins the backend to unavailable and hides the things that cannot work without Play services. The shop shows locks and a link to the app.
Environment detection that fails open is a decision, whether or not you made it on purpose.
The part nobody writes about
Here is what shipping actually looks like from where I am standing.
Over the last 28 days, straight from the console:
| Times the store showed my app to someone | 1,930 |
| Installs | 3 |
| People who opened the game after installing | 2 |
| Monthly active devices | 9 |
| Ratings, all time | 1 (mine) |
The store listing converts at 16 % over the last 90 days — of the people who actually reach the page, a normal share install it. That part is fine.
The problem is one step earlier, and it is brutal. In the month I looked at the traffic breakdown in detail, not a single search term cleared the console's reporting threshold, and the only traffic source listed was people browsing the store rather than searching it. The countries that showed up at all were Brazil and Italy — for a game whose listing is written in German and English.
Nobody searches for it. Nobody could. They do not know it exists.
I did not write this article to complain about that. I wrote it because the engineering above is genuinely the enjoyable part, and because I think the honest version of "I shipped a thing" is more useful to other people than the version where the numbers are left out.
If there is a lesson beyond the individual bugs, it is this: I was careful, and careful was not the same as correct. Every bug in this list survived review, survived hand testing, and shipped. What found them was not more care. It was a measurement that could come back and say no:
- Count the frames in the file instead of watching the video.
- Print the canvas dimensions instead of reading
applyResolution. - Run the record check 12 times across four stage heights instead of reasoning about the formula.
- Diff
typeof window.Gameagainsttypeof Gamein the live page instead of believing the guard clause.
None of that is sophisticated. All of it is the difference between code that looks right and code that is.
Play it
The game runs in a browser — it is the same code, no install required.
Android version, free, no ads: Google Play
Written by Timo Britz. Mechanical engineer by day. Squishly is a side project.