
Как уместить полноценный Shoot ’em up в 3 КБ, когда иконка на вашем рабочем столе весит в несколько раз больше. Однако в эпоху терабайтных дистрибутивов всё еще жива магия сайз-кодинга — искусства втиснуть максимум смысла в минимум байт.
Этот проект родился в рамках одоноо геймджема, где по правилам: «Вначале нужно придумать свой язык программирования, а потом написать на нём игру». У меня было 7 дней, чтобы пройти путь от проектирования архитектуры байт-кода до финальной оптимизации исполняемого файла под Windows.
Стратегия и инструменты
Чтобы уложиться в лимиты и при этом не сойти с ума, я набросал план:
Язык и компилятор: Пишем на F# потому что сопоставление с образцом и работа с деревьями выражений в F# превращают написание компилятора в легкую прогулку.
Runtime: Неплозая обертка на C++, которая инициализирует окно и интерпретирует байт-код.
Графика: Никаких текстур и мешей. Весь рендеринг — это один полноэкранный пиксельный шейдер на GLSL.
Сжатие: Использование Crinkler — это специализированный линкер-компрессор для Windows, который творит чудеса с маленькими бинарниками.
Назвал я проект shmup8. Изначально я рассчитывал уложиться в 8 КБ, но результат оказался куда скромнее и лучше).
Live-coding: когда скорость разработки решает всё
В период 7-дневного джема особо нет времени на бесконечные пересборки C++ проекта. Я хотел «мгновенной отдачи». В итоге я настроил окружение так, что вся логика игры и визуал менялись на лету.
Схема работала так:
Я редактирую исходный код на своем языке в IDE.
Кастомный компилятор следит за файлом, тут же превращает его в байт-код и сбрасывает в бинарный файл.
C++ приложение видит изменение файла и перезагружает байт-код прямо во время выполнения кадра.
То же самое происходило и с GLSL-шейдером.
Это позволило мне настраивать тайминги врагов, скорость пуль и спецэффекты, не закрывая окно с игрой. В творческом процессе, где нужно «нащупать» геймплей, такой стиль работы - это 90% успеха.
Архитектура VM и проектирование байт-кода
Когда бюджет копеечный — считанные килобайты, классические подходы к проектированию виртуальных машин сразу отелтают. Мне нужно было нечто среднее между калькулятором и ассемблером, где интерпретатор занимал бы минимум места в бинарнике.
Типовая система: «Только Float, только хардкор»
Первое решение для экстремального упрощения — отказ от типов данных. В моей VM существует только один тип: float32.
Нужно сохранить целое число? Пишем его как 1.0.
Нужен булевый флаг? 1.0 — истина, 0.0 — ложь.
Как индексировать массивы? Берем float, а внутри интерпретатора просто кастуем его в int.
Все данные хранились в глобальных массивах. Это убиарет необходимости реализовывать кучу, стек вызовов или сложные механизмы управления памятью.
Байт-код: Две инструкции, чтобы править миром
Чтобы код интерпретатора оставался крошечным, я свел систему команд всего к двум базовым операциям:
Записать результат математического выражения в ячейку массива.
Перейти к другому адресу в байт-коде
Математические выражения при этом могут быть довольно сложными: они поддерживают вложенность, ссылки на другие ячейки массивов и встроенные функции
Хитрый трюк с упаковкой констант
Хранение каждого числа как 4-байтового float32 в байт-коде — это непозволительная роскошь. Я применил демосценерский подход:
Если число целое и лежит в диапазоне от 0 до 255, оно записывается в 1 байт.
Если число дробное или большое, оно пакуется в 2 байта с потерей точности, которой для игровой логики более чем достаточно.
Это позволило сократить объем байт-кода почти вдвое.
Дизайн языка: Сахар для синтаксиса
Писать на чистом байт-коде — удовольствие сомнительное, поэтому мой компилятор на F# поддерживает C-подобный синтаксис. Я реализовал стандартные конструкции: if-else, циклы while и for.
Но так как переменных в привычном понимании нет, я добавил инлайнинг. Это критически важная фича для читаемости:
inline ship_x = state[10]; inline ship_y = state[11]; inline score = state[5]; if (ship_x > 100.0) { score += 1.0; }
Компилятор сам заменяет ship_x на прямой доступ к state[10]. Для программиста это выглядит как обычный код, а для VM — как быстрая работа с памятью.
Трюк с удалением из массива за O(1)
В игре много объектов: пули, враги, частицы. Как эффективно удалять пулю, вылетевшую за экран, если у вас нет динамических списков?
Писать цикл для сдвига элементов — долго и дорого для байт-кода.
Решение:
Мы берем последний элемент массива, записываем его на место удаляемого, и просто уменьшаем счетчик длины массива на единицу.
// Пример логики удаления ракеты if (missile_y > screen_height) { // Копируем последнюю ракету на место текущей missiles[i] = missiles[last_index]; missiles_count -= 1; }
Минимум инструкций — максимум профита.
Графика на одном шейдере и «мост» между CPU и GPU

