Игра «13»: Разработка первой версии для js13k 2022

Игра 13 обложка
Так выглядела первая обложка игры для конкурса

Оглавление


Впервые решил принять участие в конкурсе js13k - это где вам нужно создать игру для браузера, чтобы финальный архив игры занимал не более 13 килобайт, в архив должны входить все ресурсы и код игры. В моей игре в архив так же включается код Node.js сервера!

В итоге получилась игра «13» - это быстрый и кровавый 2D многопользовательский top-down шутер в режиме реального времени. Запись игры на js13k2022

Цели проекта

Личную цель я поставил себе ещё до оглашения темы конкурса. Я хотел реализовать многопользовательскую игру в режиме реального времени со схемой peer-to-peer соединения. Я никогда ещё не делал таких игр, но давно интересуюсь их реализацией.

Тема

Охотники в масках из фильмов и сериалов в жанре триллер и ужасы

Тема была оглашена - Смерть. Несколько дней обдумывал что-нибудь оригинальное в плане геймплея, что могло бы лучше раскрыть тему. В итоге решил - чем проще, тем лучше, и взял фокус на идею "Bullet-Hell" (это когда на экране у вас огромное количество движущихся пуль). Я понял, что если я использую эмодзи для лиц персонажей, то это будет напоминать макси психопатов-убийц (например, серия фильмов Судная Ночь).

Конечно для тактики многопользовательской игры было бы круто сделать городскую или пригородную локацию, но учитывая что в количестве ресурсов, кода и времени я сильно ограничен - я определился, что всё будет происходить на уютной лесной полянке, что может вызвать правильные ассоциации с триллерами, где убийцы выслеживают и гоняются за героями по ночному лесу. По поводу освещения я был настроен скептически, но к концу у меня хватило воли реализовать простейший "туман войны", что улучшило картинку в целом.

Выбор похожих игр и стилистики

Вдохновлялся Nuclear Throne для механики стрельбы, и Hotline Miami с точки зрения стиля (небрежный пиксель в высоком разрешении без фильтрации), а так же струи крови и пятна на карте.

Я люблю платформеры, но принимая во внимание, что интереснее будет писать в другом жанре - я остановился на жанре top-down shooter.

Думал если повезёт - то дам чёртово название "13" и сделаю 13 персонажей и 13 видов оружия, но с оружием немного не успел.

Сеть

Использую WebRTC для быстрого обмена данными без подтверждения и без сохранения порядка отправки.

Архитектурно я использую схему Lock-step - игрок ждёт пока от всех игроков придут сообщения о вводе, и только потом мы можем проиграть этот тик. Полный детерминизм игровой логики. Для предотвращение мелких лагов я использовал схему с перекрытием отправляемых команд. Игрок назначает командам ввода время выполнения немного забегая вперёд (delayed input), несмотря на задержку отзыва на клиенте в несколько кадров мы имеем возможность гладко накапливать (буферизировать) команды на клиентах для предотвращение постоянного попадания в холодное состояние (когда не хватает ввода для симуляции тика).

В качестве Signalling server я не использовал web-sockets, а реализовал более простой обмен данными по HTTP через fetch + EventSource. Сервер держит текущие подключения и даёт клиентам возможность обмениваться простыми сообщениями друг с другом, один клиент отправляет команду адресованную другому клиенту на сервер, а сервер через серверное событие отправляет это сообщение получателю. На мой взгляд получилось наиболее компактно по сравнению с web-sockets.

Я умышленно не делал сложную обработку проблем с подключением, дропом игроков, отслеживанием наибольшего лага, процедуру кика лагающего игрока и тому подобное в целях предотвратить увеличение размера кода. В данном проекте я сфокусировался на удачном сценарии подключения.

Детерминизм

