Android · Vanilla JS · Canvas

Jump as high
as you can.

Squishly is an endless jumping game: 20 characters, 16 worlds with their own physics, duels against a rival and guardian bosses every 50,000 points. Built without a game engine and without a single graphics file – every character, every enemy and every background is created at runtime on the canvas. Free.

On the Play Store since 13 August 2026
412 m

Get Squishly for free

Since 13 August 2026 Squishly has been publicly available on the Google Play Store – free and without ads. No more tester detour, no group, no unlocking: install and play.

▶️On the Google Play Store

Android · free · no ads

The public version is here. It is the same one that ran in the test phase before – just without the hurdles in front of it.

Open Squishly in the Play Store

de.timobritz.squishly · Android

🧾What is in it – and what is not

So that it is clear beforehand what it contains.

  • No ads. No banner, no video in between, nothing.
  • No tracking, no analytics tools. I do not run any servers of my own for the game – the game progress is stored on the device.
  • No account needed. The leaderboard and cloud backup via Google Play Games are optional; without signing in, both only work on the device itself, and the game progress is gone when the app is uninstalled.
  • Optional in-app purchases. Additional characters, perks and coin packs, handled via Google Play. Anyone who buys nothing still gets the whole game.

All the details are in the app’s privacy policy.

The whole game in the browser

Below is Squishly itself, the same code as on the Play Store, with nothing to install. Purchases are only available in the app; all characters and worlds from the regular shop can be unlocked here too with coins earned in play.

  • Controls on a computer. Arrow keys or A and D to move, space bar to shoot. Click into the game once first.
  • Controls on a phone. Tap areas at the bottom left and right, fire button on the right. There is more room in its own window.
  • Progress stays in the browser. It is kept on this device only and is separate from the app: progress made here does not appear in the app, and vice versa.
  • Nothing is transmitted. No account, no cookies, no cloud backup, no leaderboard.
Field report · English only

Nothing Threw an Error

What I found when I started measuring a mobile game I had already shipped

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 a requestAnimationFrame loop.
  • No build step. No bundler, no transpiler, no node_modules in 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:

DeviceReal pixelsBufferFactor
720p7207681.07 — 7 % wasted on the weakest hardware
1080p10807680.71 — upscaled 1.41×
1440p14397680.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 recordings14.2 – 15.7 fps
A reference clip30.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 scoreReached the boss
Heuristic version1,3801 of 5
Reachability, no commitment7,5521 of 5
Reachability + commitment22,4354 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 someone1,930
Installs3
People who opened the game after installing2
Monthly active devices9
Ratings, all time1 (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.Game against typeof Game in 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.

Play Squishly in your browser

Android version, free, no ads: Google Play


Written by Timo Britz. Mechanical engineer by day. Squishly is a side project.

20 characters, four stats

Every character plays differently. The radar chart shows the real values from the game – here eight of the 20 characters for comparison; tapping one shows its values. Two axes are reversed so that outwards always means “better”: for gravity and fire rate, the smaller values are the stronger ones in the game.

Doodler. The classic. Balanced in everything – perfect for learning.

16 worlds, 16 sets of rules

A world is not just a different background – each one brings its own physics, its own mix of enemies and different platform spacing. All of them can be played above in the browser.

🌱 Green Meadow

A gentle start with soft grass platforms.

🏜️ Desert Storm

Gusty crosswinds push the character off course.

🌴 Jungle

Dense foliage, narrow paths, more enemies.

❄️ Ice World

Slippery platforms – braking takes practice.

🌋 Volcano

Hot lava slabs, better not to linger too long.

🌆 Cyber City

Racing hologram platforms in neon light.

🚀 Outer Space

Hardly any gravity, but long jumps instead.

🍭 Candy Land

Pastel-coloured – and it rains coins.

🌊 Deep Sea

Floating and light, everything glides leisurely.

⛈️ Thunderstorm

Violent gusts keep sweeping the character along.

⚙️ Gear Factory

Oily metal – everything a little slippery.

🍄 Mushroom Forest

Long jumps between giant mushrooms.

💎 Crystal Cave

Tight jumps in the dark, heavy air.

🎋 Bamboo Forest

Light gravity and a gentle wind.

🌌 Northern Lights

Icy peaks beneath dancing auroras – bitterly slippery.

🌸 Cherry Blossom

Pink showers of blossom – jumping here is light as a feather.

On top of that come ten seasonal worlds that only appear at their time of year, from New Year's Night in January to Winter Wonder in December. Each one comes with a costume to unlock.

No build, no engine, no image files

Squishly is deliberately built with the simplest means available. The result is an app that starts with a double-click on index.html – and still runs in the Play Store.

Everything drawn, nothing loaded

There is not a single PNG file for characters, enemies or backgrounds. Every character is a drawing function made of ellipses, arcs and gradients. This keeps the app tiny, and every character stays sharp at any resolution.

Sound out of nothing

The sound effects and the background music are not files either; they are synthesised at runtime via the Web Audio API – oscillator, envelope, done.

Vanilla JS, no build step

No React, no Webpack, no npm build. Individual JavaScript files, one CSS file, one HTML file. Change, reload, done – which makes debugging pleasantly boring.

Capacitor for Android

An Android package is built from the same code base via Capacitor. The web version remains runnable unchanged – one project, two output paths.

Fixed base resolution

The entire game calculates in 480 × 800 points and is scaled onto the real display. As a result, it plays exactly the same on every device, no matter how big the screen is.

Always makeable

The platform spacing is automatically limited to 70 % of the jump height that can actually be reached – depending on the selected character. This way no world can become unplayable with any character.

Where the project stands

Fully developed, tested, submitted, reviewed and published. Squishly has been publicly available on the Play Store since 13 August 2026.

Done

Game core

Physics, platform types, collisions, camera and rendering on the canvas.

Done

Content

20 characters, 16 worlds, duels against a rival, guardian bosses, missions, achievements and a statistics page. In addition, ten seasonal worlds that appear in their season, a daily run on the same course for everyone, German and English, and an optional cloud backup of the game progress via Google Play Games.

Done

Android package

Capacitor project, app icons, signed release package, store listing with graphics and privacy policy.

Done

Closed test phase

Testing on real devices via a tester group. Google requires this before public release – and it is worth it: what runs on one’s own phone is far from guaranteed to run on someone else’s.

13 August 2026

Public on the Play Store

Google’s review passed, release granted. Squishly is available for everyone to download for free – without a tester group, without unlocking. To the store listing.

Next

Listen and follow up

What comes next will be decided by real players: which world gets abandoned, which character is never chosen, at which point someone stops playing. Without these numbers, every further world would be guesswork.

Contact

Questions about how it is built, the decisions behind it or how it might transfer to another case.

Arithmetic check Arithmetic task, shown as an image

No Google captcha, no cookies, nothing passed to third parties. Prefer e-mail? info@timobritz.de.