Разработчик построил DOOM на чистом CSS — можно поиграть прямо в браузере

Чистый CSS, без JavaScript — вся логика на :checked, ~, counter() и CSS Grid. Работает в любом современном браузере, можно пострелять прямо сейчас.

Обложка: Разработчик построил DOOM на чистом CSS — можно поиграть прямо в браузере

Веб-разработчик Нильс Ленхер (Niels Leenheer) построил полноценный DOOM на чистом CSS. Каждая стена, пол, бочка и имп — это <div>, позиционированный в 3D-пространстве через CSS transforms. Игровая логика работает на JavaScript, но весь рендеринг — исключительно CSS.

Поиграть прямо в браузере: cssdoom.wtf — полноценный первый уровень DOOM, целиком на CSS. Chrome или Safari, WASD + мышь.
CSS DOOM — DOOM работающий на чистом CSS в браузере
DOOM на CSS — все стены, полы и спрайты отрисованы через CSS transforms

Ленхер — автор нескольких экспериментальных проектов, включая DOOM на осциллографе 1980-х годов. Новый проект использует координаты из оригинального WAD-файла (формат хранения данных карт DOOM) 1993 года и современные CSS-функции: @property, shape() и clip-path (свойство CSS для обрезки элемента по произвольному контуру) с правилом evenodd (правило заливки SVG-путей: закрашиваются области, ограниченные нечётным числом линий). Исходная статья набрала 306 очков и 67 комментариев на Hacker News, а код опубликован на GitHub.

Ключевые выводы

— CSS умеет считать тригонометрию: ширина стены через hypot(), угол через atan2()

— Камеры в CSS нет — двигается весь мир вокруг игрока через translate3d с инвертированными координатами

— Двери, лифты и снаряды анимируются через CSS transitions и CSS animations без JS animation loop

— Освещение секторов работает через filter: brightness(), наследуемый по каскаду

@property регистрирует custom properties как числа, что позволяет анимировать их плавно

Как это работает — CSS как 3D-движок

Сцена строится из нескольких тысяч <div>-элементов. Каждый получает сырые координаты из оригинального WAD-файла DOOM в виде CSS custom properties: две пары x/y-координат, высоту пола и потолка. CSS сам вычисляет всё остальное.

Тригонометрия в CSS

Ширина стены — это расстояние между двумя точками, которое вычисляется по теореме Пифагора через функцию hypot(). Угол поворота — это арктангенс, вычисляемый через atan2(). Обе функции добавлены в CSS специально для подобных расчётов.

			.wall {
    --delta-x: calc(var(--end-x) - var(--start-x));
    --delta-y: calc(var(--end-y) - var(--start-y));

    width: calc(hypot(var(--delta-x), var(--delta-y)) * 1px);
    height: calc((var(--ceiling-z) - var(--floor-z)) * 1px);

    transform:
        translate3d(
            calc(var(--start-x) * 1px),
            calc(var(--ceiling-z) * -1px),
            calc(var(--start-y) * -1px)
        )
        rotateY(atan2(var(--delta-y), var(--delta-x)));
}
		

JavaScript передаёт сырые данные DOOM. CSS считает тригонометрию. Это разделение — ключевой архитектурный принцип проекта.

Движение мира вместо камеры

В CSS нет понятия камеры. Вместо этого используется классический трюк: перемещается весь мир в направлении, противоположном движению игрока. JavaScript задаёт всего четыре custom property — --player-x, --player-y, --player-z и --player-angle. CSS делает остальное:

			#scene {
    translate: 0 0 var(--perspective);
    transform:
        rotateY(calc(var(--player-angle) * -1rad))
        translate3d(
            calc(var(--player-x) * -1px),
            calc(var(--player-z) * 1px),
            calc(var(--player-y) * 1px)
        );
}
		

Обратите внимание на инвертированные знаки: если игрок шагает вперёд — мир сдвигается назад. Если игрок поднимается по лестнице — лестница опускается вниз.