Логика на клиенте должна быть полностью детерминирована, это значит что симуляция с одними входными параметрами (включая состояние игры) должно давать только единственный и полностью определенный результат. Состояние генераторов случайных чисел, последовательность списка игровых сущностей, позиции и скорости - всё должно точно совпадать на каждом клиенте. Я добавил несколько дополнительных проверок для режима отладки, чтобы проверять корректность состояния игры между подключенными игроками. Для того чтобы было проще находить ошибки связанные с состоянием игры - я создал HTML страницу с четырьмя <iframe>, которые содержат по одному виртуальному клиенту.

Так же стоит добавить простейшую симуляцию Задержки + Вероятность потери пакета, чтобы проверять стабильность подключения и находить проблемы связанные с таймингом (когда нужно догнать или подождать, чтобы в конечном итоге все клиенты попали в определенный разрешенный диапазон менее 8 тиков симуляции).

Графика

Для отрисовки я использовал WebGL instancing. За основу взял js13k-2d и постепенно перемешивал код. Удалил всё что связано с alpha-discard и z-buffer, добавил color-offset для эффекта попадания. Альфа компонент color-offset использую в качестве коэффициента для интерполяции режимов смешивания (blend modes) между обычным (normal) и аддитивным (additive blending). Этот приём основан на классической технике PMA (PreMultiplied Alpha) с использованием цвета умноженного на альфа-компонент (прозрачность).

Для более эффективного сжатия все необходимые GL константы перенесены в const enum. Финальный шейдер минифицировал с помощью GLSLX.

Код сжатого шейдера

Все игровые спрайты я сразу рисую в атлас, простые квадратики, кружки, элементы виртуального джойстика. Все emoji я рисую во временный <canvas> и потом уменьшаю в ячейку атласа, сделано это для достижения большей пикселизации emoji. Почти каждый спрайт в атласе обрезается альфа, чтобы достичь жестких пиксельных границ объекта.

Подбор параметров для генерации спрайтов в реальном времени

Изначально лужи крови и гильзы просто лежали на карте и копились, что сильно замедляло отрисовку кадра, поэтому в какой-то момент я превратил фон карты в FBO (Frame Buffer Object) и просто начал рисовать накопленные частицы в эту текстуру.

В новой версии игры эти следы постепенно исчезают, это сделано через подмешивание оригинальной текстуры карты в рабочую со смешиванием через прозрачность.

Освещение изначально думал сделать через сетку, но быстро понял что кода будет много, второй шейдер я добавлять не хотел, в итоге решил попробовать рисовать источники света в текстуру. Для источника света в отдельную текстуру сгенерировал мягкий шар с градиентом альфа-канала. Включил режим смешивания который вычитает альфу у приёмника.

gl.blendFunc(GL.ZERO, GL.ONE_MINUS_SRC_ALPHA);

Дальше с апскейлом и выключенной фильтрацией рисую эту текстуру поверх всего. Получился эффект "тумана войны" и визуально видны квадратики, как будто бы я это делаю через тайловую сетку.

Пиксели на тумане войны
Fog of War

Аудио - музыка и звуки

Изначально я взял то, что всегда было перед глазами - SFXR для эффектов и SoundBox для музыки. Перенеся по-быстрому код в чистый TypeScript и загрузив стандартные звуки и музыкальный трек я понял, что меня уже неплохо ограничило по размеру. А мне еще нужно было сделать симуляцию панорамного аудио: балансировать звук между левым и правым каналом, а так же вычислять громкость в зависимости от расстояния от источника звука.

И сразу я кинул силы на оптимизацию размера кода и эффективности хранения данных для звуков. Сделал пред-обработку параметров для SFXR, чтобы убрать некоторые вычисления в генерации звуков, что дало действительно хороший результат.

В итоге всё равно музыка и звуки мешали добавлять новый функционал и я нашёл ИДЕАЛЬНУЮ библиотеку ZZFX и ZZFXM, смысл музыкального проигрывателя заключается в том, что используются ZZFX семплы для инструментов, что убирало дублирование кода для генерации звука для эффектов и для музыкальных дорожек. Долгое время я тестировал с музыкальным треком Depp из примеров ZZF

