DOOM известен тем, что запускается где угодно – различные порты игры появляются с 1993 года. Мем «It Runs Doom» живёт в интернете уже больше десяти лет. Люди запускали DOOM на тостерах, на тачбарах макбуков, на умных холодильниках.
И, кажется, я – первый человек, который засунул DOOM в QR-код.

Абсурдная постановка задачи
QR-код может хранить до ~3 КБ текстовых и бинарных данных. Для сравнения:
Этот абзац до текущего момента весит около 0,4 КБ.
Спрайт «chaingun» из оригинального DOOM – 1,2 КБ.
Моя цель: сделать работоспособную DOOM-подобную игру, которая будет весить меньше трёх абзацев обычного текста.
С чего всё началось
Всё началось с того, что пару лет назад я посмотрел видео автора matttkc и был им крайне заинтригован. Главный вопрос, который там звучал: можно ли уместить целую игру в QR-код?
Похоже, идея такого проекта всё это время сидела у меня в подсознании, но я за неё не брался – просто потому, что считал, что мне это не по зубам. Когда на прошлой неделе идея всплыла снова, я понял, что хочу сделать именно DOOM. Но найти реализации или HTML-ремейки DOOM оказалось практически невозможно, так что я пошёл по следующему очевидному пути – сделать DOOM-подобную игру с нуля.
Это оказалось крайне сложно, потому что в моём распоряжении не было:
игрового движка – только чистый HTML/JavaScript;
ассетов – вся графика генерируется кодом;
библиотек с кодом – на счету каждый байт.
По ходу разработки я остановился на концепции backdooms – из-за созвучия и схожести идей бэкрумов (the Backrooms) и DOOM.
В отличие от автора видео, свою игру для QR-кода я делал на HTML. Глупо ли это было? Возможно. Оказалось ли это в итоге очень удачным решением? Безусловно.
Минификация
Чтобы уместить игру в такой абсурдно малый размер файла, нужна минификация – а в данном случае крайне агрессивная минификация.
Вот как выглядит часть кода игры:
<!DOCTYPE html><html><head><meta charset="utf-8"><style>body{margin:0;overflow:hidden;background:#000;cursor:crosshair}canvas{width:100vw;height:100vh}</style></head><body><canvas id=c></canvas><script>
M=Math,c=document.getElementById("c"),c.width=320,c.height=240,h=c.getContext("2d"),x=4,y=4,a=0,H=100,am=25,rc=0,fl=0;
f=(i,j)=>(Math.abs(i-4)<4&&Math.abs(j-4)<4)?"0":((((i+1000)%7)==3||((j+1000)%7)==3)?"0":(Math.random()<.05?"1":"0"));
e=[{x:5,y:4,h:100},{x:4,y:5,h:100}],k={};onkeydown=e=>k[e.key]=1;onkeyup=e=>k[e.key]=0;
onclick=_=>{if(am){am--;fl=2;rc=.2;e.forEach(o=>{d=M.hypot(o.x-x,o.y-y),r=M.atan2(o.y-y,o.x-x);if(d<5&&Math.abs(r-a)<.3)o.h-=50})}};
R=_=>{
rc=Math.max(0,rc-.02);fl=Math.max(0,fl-1);e=e.filter(o=>o.h>0);
h.fillStyle="#000";h.fillRect(0,0,320,240);
k.ArrowLeft&&(a-=.1);k.ArrowRight&&(a+=.1);m=.1;Если бы не <!DOCTYPE> в начале, то вы вполне могли бы решить, что это вообще не HTML.
Приведу пример попроще. Было:
function drawWall(distance) {
const height = 240 / distance;
context.fillRect(x, 120 - height/2, 1, height);
}После минификации:
h.fillRect(i,120-240/d/2,1,240/d) Переменные превращаются в одиночные буквы, комментарии испаряются, и код начинает напоминать зашифрованную записку с требованием выкупа.
Генерация карты
На ранних этапах разработки карта была совсем маленькой – 16×16 и 8×8. Для такой крошечной игры это в принципе приемлемо, но мне хотелось чего-то более играбельного, поэтому я реализовал бесконечную генерацию карт – да ещё и с генерацией по сидам.
Если вы играли в Minecraft, вы знаете, что такое сиды: предельно случайные значения из набора символов, которые служат основой для генерации игровых миров.
Псевдо-3D на основе техник оригинального DOOM
Теоретически, если вам понравилась какая-то конкретная генерация и вы знаете её сид, вы можете захардкодить его в код, чтобы каждый раз получать одну и ту же карту:
SEED = Math.random() * 100;Моя версия симуляции 3D-эффекта использует raycasting (бросание лучей) – приём рендеринга родом из 1992 года. Для каждого вертикального столбца экрана (из 320):
Бросаем луч под чуть изменённым углом.
Измеряем расстояние до ближайшей стены.
Рисуем прямоугольник тем выше, чем ближе стена.
for (let i = 0; i < 320; i++) {
const rayAngle = playerAngle + (i - 160) / 500;
let distance = 0;
while (!isWall(x + distance * cos(rayAngle), y + distance * sin(rayAngle))) {
distance += 0.1; // March forward
}
drawColumn(i, distance);
}Хотя это базовая тригонометрия, на неё приходится значительная часть всей игры. Честно говоря, если бы не бесконечная генерация карты, я бы просто закодировал URL в Base64 – и его одного хватило бы, чтобы запускаться напрямую. Но оно того стоило.
Примечание переводчика. В оригинале raycasting назван техникой DOOM. Строго говоря, это не так: raycasting прославил Wolfenstein 3D (1992), а сам DOOM (1993) использовал другой подход – рендеринг на основе BSP-деревьев. Игра автора – DOOM-подобная, однако под капотом у неё именно raycasting.
Механика противников
Это была ещё одна большая головная боль. В ранних версиях враги были только в начале, а стоило отойти подальше – их не оставалось вовсе. Для маленькой карты это ещё работало, но для бесконечной генерации – нет.

Противники дались мне тяжело. Во-первых, очень сложно сделать сколько-нибудь реалистичные эффекты стрельбы или хоть сколько-то реалистичных врагов, когда ты так зажат размером файла. Во-вторых, гейм-дев – не самая моя сильная сторона.
Поначалу враги просто стояли на месте и ничего не делали. В более поздних версиях я добавил движение, чтобы они преследовали игрока. И только намного позже я наконец нашёл рабочий способ спавнить врагов поблизости во время движения игрока:
if ((k.ArrowUp || k.w || k.ArrowDown || k.s || k.ArrowLeft || k.ArrowRight) && e.length < 10 && Math.random() < .01) {
t = Math.random() * 6.283;
Rdist = 1 + Math.random();
X = x + M(t) * Rdist;
Y = y + N(t) * Rdist;
f(~~X, ~~Y) == "0" && e.push({ x: X, y: Y, h: 100 });
}Сделать игру – это была лишь половина задачи. Настоящий вызов был в том, чтобы засунуть её в QR-код.
Концепция и реализуемость
Самый большой стандартный QR-код (Version 40) вмещает 2 953 байта (~2,9 КБ). Это очень мало. Например:
Звуковой файл Windows длиной 1/15 секунды весит 11 КБ.
На дискету (1,44 МБ) поместилось бы почти 500 QR-кодов данных.
Начальный размер моей игры составил 3,4 КБ. После изнурительного четырёхдневного процесса оптимизации я успешно уменьшил размер файла до 2,4 КБ – пусть и ценой нескольких тщательно взвешенных компромиссов.
Помните, я говорил, что QR-код хранит текстовые и бинарные данные? Так вот, HTML – это ни бинарные данные, ни обычный текст, так что попытка напрямую скормить HTML генератору QR-кодов провалилась.
Обычно в таких случаях советуют конвертацию в Base64 – но у этого подхода огромный оверхед в 33%! Оставалось меньше 1,9 КБ под саму игру. Теперь мне стало понятно, почему matttkc выбрал змейку.
Признаюсь, на этом этапе я подумывал сдаться. Я обсуждал эту проблему с тремя разными ИИ-чат-ботами – ChatGPT, DeepSeek и Claude – пытаясь хоть что-то с этим сделать (и каждый раз слышал, что разместить игру на сайте было бы проще). А потом ChatGPT между делом подкинул DecompressionStream.
DecompressionStream
DecompressionStream – малоизвестный компонент Web API, который встроен буквально в каждый современный браузер. Считайте его чем-то вроде WinRAR для браузера, только работает он с потоками данных, а не с zip-файлами.
Единственный способ добиться нужного результата (а я в этом практически уверен – после всех поисков у меня диплом по микро-играм) был таким:
Читаем исходный HTML.
Сжимаем его gzip с максимальным уровнем сжатия (9).
Кодируем результат в Base64.
Встраиваем в самораспаковывающуюся HTML-обёртку.
Преобразуем в data URI.
Проверяем: влезают ли данные в QR-код?
Да → генерируем QR-код.
Нет (превышен лимит QR Version 40) → берём максимально допустимую версию QR и проверяем, влезет ли при низкой избыточности (low redundancy). Если да – генерируем QR-код. Если нет – данные слишком большие, возвращаемся назад и уменьшаем HTML.
Показываем готовый QR-код для сканирования.
Я быстро набросал на Python инструмент, автоматизирующий весь этот процесс, и оно сработало. Доведение скрипта до ума заняло больше 34 итераций, а также крови, пота, слёз и процессорного времени.

Отсканируйте QR-код, скопируйте полученную строку
data:text/html;base64,…и вставьте её в адресную строку десктопного браузера (Chrome или Firefox) – игра запустится прямо оттуда.
Наследие и доступность
Проект (репозиторий на GitHub) показывает, что с помощью сжатия даже такие ограниченные носители, как QR-коды, могут вмещать интерактивные игры. Это непрактично для сложных игр, но открывает возможности для:
офлайн-распространения игр через QR-постеры;
ретро-творчества в духе демосцены;
образовательных инструментов для сред с низкой пропускной способностью.
Что насчёт будущих обновлений? Изначально я пошутил, что выпуск «версии 1.1» потребовал бы убрать букву «e» из всей кодовой базы – некоторые ограничения лучше не трогать. Но я сглазил: всего день спустя я обновил проект, добившись лучшего сжатия на целых 15%. Об этом – в отдельной статье: «How I Managed to Make HTML Game Compression so Much Better».
Примечание переводчика. Пара слов о том, почему в QR-код вообще влезает так мало. Ёмкость QR-кода определяется его версией (от 1 до 40) и уровнем коррекции ошибок. Version 40 в бинарном режиме при минимальном уровне коррекции (L) вмещает те самые 2 953 байта – и чем выше уровень коррекции (L → M → Q → H), тем больше места уходит на избыточность, позволяющую считать повреждённый или частично закрытый код, и тем меньше остаётся под полезные данные.
Именно поэтому автор выбирал «низкую избыточность»: он разменивал устойчивость кода к повреждениям на дополнительные байты для игры. Здесь же кроется и причина, по которой стоит с осторожностью сканировать произвольные QR-коды: в те же 2,9 КБ помещается не только игра, но и вредоносная нагрузка или ссылка.





















