Cat Survivors: Как мы упаковали целую игру в 13КБ для js13k 2025
Привет! Каждый год конкурс js13k бросает разработчикам вызов: создать игру на заданную тему всего за 1 месяц, причём так, чтобы ZIP-архив с ней весил не больше 13 312 байт
. В 2025 году темой стал Чёрный Кот. Мы с энтузиазмом приняли вызов и поставили себе несколько целей: работать над игрой с умом, а не до изнеможения, добиться максимум геймплея при минимуме затрат и, конечно, сфокусироваться на главном — самой игре!
Оглавление
- Итоги: ключевые выводы и уроки
- Процесс создания: от идеи до релиза
- Идея и кото-база
- Наращивание контента
- Есть ли концовка? Да!
- Оптимизация вычисления характеристик
- Определение столкновений
- База знаний: что внутри игры?
- Код: агрессивная минификация
- Сборка: от TypeScript до ZIP
- Графика и управление
- Аудио: ZZFX и процедурная музыка
- Заключение
- Наши прошлые работы для js13k
Итоги: ключевые выводы и уроки
Игра была готова и отправлена в срок. Мы довольны результатом: получился затягивающий геймплей с прокачкой, сменой погоды, боссами и даже финалом. Вот главные уроки, которые мы извлекли:
- Canvas — это быстро. Современные браузеры творят с ним чудеса. Это позволило нам сэкономить килобайты, которые ушли бы на обвязку для WebGL.
- Трансформеры TypeScript — мощь. Мы написали кастомный трансформер для переименования полей интерфейсов, что ощутимо сократило размер кода и избавило от необходимости придумывать сложные правила именования.
- Управление — это просто. Pointer Events полностью закрыл наши потребности как для десктопов, так и для мобильных устройств.
- Геймпад нужно было делать сразу. Его реализация в последний момент стала главным источником багов и недоработок.
Процесс создания: от идеи до релиза
Идея и кото-база
Идея родилась мгновенно: авто-шутер в духе Vampire Survivors, но с очаровательным чёрным котом в главной роли.
Чтобы выделиться, мы отказались от пиксель-арта в пользу векторной графики. Начали с процедурной анимации кота — это заняло немного времени, но сразу задало игре качественный и плавный вид, как в мобильных хитах.
Мне кажется, всё получилось так хорошо только потому, что у нас есть реальный прототип — наша любимая чёрная кошка Чичи. Она лично контролировала каждый этап разработки!
Следом добавили базовый геймплей: первого врага — Муху (выбрали голосованием!), стартовый экран и экран поражения. На тот момент мы ещё не знали, будет ли в игре концовка.
Наращивание контента
Дальше игра начала обрастать контентом: пассивные умения, оружие, бонусы, новые враги и, конечно, баланс прокачки. Появились экраны выбора улучшений и открытия сундуков. С каждым новым элементом игра становилась всё более целостной.
Страх перед размером. В оригинальной Vampire Survivors есть эволюция оружия — мощный апгрейд из комбинации оружия и пассивки. Я с сожалением отключил эту механику, опасаясь, что она раздует игру сверх лимита в 13 КБ. Каждое новое оружие, описание и уникальная система стрельбы линейно увеличивали размер архива.
Мы добавили немного визуальной кастомизации: некоторые умения меняют внешний вид кота, добавляя ему очки или губы. В планах было сделать ещё больше подобных изменений, чтобы билд игрока отражался на его виде.
Система улучшений тоже не идеальна. Вначале игра заставляет выбрать три новых вида оружия, а затем предлагает случайные апгрейды. В будущем мы хотим это доработать: например, давать на выбор улучшение одного из имеющихся оружий и два новых.
Есть ли концовка? Да!
Мы очень рады, что смогли добавить в игру понятную цель и романтическую концовку. В оригинальной Vampire Survivors победить смерть невозможно, и это, честно говоря, немного расстраивает. Когда игрок понимает, что финала нет, он может испытать разочарование. У нас же есть шанс на победу!
Я также рад, что мы проработали ситуацию, когда все улучшения уже получены. В этом случае игра автоматически начинает прокачивать базовые характеристики персонажа. Этот момент было легко упустить.
Чтобы сделать геймплей более драматичным, мы добавили смену погоды, которая визуально связывает монстров и окружение.
Финальным штрихом стала настройка волн врагов и балансировка сложности. Мы специально сделали так, чтобы игрок, даже проиграв несколько раз, мог быстро освоиться и в итоге насладиться своей мощью в течение 10-минутной сессии.
Оптимизация вычисления характеристик
Изначально характеристики рассчитывались по классической формуле: value = base * (1 + mod) + add
. Улучшения давали прирост в процентах, что требовало хранения избыточных данных и усложняло расчёты. В интерфейсе это выглядело как непонятное "+20%".
Я решил всё упростить. В новой схеме характеристики оружия и персонажа просто суммируются. Это позволило показывать игроку точный прирост к параметрам оружия и, что самое приятное, уменьшило размер архива на 100 байт! Неожиданный и впечатляющий результат.
Определение столкновений
В игре много врагов, которые толкают друг друга. Мы намеренно выбрали простейший и медленный алгоритм — перебор «каждый с каждым»:
for (let i = 0; i < N; ++i) {
for (let j = i + 1; j < N; ++j) {
// check enemy[i] vs enemy[j]
}
}
Почему не тормозит? Обычно для таких задач используют пространственное разбиение (например, сетку), чтобы проверять столкновения только в пределах одной области. Но ради экономии места я от него отказался. Я ожидал серьёзных тормозов, но современные устройства справились. В качестве подстраховки можно было бы ограничить число врагов геймдизайном или отключать коллизии для тех, кого игрок не видит.
Чтобы разгрузить систему, я сделал летающих врагов (мух) на отдельном слое — они просто пролетают над остальными.
База знаний: что внутри игры?
Оружие (11 штук):
Опустошающий луч: Стреляет в ближайшего врага (стартовое).
Огненные шары: Веер огня в случайном направлении.
Топоры: Летят вверх, а затем падают, нанося урон всему на своём пути.
Ножи: Быстро летят в сторону движения кота.
Хвост: Бьёт по горизонтали от текущего направления.
Пилы: Вращаются вокруг кота, превращая врагов в фарш.
Ураган Меха: Аура, которая наносит периодический урон всем врагам внутри.
Бумеранг: Острые кресты, которые летят в случайном направлении и возвращаются.
Молнии: Бьют в случайное место, нанося урон по большой площади.
След Неудачи: Кот оставляет за собой лужи, которые наносят урон.
Когти: Мощный удар по ближайшему врагу.
Пассивные умения (12 штук):
Мощь: Увеличивает урон.
Броня: Снижает получаемый урон.
Фонарик: Увеличивает радиус и размер снарядов.
Наруч: Увеличивает скорость снарядов.
Пустая Книга: Увеличивает скорость стрельбы.
Дубликатор: Добавляет +1 снаряд (максимум 2 уровня).
Кеды: Увеличивает скорость передвижения.
Клевер: Повышает удачу (шанс увернуться).
Здоровье: Увеличивает максимальное здоровье.
Помидор: Включает регенерацию здоровья (+1/сек).
Магнит: Увеличивает радиус сбора предметов.
Образование: Даёт бонус к опыту.
Враги (5 архетипов):
Муха: Несколько видов, включая чемпионов.
Мышь: Белые, серые и большие.
Снеговик: От маленьких до огромных.
Жаба: Обычные, толстые и чемпионы.
Змея: Чёрные, жёлтые и красный чемпион.
В планах были ещё Голуби и Еноты.
Из чемпионов и боссов выпадает Консерва, которая даёт 1 или сразу 3 улучшения. А из Коробки с сюрпризом можно получить крест (убить всех), магнит, здоровье или опыт.
Код: агрессивная минификация
Наш главный враг — байты. Мы использовали агрессивные методы минификации, иногда в ущерб читаемости и производительности. Но для js13k это оправдано.
Ключевые слова под запретом
Я запретил себе использовать некоторые ключевые слова JavaScript, чтобы сэкономить место:
class
→ объекты и функцииfunction
→ только стрелочные функцииconst
→let
(меняется на этапе сборки)while
,do
→ толькоfor
циклыswitch
→if/else
или трюк с массивом[func0, func1][caseId]()
true
/false
→1
/0
Трюк с локальными переменными. Чтобы избавиться от
{}
иreturn
, можно объявлять локальные переменные прямо в аргументах стрелочной функции.
// Было const fn = (x, y) => { let value = calc(x, y); return s(value); }; // Стало const fn = (x, y, value = calc(x, y)) => s(value);
Математика
Оператор **
отлично заменяет Math.sqrt(x)
на x**.5
и Math.pow(x, y)
на x**y
. Все нужные функции из Math
я ре-экспортирую, чтобы их можно было минифицировать. Например, export const {sin, cos, atan2, exp, hypot, random, ...} = Math;
.
А для вычисления длины вектора я использую hypot(x, y)
вместо sqrt(x*x + y*y)
.
Сборка: от TypeScript до ZIP
Наша схема сборки выглядела так:
- Rollup + TypeScript Transformers: Компиляция TypeScript с кастомными трансформациями (например, для переименования полей).
- esbuild: Первый проход минификации.
- Terser: Более агрессивная минификация.
- Roadroller: Упаковка HTML/CSS в JS и создание финального HTML-файла.
- advzip: Сжатие в ZIP-архив.
- Efficient Compression Tool: Дополнительное сжатие ZIP-файла.
Будущая идея: Написать Class Crasher — трансформер, который автоматически превращает простые классы в интерфейсы и набор функций. Это может дать ещё большую экономию.
Графика и управление
Canvas, а не WebGL
Я фанат WebGL, но в этот раз решил использовать чистый Canvas с учётом devicePixelRatio
. Я опасался, что на больших экранах игра будет тормозить, но современные устройства справились на ура. Это было приятным сюрпризом и сэкономило много места.
Советы по Canvas:
- Не обязательно вызывать
closePath()
, можно просто начинать новый контур сbeginPath()
.- Не рисуйте emoji через
fillText()
. Это вызывает дикие тормоза. Лучше закешируйте их как изображения и рисуйте черезdrawImage()
.
Управление
Pointer Events отлично справился с мышью и тач-событиями. Кода получилось очень мало. Для клавиатуры я использовал KeyboardEvent.code
и вот такой трюк для определения направления:
const x =
(isDown["KeyD"] | isDown["ArrowRight"]) -
(isDown["KeyA"] | isDown["ArrowLeft"]);
const y =
(isDown["KeyS"] | isDown["ArrowDown"]) - (isDown["KeyW"] | isDown["ArrowUp"]);
Побитовое ИЛИ (|
) здесь нужно, чтобы undefined | 1
превращалось в 1
, а не в NaN
.
Главная ошибка: Я не добавил поддержку геймпада с самого начала. Когда я взялся за это в конце, оказалось, что нужно переписывать все экраны выбора улучшений. Времени уже не хватило.
Аудио: ZZFX и процедурная музыка
Для меня звук — это не опция, а половина атмосферы игры. Кто-то вырезает аудио, чтобы влезть в лимит, но в этом году правила стали строже: нет звука — нет и оценки в соответствующей категории. Поэтому я всегда ищу способ добавить звук в игру, даже если это не звучит профессионально.
Для звуковых эффектов я использовал ZZFX — это, пожалуй, самый простой и быстрый способ добавить звук в игру. Музыку я пытался генерировать с помощью chatgpt
и deepseek
, но результаты были ужасны. В итоге я оставил простую ритм-машинку, написал генератор мелодии на основе случайных паттернов в Ля-мажоре и добавил простую аранжировку. Получилось немного назойливо, но лучше, чем ничего.
Идея на будущее: Использовать семплы, сгенерированные в ZZFX, в качестве инструментов для процедурной музыки. Это может сэкономить ещё немного байт.
Заключение
Это была наша четвёртая игра на js13k, и с каждым годом становится только интереснее. Конкурс заставляет по-новому взглянуть на инструменты, оптимизацию и веб-стандарты.
Поиграть в Cat Survivors: Ссылка на игру (буду рад отзывам и оценкам!)
Исходный код на GitHub: Ссылка на репозиторий
Мы планируем развивать игру дальше: добавим новых персонажей, прокачку, локации и достижения. Подписывайтесь на мои соцсети, чтобы следить за новостями!
Поздравляем всех с завершением конкурса! Мы очень рады, что существует этот конкурс, есть участники, которые как и мы, каждый год принимают в нём участие! Хотим выразить благодарность организаторам (в особенности Andrzej Mazur), которые дают нам возможность ставить себе вызов каждый год и собираться для обсуждения получившихся игр!
А что думаете вы? Сталкивались с подобными задачами? Какие у вас любимые трюки для минификации? Делитесь мыслями в моём Telegram-канале или пишите на почту!
Наши прошлые работы для js13k
- 2022 (Смерть): 13 — многопользовательский шутер на WebGL + WebRTC. Постмортем.
- 2023 (XIII век): 3D-тайм-менеджер на C/Wasm. Играбельная версия.
- 2024 (Triskaidekaphobia): FRI3 — игра про маньяка из «Пятницы 13-е» на Zig.