Cat Survivors: How We Squeezed a Whole Game into 13KB for js13k 2025
Hey everyone! Every year, the js13k competition challenges developers to create a game based on a specific theme in just one month, with the final ZIP archive being no larger than 13,312 bytes
. The theme for 2025 was Black Cat. We eagerly accepted the challenge with a few goals in mind: work smart, not hard; achieve maximum gameplay with minimum overhead; and, most importantly, focus on the game itself!
Table of Contents
- The Outcome: Key Takeaways and Lessons Learned
- The Creation Process: From Idea to Release
- The Idea and the Cat-Base
- Ramping Up the Content
- Is There an Ending? Yes!
- Optimizing Stat Calculations
- Collision Detection
- Knowledge Base: What's Inside the Game?
- The Code: Aggressive Minification
- The Build: From TypeScript to ZIP
- Graphics and Controls
- Audio: ZZFX and Procedural Music
- Conclusion
- Our Previous js13k Entries
The Outcome: Key Takeaways and Lessons Learned
The game was completed and submitted on time. We're happy with the result: an addictive gameplay loop with leveling, weather changes, bosses, and even a proper ending. Here are the main lessons we learned:
- Canvas is fast. Modern browsers work wonders with it. This saved us kilobytes that would have otherwise been spent on a WebGL wrapper.
- TypeScript transformers are powerful. We wrote a custom transformer to rename interface fields, which significantly reduced the code size and saved us from inventing complex naming conventions.
- Controls are simple. Pointer Events covered all our needs for both desktop and mobile.
- Gamepad support should have been implemented from the start. Adding it at the last minute became the main source of bugs and unfinished features.
The Creation Process: From Idea to Release
The Idea and the Cat-Base
The idea came instantly: an auto-shooter in the style of Vampire Survivors, but starring a charming black cat.
To stand out, we ditched pixel art in favor of vector graphics. We started with procedural animation for the cat, which took a bit of time but immediately gave the game a polished and smooth look, similar to mobile hits.
I think everything turned out so well only because we had a real-life prototype — our beloved black cat, Chichi. She personally supervised every stage of development!
Next, we added the basic gameplay: the first enemy — the Fly (chosen by a vote!) — a start screen, and a game-over screen. At that point, we still weren't sure if the game would have an ending.
Ramping Up the Content
From there, the game started growing with content: passive abilities, weapons, power-ups, new enemies, and, of course, balancing the progression. We added screens for selecting upgrades and opening chests. With each new element, the game felt more complete.
The Fear of Bloat. The original Vampire Survivors has weapon evolutions — a powerful upgrade combining a weapon and a passive item. I regretfully disabled this mechanic, fearing it would push the game over the 13KB limit. Every new weapon, description, and unique firing system linearly increased the archive size.
We added some visual customization: certain abilities change the cat's appearance, giving it glasses or lips. We had plans for more of these cosmetic changes so the player's build would be reflected in their look.
The upgrade system isn't perfect either. Initially, the game forces you to choose three new weapons, and then it offers random upgrades. In the future, we want to refine this: for instance, by offering an upgrade for an existing weapon alongside two new ones.
Is There an Ending? Yes!
We're thrilled that we managed to give the game a clear goal and a romantic ending. In the original Vampire Survivors, you can't defeat death, which, honestly, is a bit of a letdown. When a player realizes there's no finale, it can be disappointing. In our game, you get a chance to win!
I'm also glad we handled the scenario where all upgrades are maxed out. In this case, the game automatically starts leveling up the character's base stats. This was an easy detail to overlook.
To make the gameplay more dramatic, we added changing weather, which visually connects the monsters and the environment.
The final touch was tuning the enemy waves and balancing the difficulty. We intentionally designed it so that even if a player loses a few times, they can quickly get the hang of it and eventually enjoy feeling powerful during a 10-minute session.
Optimizing Stat Calculations
Initially, stats were calculated using a classic formula: value = base * (1 + mod) + add
. Upgrades provided percentage-based boosts, which required storing redundant data and complicated the calculations. In the UI, this appeared as a vague "+20%."
I decided to simplify everything. In the new system, weapon and character stats are simply summed up. This allowed us to show the player the exact increase in weapon parameters and, best of all, it reduced the archive size by 100 bytes! An unexpected and impressive result.
Collision Detection
The game features many enemies that push each other around. We deliberately chose the simplest and slowest algorithm — a brute-force "every-to-every" check:
for (let i = 0; i < N; ++i) {
for (let j = i + 1; j < N; ++j) {
// check enemy[i] vs enemy[j]
}
}
Why doesn't it lag? Typically, spatial partitioning (like a grid) is used for such tasks to check for collisions only within a specific area. But to save space, I skipped it. I expected serious performance issues, but modern devices handled it just fine. As a backup, we could have limited the number of enemies through game design or disabled collisions for those off-screen.
To offload the system, I put flying enemies (flies) on a separate layer — they simply fly over the others.
Knowledge Base: What's Inside the Game?
Weapons (11 total):
Void Beam: Fires at the nearest enemy (starter weapon).
Fireballs: A fan of fire in a random direction.
Axes: Fly up and then fall, damaging everything in their path.
Knives: Quickly fly in the direction the cat is moving.
Tail Whip: Swipes horizontally from the current direction.
Sawblades: Orbit the cat, mincing enemies.
Furricane: An aura that deals periodic damage to all enemies inside.
Boomerang: Sharp crosses that fly in a random direction and return.
Lightning: Strikes a random spot, dealing heavy area damage.
Unlucky Drops: The cat leaves damaging puddles behind.
Claws: A powerful strike on the nearest enemy.
Passive Abilities (12 total):
Might: Increases damage.
Armor: Reduces incoming damage.
Flashlight: Increases projectile radius and size.
Bracer: Increases projectile speed.
Empty Tome: Increases firing speed.
Duplicator: Adds +1 projectile (max 2 levels).
Sneakers: Increases movement speed.
Clover: Increases luck (dodge chance).
Hp: Increases maximum health.
Regen: Enables health regeneration (+1/sec).
Dry Wool: Increases item collection radius.
Education: Grants a bonus to experience gain.
Enemies (5 archetypes):
Fly: Several types, including champions.
Mouse: White, gray, and large ones.
Snowman: From small to huge.
Toad: Regular, fat, and champions.
Snake: Black, yellow, and a red champion.
We also had plans for Pigeons and Raccoons.
Champions and bosses drop a Can of Food, which grants 1 or even 3 upgrades. And from a surprise Box, you can get a cross (kills all enemies), a magnet, health, or experience.
The Code: Aggressive Minification
Bytes were our main enemy. We used aggressive minification techniques, sometimes at the expense of readability and performance. But for js13k, it's a worthy trade-off.
Banned Keywords
I forbade myself from using certain JavaScript keywords to save space:
class
→ objects and functionsfunction
→ arrow functions onlyconst
→let
(swapped during the build step)while
,do
→for
loops onlyswitch
→if/else
or the array trick[func0, func1][caseId]()
true
/false
→1
/0
Local Variable Trick. To get rid of
{}
andreturn
, you can declare local variables directly in the arrow function's arguments.
// Before const fn = (x, y) => { let value = calc(x, y); return s(value); }; // After const fn = (x, y, value = calc(x, y)) => s(value);
Math
The **
operator is a great replacement for Math.sqrt(x)
with x**.5
and Math.pow(x, y)
with x**y
. I re-export all necessary functions from Math
so they can be minified. For example, export const {sin, cos, atan2, exp, hypot, random, ...} = Math;
.
And to calculate the length of a vector, I use hypot(x, y)
instead of sqrt(x*x + y*y)
.
The Build: From TypeScript to ZIP
Our build pipeline looked like this:
- Rollup + TypeScript Transformers: Compile TypeScript with custom transformations (e.g., for renaming fields).
- esbuild: The first minification pass.
- Terser: More aggressive minification.
- Roadroller: Pack HTML/CSS into JS and create the final HTML file.
- advzip: Compress into a ZIP archive.
- Efficient Compression Tool: Further compress the ZIP file.
Future Idea: Write a Class Crasher — a transformer that automatically converts simple classes into interfaces and a set of functions. This could yield even more savings.
Graphics and Controls
Canvas, Not WebGL
I'm a big fan of WebGL, but this time I decided to use a pure Canvas context, taking devicePixelRatio
into account. I was worried the game would lag on large screens, but modern devices handled it like a champ. It was a pleasant surprise and saved a lot of space.
Canvas Tips:
- You don't always need to call
closePath()
; you can just start a new path withbeginPath()
.- Don't render emoji with
fillText()
. It causes horrible performance issues. It's better to cache them as images and draw them withdrawImage()
.
Controls
Pointer Events handled mouse and touch events perfectly. The code was very minimal. For the keyboard, I used KeyboardEvent.code
and this little trick to determine direction:
const x =
(isDown["KeyD"] | isDown["ArrowRight"]) -
(isDown["KeyA"] | isDown["ArrowLeft"]);
const y =
(isDown["KeyS"] | isDown["ArrowDown"]) - (isDown["KeyW"] | isDown["ArrowUp"]);
The bitwise OR (|
) is used here so that undefined | 1
becomes 1
instead of NaN
.
My Biggest Mistake: I didn't add gamepad support from the beginning. When I finally got around to it at the end, I realized it required rewriting all the upgrade selection screens. There just wasn't enough time.
Audio: ZZFX and Procedural Music
For me, sound isn't an option; it's half the game's atmosphere. Some people cut audio to fit the size limit, but this year the rules got stricter: no sound, no score in that category. That's why I always find a way to add sound to my games, even if it doesn't sound professional.
For sound effects, I used ZZFX — it's probably the easiest and fastest way to add audio to a game. I tried generating music with chatgpt
and deepseek
, but the results were awful. In the end, I settled for a simple drum machine, wrote a melody generator based on random patterns in A-major, and added a basic arrangement. It turned out a bit repetitive, but it's better than nothing.
Idea for the Future: Use samples generated in ZZFX as instruments for procedural music. This could save a few more bytes.
Conclusion
Here we are, celebrating the end of the competition with all the participants and organizers! This was our fourth js13k game, and it gets more interesting every year. The contest forces you to rethink your tools, optimization strategies, and web standards. A big thanks to all the participants for this event, and a special shoutout to the organizer, Andrzej Mazur!
We plan to continue developing the game: we'll add new characters, progression, locations, and achievements. Follow me on social media to stay updated!
Play Cat Survivors: Link to the game (feedback and ratings are welcome!)
Source Code on GitHub: Link to the repository
What do you think? Have you faced similar challenges? What are your favorite minification tricks? Share your thoughts in my Telegram channel or send me an email!
Our Previous js13k Entries
- 2022 (Death): 13 — a multiplayer shooter with WebGL + WebRTC. Postmortem.
- 2023 (13th Century): A 3D time-manager in C/Wasm. Playable version.
- 2024 (Triskaidekaphobia): FRI3 — a game about the killer from "Friday the 13th" in Zig.