В обычном игровом движке всё довольно предсказуемо: меши, текстуры, вершинные буферы, десятки (а иногда и сотни) draw-call’ов за кадр. У меня же ничего этого нет. Вообще.
Вся графика — это один-единственный полноэкранный прямоугольник (по сути, два треугольника), на который повешен тяжёлый пиксельный шейдер. Всё, что вы видите на экране, вычисляется прямо в нём.
Если вы когда-нибудь запускали ShaderToy, принцип вам знаком: для каждого пикселя вызывается функция, которая, опираясь на координаты и входные параметры, рассчитывает итоговый цвет. Никакой сцены в привычном смысле — только математика.
Как подружить байт-код и видеокарту
Самый интересный момент — как передать состояние игры (позицию корабля, врагов, пуль и прочее) из моей виртуальной машины в GLSL-шейдер?
Я пошёл по максимально прямолинейному пути: использовал общий массив чисел с плавающей точкой.
Схема простая:
Виртуальная машина обновляет массив
state[]— туда складываются координаты, здоровье, таймеры и другие параметры.C++-обёртка передаёт этот массив в шейдер как
uniform(или как текстуру, если данных становится слишком много).Шейдер читает эти значения и на их основе «рисует» объекты.
Например, чтобы отобразить пули, шейдер просто проходит по соответствующему участку массива в цикле и для каждого элемента вычисляет вклад в итоговый цвет пикселя.
for (int i = 0; i < bullet_count; i++) { float dist = distance(uv, bullets[i].position); if (dist < 0.01) { final_color += vec3(1.0, 0.5, 0.0); } }
Визуальные эффекты: магия feedback-петли
Когда у тебя есть всего 7 дней и жёсткий лимит по размеру бинарника, про красивые системы частиц можно забыть. Никаких сложных взрывов, дымов и сотен спрайтов. Моим спасением стал feedback-эффект — обычная, но очень мощная техника обратной связи.
Идея предельно простая: текущий кадр смешивается с предыдущим. Всё. Но из этой простоты рождается сразу несколько «бесплатных» эффектов:
Motion blur. Движущиеся объекты начинают оставлять мягкий шлейф.
Свечение. Если яркость пикселя превышает единицу, при накоплении возникает естественный ореол.
Процедурный огонь и следы. Достаточно слегка смещать предыдущий кадр вверх и добавить шум — и за кораблём появляется убедительный инверсионный след.
Никаких текстур, никаких частиц — только математика и накопление кадров.
Рисование математикой (SDF)
Поскольку текстур в проекте нет, все формы описываются через SDF (Signed Distance Field) — функции расстояния до поверхности.
Корабль — это комбинация примитивов: несколько треугольников и кругов, аккуратно объединённых в одну формулу.
Враги — те же примитивы, но с более агрессивными геометрическими искажениями.
Каждый пиксель просто проверяет расстояние до этих форм и на основе него вычисляет цвет.
Чтобы всё это не только работало быстро, но и помещалось в лимит, финальный GLSL-код прогоняется через Shader Minifier. Он превращает аккуратный, читаемый код в плотную массу из a, b, c, удаляя пробелы, комментарии и сокращая идентификаторы. Смотреть на это больно, зато весит мало.
Игровой дизайн: бесконечность в нескольких строках
По жанру это классический бесконечный шмап. Но внутри — сплошной минимализм и немного ленивой инженерии.
Неубиваемые враги.
Когда игрок попадает во врага, он не удаляется. Никаких операций с памятью. Объект просто телепортируется далеко за пределы экрана и через пару секунд возвращается с новыми координатами. С точки зрения игрока — обычный респавн. С точки зрения кода — экономия байтов.
Прогрессия сложности.
Каждые 7 секунд добавляется новый враг. Его тип и поведение определяются индексом в массиве. Это позволило обойтись без громоздких switch в байт-коде — логика распределяется почти бесплатно.
Управление.
Бинарник весит 3 КБ, так что никаких сложных систем ввода. Используется обычный GetAsyncKeyState — самый дешёвый по размеру способ читать клавиатуру в Windows.
Финал: байт-код против C++ и битва за 3 КБ
Когда всё заработало, оставался главный вопрос: а вообще стоило ли городить виртуальную машину? Не проще ли было написать всё на чистом C++?
Я провёл честный эксперимент. Полностью переписал игровую логику обратно на C++, убрал VM и собрал проект с теми же настройками через Crinkler — лучший линкер-сжимальщик в демосцене.
Результат оказался неожиданно показательным:
Версия с VM и байт-кодом — 3072 байта.
Версия на чистом C++ — 3162 байта.
Байт-код победил. Почти 100 байт экономии — в мире сайзкодинга это серьёзная цифра.
Почему так произошло? Инструкции x86/x64 довольно избыточны: работа с регистрами, стеком, вызовами функций стоит дорого в байтах. Мой байт-код устроен плотнее — там, где процессору нужны десятки байт, виртуальной машине хватает одного-двух. Плотность инструкций перекрыла даже вес самого интерпретатора.
Технические «грязные» хаки
Чтобы уложиться в лимит, пришлось забыть о чистоте архитектуры:
Никакого дестроя. Программа не освобождает память и не закрывает дескрипторы при выходе. Windows всё подчистит сама, а каждый лишний вызов — это байты.
SDF-графика вместо текстур. Корабли, враги и пули — это формулы внутри одного шейдера.
Телепортация вместо менеджера памяти. Убитые враги никуда не исчезают — они просто улетают за экран и возвращаются.
Итог
Этот проект показал, что кастомная виртуальная машина — это не только академическое упражнение, но и вполне практичный инструмент сжатия.
Ограничения вынуждают думать иначе. Там, где в обычном проекте мы бы добавили пару мегабайт библиотек, здесь приходится искать более элегантное решение. И именно в этом — весь кайф.
