Один движок, тринадцать игр: общий слой Лаборатории
Платформа заслуживает своё имя тогда, когда тринадцатую вещь строить дешевле, чем третью. Это движок браузерных игр под нашей Лабораторией: что общего у всех игр, чего нет ни у одной, и горстка маленьких контрактов, которые не дают тринадцати очень разным игрушкам подраться друг с другом.
На этой странице
В Лаборатории работают тринадцать игр: восемь трёхмерных в реальном времени на WebGL и пять оригиналов на Canvas 2D. Интересно тут не количество. Интересно, во сколько обходится следующая, и ответ почти целиком решается тем, какие части мы согласились сделать общими. Общими мы делаем скучные: насколько всё громко, сколько пикселей просят у GPU, что происходит, когда телефон поворачивают, что происходит, когда браузер выбрасывает графический контекст. Ни одна интересная часть общей не является. Нет ни общего игрового цикла, ни общей физики, ни системы сущностей, ни абстракции над графом сцены между игрой и three.js.
- 13
- игр в Лаборатории
- 8
- трёхмерных в реальном времени на WebGL
- 120 Hz
- симуляции с фиксированным шагом
- 1
- кнопка звука на все сразу
Лаборатория по состоянию на 27 июля 2026 года. Каждая игра бесплатна, работает в браузере, без установки и без аккаунта.
Восемь на WebGL, пять на канвасе
Каждую игру описывает единый реестр: слаг, заголовок, слоган, акцентный тон, поддерживаемые устройства, статус, список особенностей, 3D она или 2D, и короткая моношрифтовая подпись движка, называющая механику («чанковый воксельный ландшафт» и в этом духе). Сетка на /games читает этот реестр и делит себя надвое: рельс 3D в реальном времени для игр на React Three Fiber и сетка под ним для оригиналов — игр на Canvas 2D, которым GPU не нужен вовсе.
Правило, из-за которого это деление что-то значит, укладывается в одну строку: каждый компонент игры и каждый компонент демо-режима обёрнут в React.lazy на уровне модуля. Поэтому three.js разъезжается по чанкам на игру. Тот, кто открыл оригинал на Canvas 2D и играет в него час, не скачивает рендерер вовсе, а тот, кто открыл индекс игр, не скачивает ни того ни другого — ему показывают постеры. Оставить пять игр на обычном канвасе — не ностальгия. Некоторые идеи лучше живут пикселями, а игра, которая ничего не стоит при загрузке, — это игра, которую люди действительно доигрывают.
Классы качества WebGL, угаданные один раз
Каждая 3D-игра задаёт при монтировании один и тот же вопрос и больше его не повторяет: насколько мощная машина мне досталась? Ответ — класс low, medium или high, посчитанный из детекции грубого указателя, navigator.hardwareConcurrency, navigator.deviceMemory и меньшей стороны экрана. Устройство считается телефоном, когда указатель грубый и короткая сторона меньше 700 CSS-пикселей. Самый весомый выход класса — жёсткий потолок плотности пикселей: 1,25, 1,5 или 2,0. На современном телефоне разница между потолком 1,25 и неограниченными 3,0 — это примерно шестикратная разница в числе закрашиваемых фрагментов, то есть разница между игрой и обогревателем.
Эту деталь про Safari стоит проговорить прямо, а не заводить на неё баг. Safari не отдаёт deviceMemory, поэтому iPhone никогда не сможет выторговать себе ступеньку выше и по умолчанию попадает в низший класс. Мобильный Safari к тому же держит меньше всего живых контекстов WebGL среди браузеров, под которые мы выпускаемся, и агрессивно забирает фоновые вкладки, так что консервативный ответ и есть правильный. Когда эвристика ошибается в удобную для вас сторону, не трогайте её.
Два решения помельче в том же хуке улаживались дольше, чем пороги. Серверный рендеринг возвращает medium: сервер ничего измерить не может, поэтому угадывает середину и оставляет клиенту поправить при монтировании. И пользовательское предпочтение уменьшенного движения класс не понижает. Соблазнительно смешать одно с другим, но это разные просьбы: перестаньте всё двигать — не то же самое, что дайте мне меньше пикселей, и отвечать на просьбу о читаемости ухудшением качества картинки было бы странным способом слушать. Уменьшенное движение уважается там, где ему место, — в самой анимации.
Одна кнопка звука на всю Лабораторию
Ни одного аудиофайла в Лаборатории нет, потому что спецификация запрещает бинарные ассеты. Каждый звук в каждой игре выходит из одного модуля синтеза с поверхностью ровно из двух функций: beep() и noiseBurst(). Вдвоём они закрывают двигатель стыковки, приземление пешки, выстрел башни и завершение уровня — что говорит не столько о нашем саунд-дизайне, сколько о том, какую долю игрового звука составляет форма огибающей.
Стоящий за ними AudioContext — ленивый синглтон, создаваемый при первом использовании, а не на импорте, потому что контекст, созданный до пользовательского жеста, приходит приостановленным и таким и остаётся. Звуки, запрошенные, пока контекст просыпается, ставятся в очередь, а не выбрасываются, — поэтому клик, который разблокирует аудио, слышен и сам. Когда вкладка скрывается, контекст приостанавливается.
Один ключ в localStorage управляет одним мастер-гейном. Заглушите симулятор стыковки — и шахматная доска тоже замолчит.
Этот ключ — vaynerov:engine3d:muted:v1 — намеренно глобален, а не пригоден для каждой игры по отдельности. Сегодня синтезатор импортируют пятнадцать модулей, включая одну страницу, которая вовсе не игра, и все они висят на едином мастер-гейне. Альтернатива, переключатель звука в каждой игре, звучит гибче и на деле хуже: никто не хочет искать тринадцать галочек из-за того, что началась встреча. Заглушить звук — экстренное действие, а экстренные действия должны быть тотальными.
Остальной общий слой намеренно такой же маленький:
- Сенсорные стики с мёртвой зоной в 18% — достаточно, чтобы дрожь большого пальца не рулила, и достаточно мало, чтобы точное управление выжило.
- Распознавание свайпа с порогом в 24 px, где перетаскивание мышью тоже считается свайпом, так что один и тот же обработчик работает и на ноутбуке.
- Хранилище на игру под
vaynerov:<slug>:v1— настройки и прогресс, разложенные по пространствам имён, чтобы ни одна игра не могла прочитать состояние другой. - Уменьшенное движение как хук, который защёлкивает начальное значение и дальше подписывается, — поэтому предпочтение, изменённое посреди сессии, срабатывает без перезагрузки.
- Обработка потери контекста, смонтированная внутри канваса: она превращает потерянный контекст WebGL в постер, а не в чёрный прямоугольник.
У последнего пункта в исходниках есть честный комментарий: страж подключён к сцене витрины, а оставшимся семи сценам отдельных игр он всё ещё нужен. Это самый старый открытый пункт в слое. Потеря контекста на странице игры сегодня даёт границу ошибки, а не аккуратный постер: восстановимо, но не изящно.
Никакого общего игрового цикла
Самым полезным решением во всём слое был отказ написать то, что всякий движок пишет первым, — общий цикл обновления.
Вместо этого каждая игра владеет своим циклом, а те, где есть настоящая физика, идут фиксированным шагом — 120 Гц в симуляторе стыковки и в укладчике с краном и маятником, где переменный шаг заметно меняет сложность игры. Что важнее, симуляция нескольких игр живёт в обычном модуле с комментарием сверху: никакого three.js, никакого React. Курьер в духе crossy road, карманная RTS, бегун по полосам и воксельный остров держат свои правила в файлах, которые не импортируют ничего. Физика, которую можно вызвать из тестового файла, — это физика, которой можно доверять; физика, запутанная в колбэк рендера, — это слух.
Нагляднее всего это окупается в Slipstream, бегуне по полосам. Ряды препятствий в нём генерируются из паттернов, а красивый паттерн вполне может оказаться непроходимым: щель, в которую можно спокойно войти, становится стеной, когда трасса едет со скоростью 32 единицы в секунду. Поэтому файл паттернов везёт валидатор, который проходит каждый ряд и доказывает, что проходимая полоса остаётся достижимой на худшей скорости, протаскивая достижимость вперёд по трём полосам. Он выполняется при инициализации модуля в разработке, а значит, нечестный паттерн не переживёт даже загрузку страницы, не говоря о релизе.
Нам нравится, как это переиспользуется: та же модель рядов крутит автопилот демо-режима на витрине игр. Демо, которое играет само в себя на главной, рулит ровно по той достижимости, которую проверил валидатор. Честно для валидатора — то же самое, что честно для игрока, и демо это доказательство, работающее у всех на виду.
Один канвас за всем сразу
Витрина игр листает демо-слайды: по несколько секунд каждой игры, играющей самой в себя. Наивная реализация монтирует канвас на слайд, и на телефоне она умирает сразу: браузеры ограничивают число живых контекстов WebGL, а на телефоне этот потолок достаточно низок, чтобы упереться в него раньше, чем рельс закончит круг. Поэтому за всеми 3D-слайдами витрины работает один общий канвас, и в каждый момент смонтирована ровно одна сцена.
Подмена сцен внутри живого канваса — некрасивая часть, и мы прячем её за непрозрачной DOM-вуалью с хореографией из пяти констант.
| Константа | мс | Роль |
|---|---|---|
| VEIL_UP | 240 | Непрозрачная DOM-панель поднимается над канвасом |
| SETTLE | 160 | Пауза за вуалью, пока меняется сцена |
| CANVAS_FADE | 700 | Сам канвас перетекает между сценами |
| VEIL_DOWN | 480 | Панель уходит уже на новой игре |
| COOL_DOWN | 400 | Дальнейшие смены слайда отклоняются, пока это не истечёт |
Переход 3D → 3D. Пауза остывания нужна потому, что посетитель, быстро тыкающий в рельс, иначе может размонтировать сцену прямо во время её сборки.
За вуалью одна строка делает непропорционально много работы: перед каждым монтированием камера по умолчанию сбрасывается к заводским значениям — угол обзора 75°, дальняя плоскость 1000. Игры оставляют камеру там, где её потребовал их мир. Шутер с захватом указателя оставляет дальнюю плоскость на 160, что верно для коридорной арены и катастрофично для воксельного острова, чьему горизонту нужно минимум 260. Без сброса порядок слайдов решал бы, отрисуется мир или резко закончится туманом. Общее изменяемое состояние рано или поздно наказывает; заводской сброс — самое дешёвое извинение.
Канвас работает в режиме frameloop="always" только тогда, когда он действительно на экране и активен 3D-слайд; в остальное время он не рисует ничего. А когда контекст всё-таки теряется, сцена откатывается к DOM-постеру.
Эти постеры заслуживают абзаца. Они есть у всех тринадцати игр, и каждый постер — чистые DOM и SVG: ни WebGL, ни 2D-канваса, ни единого пикселя растровой графики. Именно это ограничение делает их безопасными для рендера из серверных компонентов и даёт всей Лаборатории одну цепочку отката сразу на три разных отказа: серверный рендеринг, браузер без WebGL и ленивый чанк, который ещё не приехал. Один и тот же рисунок закрывает все три. Оригиналы на Canvas 2D делят между собой один модуль постеров, потому что их карточки — одна семья.
Маленькие контракты, помноженные на тринадцать
Остальная платформа — короткий список договорённостей, и каждая существует потому, что однажды что-то пошло не так.
Класс устройства защёлкивается. Игра один раз, при монтировании, спрашивает, поддерживается ли устройство, и ответ приколот на всю сессию. Комментарий в хуке объясняет причину лучше, чем смогли бы мы: изменение размера окна посреди партии не должно перевернуть класс и подменить идущую игру экраном блокировки. Детекция сначала читает user agent, потом размер вьюпорта и на самом первом рендере возвращает null, чтобы сервер и клиент договорились до того, как что-то зафиксируется. Ровно одна игра из тринадцати только для десктопа — шутер с захватом указателя, которому нужна мышь, — и неподдерживаемое устройство получает нормальный экран блокировки, а не сломанный опыт.
Есть правило, которого мы намеренно не ввели: телефоны не загоняются на сенсорное управление на уровне платформы. Каждая игра защёлкивает тип указателя при монтировании и сама решает, какие стики и какие подсказки управления рисовать. Планшет с подключённой клавиатурой существует, и общее правило платформы ошибалось бы на нём в обе стороны.
Полный экран — это контракт, а не вызов API. Игры никогда не трогают Fullscreen API сами, они читают контекст. Там, где нативный API есть, мы им пользуемся. В Safari на iPhone, где для произвольных элементов его нет, мы откатываемся к CSS-псевдополноэкранному режиму: оболочка, прибитая к вьюпорту на высоком слое, и заблокированная прокрутка страницы, чтобы резинка не утащила доску посреди хода. Оба пути отдают одно и то же булево значение через один и тот же хук, а игра, которая вешает Escape, обязана делать ранний выход в полноэкранном режиме, потому что там этой клавишей владеет хост.
Клавиатурные привязки разделены по намерению. Клавиши движения сопоставляются по e.code, то есть по физической клавише, чтобы WASD оставалась ромбом под левой рукой и на AZERTY. Мнемонические буквы — те, где смысл именно в букве, — сопоставляются по e.key. Сайт выходит и на русском, и игрок с кириллической раскладкой должен иметь возможность нажать клавишу, помеченную нужной буквой. Этой конвенции следуют сегодня пятнадцать файлов Лаборатории.
У каждой игры своя граница ошибки, перезаданная по слагу, чтобы падение в одной игре не протекло в следующую, которую откроет посетитель.
Что запрещает спецификация
Главная спецификация трёхмерной Лаборатории — в основном список запретов, а это полезная половина любой спецификации. Никаких новых npm-зависимостей: вся Лаборатория — это three.js 0.184 с React Three Fiber 9.6 и drei 10.7, и больше ничего. Никаких бинарных ассетов: каждая текстура процедурна, каждый звук выходит из синтезатора. Каждый цикл, слушатель, AudioContext и захват указателя обязаны убираться при размонтировании. Всё встаёт на паузу по visibilitychange и через две секунды без фокуса. Уменьшенное движение уважается везде.
В шапке самой спецификации до сих пор написано «девять игр». Мы написали её в июне, выпустили ещё три за один день и число так и не обновили — обычная судьба всякого документа, который нельзя исполнить. Ограничения от этого не рассыпались, потому что их держат ревью и сборка, а не фраза наверху.
Вот и вся экономика, ведь именно в ней было утверждение. Тринадцатая игра приехала собственным каталогом примерно из пятидесяти файлов плюс правками в четырёх общих местах: реестр, карта постеров, шесть каталогов локалей и машиночитаемая сводка сайта. Ни одной новой зависимости. Ни одного изменения в классах качества, синтезаторе, контракте полноэкранного режима или витрине. Ей достались по наследству кнопка звука, защёлка устройства, цепочка отката к постеру и клавиатурная конвенция, о которых ей не пришлось думать вовсе, — а всю собственную сложность она потратила на ту часть, которой раньше никто не строил.
В этом и вся отдача от общего слоя: не в том, что он делает игры, а в том, что он делает те части игры, которые никому не захотелось бы писать дважды. Семь стражей потери контекста в отдельных играх всё ещё в списке, и они там останутся, пока браузер не уронит контекст у кого-нибудь, кто выигрывал.