Полы, текстуры и клипы

DOM-элементы по умолчанию вертикальны — существуют в плоскости x/y. Чтобы превратить <div> в пол, достаточно rotateX(90deg) — элемент «ложится» горизонтально.

Сложные формы через clip-path

Секторы DOOM — это произвольные многоугольники: L-образные комнаты, неправильные формы, закруглённые коридоры. Для них используется clip-path с polygon(). Для секторов с отверстиями (колонны, платформы, окна) — clip-path с path() и правилом заливки evenodd.

Однако polygon() работает с процентами, а path() требует координат в пространстве CSS — что нарушает чистоту разделения. Решение нашлось в новой функции shape(), которая поддерживает проценты и evenodd одновременно.

Выравнивание текстур

Два соседних сектора с одинаковой текстурой пола должны бесшовно стыковаться. Поскольку background-image повторяется бесконечно, достаточно выровнять начало паттерна по мировым координатам:

			.floor {
    background-repeat: repeat;
    background-size: 64px 64px;
    background-position:
        calc(var(--min-x) * -1px)
        calc(var(--max-y) * 1px);
}
		

Каждый сектор ссылается на одну и ту же текстурную сетку — независимо от того, где расположен его <div>. В результате переход между секторами выглядит бесшовным.

Анимации — двери, снаряды, спрайты

Прежде чем перейти к деталям — важный термин: billboarding. Это техника, при которой 2D-спрайт всегда повёрнут лицом к камере, независимо от положения игрока. В DOOM все враги, бочки и снаряды — billboarded-элементы.

Двери и лифты на CSS transitions

Открытие двери в DOOM — это поднятие потолка сектора. В CSS все элементы двери группируются в контейнер, а анимация запускается переключением атрибута data-state:

			.door > .panel {
    transform: translateY(0px);
    transition: transform 1s ease-in-out;
}

.door[data-state="open"] > .panel {
    transform: translateY(var(--offset));
}
		

Никакого JS animation loop — достаточно установить атрибут состояния на элементе. CSS transitions берут анимацию на себя.

С лифтами всё сложнее: игрок едет вместе с платформой, поэтому --player-z должен обновляться синхронно с CSS transition. Но --player-z управляется из JavaScript. Поэтому для лифтов JS вынужден использовать функцию cubic ease-in-out (t² * (3 - 2t)), чтобы оставаться в синхронизации с CSS-анимацией — это признанное ограничение текущей архитектуры.

Снаряды на CSS animations

Ракеты и файерболы импов — это billboarded <div>. При создании снаряда JavaScript вычисляет конечную точку и длительность полёта, а CSS анимирует перемещение от точки A к точке B:

			.projectile {
    rotate: y calc(var(--player-angle) * 1rad);
    animation: projectile-move var(--duration) linear both;
}

@keyframes projectile-move {
    from {
        translate:
            calc(var(--start-x) * 1px)
            calc(var(--start-z) * -1px)
            calc(var(--start-y) * -1px);
    }
    to {
        translate:
            calc(var(--end-x) * 1px)
            calc(var(--end-z) * -1px)
            calc(var(--end-y) * -1px);
    }
}
		

Благодаря разделению translate и rotate как отдельных CSS-свойств анимация управляет только позицией, а rotate реагирует на --player-angle — файербол продолжает смотреть на камеру при движении игрока.

Параллельно с CSS-анимацией игровой цикл на JavaScript рассчитывает позицию снаряда тем же линейным методом — для обнаружения столкновений (collision detection). Когда снаряд попадает в стену, пол, игрока или врага, JS удаляет элемент посреди полёта и порождает взрыв.

Спрайты с billboarding и mirroring