К завершению проекта я планировал что у меня в запасе есть около 200 байт, которые я заберу за счет того что напишу короткий мощный трек сам, в трекере, или по крайней мере ВЫРЕЖУ паттерны из используемого трека. В итоге я тыкался в трекере, никакие музыкальные идеи я не смог реализовать. Я понимал что музыкальный плеер и сам трек будет занимать около 500 байт. Места мне уже не хватало...

Это была паника и я решил попробовать другой подход - процедурную генерацию музыки используя в те же ZZFX семплы. Взял минорную гамму и начал накидывать цикл проигрывания баса, и как всегда помощь и вдохновение пришло от любимой супруги, в какой-то момент один из запусков она узнала в бас линии трек Justice, после чего всё встало на свои места с ритмическим рисунком, я добавил сольники, бас-бочку, хеты, добавил вариативности с задержкой, добавил эквалайзер на басс-линию и автоматизировал её параметры случайным образом. В результате это заняло меньше 100 строчек кода на TypeScript!

Результатом очень доволен. Если бы было больше времени, то возможно я бы добавил отдельно 2 трека, один для заставки, а второй уже для игры.

Дальше в последний момент я добавил простейший синтез речи, который приветствует и комментирует процесс игры. Это тоже добавило разнообразия.

Сжатие Кода

1. Сборка TypeScript

Собираю TypeScript клиента и сервера напрямую используя esbuild. Здесь в сборке происходит минификация, которая будет изменять const на let (чего почему-то terser не делает). Так же подставляются переменные окружения (process.env.NODE_ENV), удаляются все console.* вызовы. Дважды проверьте tsconfig.json: используйте esnext, выключите эмуляцию const enum, не включайте никакие tslib, не используйте декораторы или какие-либо другие генерирующие дополнительный код фишки.

На этом шаге я так же минифицирую index.html используя html-minifier.

Минифицированный код страницы

Размер: 80933 байт

2. Слияние имён свойств для непересекающихся типов

TypeScript мог бы добавить какие-то строгие правила о свойствах в типах. Давайте предположим, что любой объект будет реализован только одним конкретным типом, и не будет использовать свойства из других типов. Если мы дадим одинаковые имена этим свойствам для всех типом, то terser будет выбирать для них одинаковые сокращенные идентификаторы, что уменьшит общий словарь для минификации свойств во всём проекте. К моему удивлению в 2022 году это свойство не используется ни в одном известном мне TypeScript сборщике. Да и вообще почему-то нету идеального минификатора, который бы работал именно на уровне TypeScript (используя всю информацию о типах!)

merge-props

Например, r_ и startX_ переименованы в $0_ идентификатор. pointer_ и downEvent_ переименованы в $5_.

Размер: 78481 байт

3. terser: cжатие и минификация

Главная опция которая даёт хороший результат это --mangle-props. Я использовал стандартное правило для определения свойств, которые можно переименовывать /._$/. Так же нужно включить режим модуля, top-level, ECMAScript версию выставить в 2020, пометить чистые функции, включить чистые геттеры, небезопасные стрелочные функции (unsafe arrow-functions), заменить булевые литералы true и false в целые числа 0 и 1, и так далее...

Размер: 32968 байт

4. Хеширование идентификаторов Web API

Мы не можем переименовать Web API методы и функции, но мы можем создать для них ссылки/ярлыки с более короткими названиями на старте игры. Как это работает?

Куда подевались все Web API свойства?

Размер: 31457 байт

5. Крашинг JavaScript кода

Я использовал Roadroller чтобы закрашить JavaScript код и это экономит примерно 1 кб в финальном ZIP архиве. Я не могу использовать опции крашинга чтобы загрязнить глобальное окружение (pollute global scope), потому что оно начинает конфликтовать с глобальными идентификаторами объявленными в HTML странице (c для элемента canvas, b для элемента body). Размер может отливаться потому что roadroller подбирает параметры и выбирает лучшие, поэтому я использовал -O2 опцию чтобы уменьшить различия в размере между сборками (лучше понимать изменения размера при внесении изменений в исходный код).

Весь c.js теперь можно уместить на 1 экране!

Весь c.js теперь можно уместить на 1 экране!

Размер: 17794 байт

6. ZIP архив

Я использовал AdvanceCOMP команду advzip для сжания клиента и сервера с опциями --shrink-insane --iter=1000. Так же используются короткие имена файлов c.js, s.js чтобы сэкономить пару байт в ZIP таблице файлов.

zip

Размер: 13229 байт

Приёмы стилем кодирования

Современный EcmaScript и его фишки. Использовал только типы без лишней вложенности, глобальные переменные, простые функции.

Заменяем switch блоки

Клонирование через деконструкцию

Аргументы стрелочной функции вместо объявления локальной переменной

Эту технику я продемонстрирую на функции которая генерирует текстуру фона игровой карты. Обратите внимание, я не вызываю closePath() - это так же сделано умышлено для уменьшения количества кода. Вот изначальный вид функции и как она сжимается в игре

export const generateMapBackground = (): void => {
  const map = createCanvas(BOUNDS_SIZE);
  const detailsColor = ["#080", "#572"];
  map.fillStyle = "#060";
  map.fillRect(0, 0, BOUNDS_SIZE, BOUNDS_SIZE);
  const sc = 4;
  map.scale(1, 1 / sc);
  for (let i = 0; i < BOUNDS_SIZE; ++i) {
    map.fillStyle = detailsColor[rand(2)];
    map.beginPath();
    map.arc(rand(BOUNDS_SIZE), rand(BOUNDS_SIZE) * sc, 1 + rand(16), 0, PI2);
    map.fill();
    map.fillRect(rand(BOUNDS_SIZE), rand(BOUNDS_SIZE) * sc, 1, 4 + rand(8));
  }
  uploadTexture(mapTexture, map.canvas);
};

() => {
  let _ = r_(1024),
    $ = ["#080", "#572"];
  (_.p = "#060"), _.de(0, 0, 1024, 1024), _.ee(1, 1 / 4);
  for (let e = 0; e < 1024; ++e)
    (_.p = $[B(2)]),
      _.yo(),
      _.arc(B(1024), 4 * B(1024), 1 + B(16), 0, v),
      _.fill(),
      _.de(B(1024), 4 * B(1024), 1, 4 + B(8));
  U(f_, _.c$);
};

// BUILD: 80946
// MANGLE: 78494
// TERSER: 32999
// REHASH: 31488
// ROADROLL: 17783
// LZMA: 13217

Дальше переносим счеткик цикла и его инициализацию в аргумент функции, так мы убираем лишний let. Дальше так же перемещаем инициализацию canvas в аргументы. Значение 1024 так же вписывается при каждом использовании BOUNDS_SIZE. Создадим для неё так же аргумент, чтобы в коде оно превратилось в 1-символьный идентификатор. Так же нам не нужны все 1024 итераций, поэтому заменим <= на < - экономия в 1 символ! Terser не инлайнит наш массив с цветами, поэтому делаем это руками. В результате мы сохранили 31 байт в минифицированном коде только для этой функции и это дало нам 10 байт экономии в финальном ZIP архиве!

export const generateMapBackground = (
  _i: number = 0,
  _size: number = BOUNDS_SIZE,
  _map = createCanvas(_size),
): void => {
  _map.fillStyle = "#060";
  _map.fillRect(0, 0, _size, _size);
  const sc = 4;
  _map.scale(1, 1 / sc);
  for (; _i++ < _size; ) {
    _map.fillStyle = ["#080", "#572"][rand(2)];
    _map.beginPath();
    _map.arc(rand(_size), rand(_size) * sc, 1 + rand(16), 0, PI2);
    _map.fill();
    _map.fillRect(rand(_size), rand(_size) * sc, 1, 4 + rand(8));
  }
  uploadTexture(mapTexture, _map.canvas);
};

(_ = 0, $ = 1024, e = r_($)) => {
  for (e.p = "#060", e.de(0, 0, $, $), e.ee(1, 1 / 4); _++ < $; )
    (e.p = ["#080", "#572"][B(2)]),
      e.yo(),
      e.arc(B($), 4 * B($), 1 + B(16), 0, v),
      e.fill(),
      e.de(B($), 4 * B($), 1, 4 + B(8));
  U(f_, e.c$);
};