Враги в DOOM — 2D-спрайты, которые всегда повёрнуты к камере (billboarding). Оригинальная игра хранит 5 уникальных наборов кадров из 8 ракурсов — ракурсы 6-8 это зеркальные отражения 2-4. CSS воспроизводит это через scaleX(-1):

			.sprite {
    background-position-y: calc(var(--heading) * var(--h) * -1px);
    transform:
        translateX(-50%)
        rotateY(calc(var(--player-angle) * 1rad))
        scaleX(var(--mirror, 1));
}
		

Анимация ходьбы — это spritesheet (набор кадров анимации в одном изображении) со сдвигом background-position-x через steps(). При атаке или смерти JavaScript меняет data-state, и CSS переключается на другой фрагмент spritesheet.

Одна из проблем: изначально все враги маршировали идеально в ногу — левая нога каждого зомби касалась земли в один и тот же момент. Решение — случайный animation-delay, задаваемый из JavaScript. Когда в браузерах появится CSS-функция random(), этот параметр можно будет перенести целиком в CSS.

Освещение и @property

DOOM хранит уровень освещённости для каждого сектора. В CSS это реализовано через custom property --light на контейнере сектора:

			.wall, .floor, .sprite {
    filter: brightness(var(--light, 1));
}
		

Каскад CSS идеально подходит для этого: все стены, полы и спрайты в тёмном секторе автоматически затемняются — без необходимости устанавливать яркость на каждом элементе отдельно. Мерцающие лампы — это @keyframes-анимации переменной --light.

Но анимировать custom properties можно только если они зарегистрированы через @property. Без этого браузер воспринимает их как строки:

			@property --player-z {
    syntax: "<number>";
    inherits: true;
    initial-value: 0;
}
		

Эта регистрация позволяет плавно анимировать --player-z — например, при падении игрока с уступа CSS сам создаёт плавный переход.

Что делает JavaScript, а что CSS

Автор чётко разделил ответственности:

  • JavaScript — игровой цикл, состояние игры, коллизии, порождение и удаление DOM-элементов, установка custom properties и data-атрибутов
  • CSS — все 3D-трансформации, вычисление геометрии (тригонометрия), анимации дверей и снарядов, освещение, CSS рендеринг спрайтов

По словам Ленхера, game loop на JavaScript — наименее интересная часть проекта. Исходный код DOOM на C доступен публично уже много лет, поэтому для портирования он использовал Claude, чтобы сосредоточиться на CSS-рендеринге.

Частые вопросы
1
Можно ли в это играть?

Да, демо доступно онлайн. Это полноценный первый уровень DOOM с врагами, дверями, лифтами и стрельбой. Управление — стандартные WASD + мышь.

2
Почему не Canvas или WebGL?

Цель проекта — показать возможности современного CSS, а не создать производительный порт. Canvas и WebGL изначально созданы для рендеринга, а CSS — нет, что делает результат особенно впечатляющим.

3
Какие браузеры поддерживаются?

Проект использует новейшие CSS-функции: hypot(), atan2(), @property, shape(). Для запуска потребуется Chrome или Safari последних версий. Firefox пока не поддерживает все необходимые функции.

4
Насколько это производительно?

Не очень. Сцена состоит из тысяч DOM-элементов с 3D-трансформациями, и браузерный движок CSS не оптимизирован для игрового рендеринга. Но проект и не ставил целью производительность — это эксперимент, доказывающий мощь современного CSS.

Выводы

Я хотел найти границы того, на что способен браузер. Увидеть, насколько мощным стал современный CSS. И потому что это DOOM. На CSS. Вам правда нужна ещё какая-то причина?
Нильс Ленхервеб-разработчик, автор CSS DOOM

Проект демонстрирует, как далеко продвинулся CSS за 30 лет. Функции вроде hypot(), atan2() и @property превращают каскадные таблицы стилей в полноценный инструмент для 3D-вычислений. Разделение на JS game loop и CSS-рендеринг оказалось не только возможным, но и элегантным.

Исходный код: GitHub. Подробный разбор: CSS is DOOMed.