// BUILD: 80933 (-13)
// MANGLE: 78481 (-13)
// TERSER: 32968 (-31)
// REHASH: 31457 (-31)
// ROADROLL: 17773 (-10)
// LZMA: 13207 (-10)

С каждой функции по 10 байт - вот и вышел килобайт!

Производительность

Чтобы уместить игру в 13kb, я выполняю множество создания массивов, клонирую объекты, делаю фильтрацию (что создаёт новые копии массивов). Несмотря на то, что это должно вызвать сильную нагрузку на сборщик мусора - игра работает на современных мобильных устройствах в 60 FPS. В последствии, я думаю, это можно будет легко оптимизировать добавив функции, которые делают все эти вещи без создания копии объекта/массива, можно будет добавить пулы объектов и использовать другие популярные грязные техники оптимизации JavaScript приложений.

Что действительно необходимо было оптимизировать в первую очередь - это найти только видимые объекты и только потом отсортировать их по глубине для отрисовки.

Так же к сожалению проверку столкновений нужно было оптимизировать ещё в начале работы, вместо полного перебора по архитипу объекта, необходимо было сразу использовать разбиение по сетке, чем максимально снизить количество пар для поиска столкновений. Мы выполняем симуляцию один и более тиков за кадр - это очень сильно оптимизировало игру уже после завершения конкурса. Оказалось, что вариант с loose-grid в размер 13kb всё таки без проблем помещается!

Простая сетка для подбора пар проверки пересечений

И всё же все приёмы сжатия кода не стоит применять там, где код чувствителен к производительности. Когда какой-то код выполняется часто, то некоторые трюки с замыканиями или изменениями объекта просто будут приводить к постоянной перекомпиляции кода V8 движком и в итоге скорее всего будут выполняться в de-opt режиме, внимательно следите за структурами данных и изменениями типов при анализе таких функций!

Чтобы я делал в следующий раз

  1. Использовал бы js13k Game Server для категории Server. Я просто не читал правила в самом начале конкурса, т. к. не думал, что есть отдельная категория для многопользовательских игр с сервером. Однако мой сервер получился настолько простым и миниатюрным, что я не видел смысла переводить всё на server sandbox за 12 часов до отправки проекта. Еще один момент по которому я переживал, что игра не будет работать на js13kgames.com, что не получится запостить игру со ссылки на свой heroku app
  2. Я бы сразу делал fixed-timestep игровой цикл (удаляем код умножения на дельту времени кадра). Я бы сразу начинал дизайн ядра и физики в целочисленных координатах (просто потому что это было бы более удобным для упаковки данных и нормализации игрового состояния).
  3. Я рекомендую с самого начала проекта интегрировать вопрос "Загружать Emoji?", т.к. когда у меня всё было готово, я уже в стрессе боролся с размером, т. к. перевес оказался на 3 байта. В такой момент не хочется заниматься оптимизациями в тех местах, которые уже протестированы, поэтому пришлось пересмотреть код инициализации и добавить дополнительное состояние, чтобы убрать одну условную ветку.
  4. Уже после отправки проекта я поменял систему проверки столкновений с обычного n^2 перебора на хеширование пространства обычной нестрогой сеткой, что оказалось очень серьёзной оптимизацией для симуляции дополнительных тиков в режиме предсказания (когда нам недостаточно событий от других игроков). В оригинальном проекте к сожалению сильное забегание вперёд может приводить к низкому FPS.
  5. Писал бы черновики для этого отчёта по мере реализации проекта.

Заключение

Участие в данном конкурсе было захватывающим и достаточно сложным. Первые две недели я не ожидал, что у меня вообще хоть что-то получится сделать. Разработка игрового мультиплеера была самой сложной задачей в проекте игры 13, но вместе с тем я получил исключительный опыт разработки игр!

Хотелки на момент конкурса