Грязные игры

Вы знаете, что игры бывают очень архитектурно «грязными» внутри? Но это не помешало им продаваться миллионами копий, или быть написаннами одним человеком на фреймворке для браузерок, или на Lua поверх библиотеки для геймджемов, или вообще ребенком в бесплатной версии юнити. При этом мощный кастомный движок с ECS, job‑системой, своим рендером и рефлексией повсюду вы тоже знаете, но игра на нем, скорее всего лежит третий год у вас в беклоге, так и не сыграная даже пару часов.
Попросили меня по старой дружбе, где‑то с полгода назад, помочь с разработкой и выводом игры в Steam. Ребята до этого занимались нефтью, и накопив деньжат, решили, что называется оставить след в индустрии. Если честно, я несколько отвык от такого «детского» кода и простых решений, что меня несколько удивило, хотя и вернуло на грешную землю из объятий ентрепрайза. Но я сразу оговорюсь, что простая архитектура это не оправдание плохого кода, а способ выбрать, где именно вы повзоляете себе быть сложным. Бюджет сложности конечен… прежде всего размером вашей натуральной оперативкой, и тратить его надо туда, где игрок это увидит.
Начнём с того, что переусложнение это не глупость/лень, но почти всегда результат ума и добросовестно выполненной работы. Человек прочитал грамотную статью, посмотрел сильный доклад, поработал в хорошей студии и пытается делать правильно, но «правильно» пришло из контекста большой или специализированной разработки, где по‑другому уже не получается делать и есть «культура перформанса» или, если хотите, «проклятие масштаба»… выбирай свое.
Доклады про data‑oriented design, правильную укладку структуры в кеш‑линию, а там всего‑то 64 байта, разговоры про виртуальный вызов и косвенность, которые убивают предсказатель переходов, всё это правда… Но эта правда была рассказана людьми, у которых на экране одновременно живёт сто тысяч сущностей, я серьезно… у нас в проекте «кончился» uint16 для идентификаторов ecs. И еще есть бюджет в 16 миллисекунд на весь кадр вместе с рендером, физикой и звуком на все эти «стотыщмильенов» ентити (не у всех всё надо считать, это уже другой разговор). А у ребят было порядка двухсот врагов в топ‑даун шутере, но у них тоже иногда лагало.
Бюджет кадра при 60 fps: 16.6 мс
200 сущностей, наивный Update()
с виртуальным вызовом: ~200 × 50 нс = 0.01 мс
плюс промахи кеша на каждой: ~200 × 200 нс = 0.04 мс
Итого игровая логика: ~0.05 мс из 16.6 мс
Тот же кадр, реальные расходы:
рендер и отправка команд: 4–8 мс
физика: 1–3 мс
GC-пауза в неудачный кадр: 2–15 мс ← вот здесь боль
То есть вы можете оптимизировать свою логику в двадцать раз и не получить ни одного лишнего кадра, потому что лаг приходил из аллокаций в горячем цикле. Знаете что они в итоге сделали? Опустили таргет рейт до 30 и о чудо… лаги пропали, потому что движок перестал пытаться натянуть сову на глобус. Я тут немного слукавил, у них было два дня до показа игры паблишеру и лезть что‑то менять в коде, ну такое… можно и не поехать к паблишеру на показ.
Карго‑культ интерфейсов

Большие студии любят интерфейсы и какой‑нибудь IAudioSystem живет не потому, что красиво, хотя это действительно красиво, но у движка есть пять платформ и три звуковых бэкенда, и бэкенды эти допиливаются в процессе разработки, поэтому интерфейс тут будет способом поменьше звать на ревью людей из звуковой подсистемы.
В команде из трёх человек с одной целевой платформой у интерфейса будет одна (всего одна) реализация и один мок, который никто не запускает, потому что тестов нет… хе‑хе, еще одна беда маленьких команд. Но вы все равно заплатили за абстракцию полную цену большого движка и не получили ничего из того, ради чего она вообще существует, оно того стоило? А теперь мы идем на игровую конференцию слушать умный доклад от ребят, которые пишут звуковую подсистему и хотят продать нам свой IAudioSystem
Что видит читатель доклада с GDC:
IWeaponSystem ──► WeaponSystemPS5
├──► WeaponSystemXbox
└──► WeaponSystemPC
(три платформы, три команды, общий контракт)
Что получается в проекте на троих:
IWeaponSystem ──► WeaponSystem
└──► WeaponSystemMock // не используется с 2023 года
(одна реализация, два лишних файла, минус переход по коду)
Отдельная любовь у программистов писать большую систему под десять тысяч объектов до того, как в игре поняли, что они там вообще будут. А если не будет… чтож, мы получили выброс дофамина от написания крутой системы, которая никому не нужна. Написать пулинг, чанки и SoA‑раскладку приятно, потому что задача формальная и в конце есть измеримый результат, а вот выяснять, почему в вашей игре скучно неприятно и результат там не измеряется. Поэтому программисты честно и с удовольствием оптимизируют игру, в которую пока никто не играет… поэтому не давайте программистам делать игру, пусть её делает дизайнер монстров, уровней и механик.
А что сделали те, у кого получилось

Лука Галанте, разработчик слотов, в начале 2021 забил на денежную и не пыльную работу и на HTML5-фреймворке Phaser (вдохновившись мобильной Magic Survivor если я правильно помню) написал Vampire Survivors. Ранний доступ в декабре 2021 стал одним из главных хитов десятилетия, потеснив разные AAA релизы и породив новый жанр, и только в 2023 году игра переехала на Unity ради производительности и кроссплатформенности. Т.е. сначала игра нашла аудиторию на браузерном фреймворке, потом под неё подвели «взрослый» стек.
Соло‑герой LocalThunk два с половиной года на движке Löve (изначально как побочный проект для резюме на позицию диза) делал Balatro, которая к январю 2025 продалась тиражом в пять миллионов копий. Löve это Lua плюс тонкая обёртка над SDL, вообще без редактора, без сцены и без инспектора. Моддеры, разобравшие игру, обнаружили внутри обычный Lua, кучу дерева и разного цвета субстанции, функции по 2-3к строк и минимум комментариев.
Stardew Valley, Undertale, Minecraft, Unturned, Phasmophobia (XNA, GameMaker, LWJGL, Unity, Unity) и опять один‑два человека, никакого своего движка, никакой архитектурной экзотики, «плохо пахнущий код», зато авторы потратили свои силы на содержание, а не на инфраструктуру кода.
И, чтобы никто вы не думали, что игровой движок это бесплатно — то когда Vampire Survivors переезжал с Phaser на Unity, это заняло около года работы и выросшей до десяти человек команды. То есть простой стек дешев, но не исчезает бесследно и вам придется заплатить когда у вас уже есть деньги и люди, но заплатить придется. Я думаю это хорошая сделка, но это именно сделка, а не подарок.
Берите скучный стек

Готовый коммерческий движок это не признание в творческой импотенции, хотя мнение это живо и активно тиражируется на конференциях, и сам я когда смотрю на очередное поделие на Unreal и не могу отличить его от неделю назад другого игранного поделия, меня терзают смутные сомнения, что не зря об этом говорят.
Но скучный стек это больше про управлению рисками и Unity, Unreal и Godot уже отладили загрузку ассетов, звук, поддержку геймпадов, экспорт билда за вас и, что важнее тех, кто планирует консоли, там нормальные платформенные бэкенды и минимум проблем с сертификацией, у Microsoft есть целый отдел, который ревьювает только игры на юньке.
И еще… выбирая чужой движок, вы берёте на себя и чужие бизнес‑решения, и если кому‑то из топ‑менеджмента не хватит пары шекелей на яхту побольше, он не раздумывая выкатит очередную гениальную идею и вы вдруг начнете платить за установки, бесплатные установки из playstore… потом конечно одумаются, но доверие уже подорвано и многие скажут «спасибо, не надо». Цена скучного стека в экономии годов разработки, но однажды кто‑то в не вашем совете директоров захочет табун на пятьсот кобыл в розовом цвете, и вам опять придется с этим смириться.
Монолит лучше связки систем
Это обычно вызывает больше всего возмущения, когда я предлагаю команде отказаться от развесистых подсистем. Потому что в игровом коде большой прямолинейный класс, который делает много вещей, часто выгоднее композиции из восьми маленьких сущностей. Не потому, что он лучше написан, а потому что игровая логика меняется, и стоимость изменения становится важнее стоимости чтения и красоты кода. А вот когда у вас будут деньги и преданные фанаты, можно подумать и красоте и философии, но вариант, который стыдно показывать на код‑ревью будет и если вариант вам стыдно показывать на код‑ревью, ну чтож… просто будет стыдно, от стыда еще никто не умирал, зато быстро и через полчаса нужная фича в игре.
class GameManager {
Player player;
WaveSpawner spawner;
UpgradeList upgrades;
float runTime;
void Update(float dt) {
runTime += dt;
spawner.SpawnIfNeeded(runTime);
player.Update(dt);
enemies.UpdateAll(dt);
CheckCollisions();
if (player.xp >= nextLevelXp) OpenUpgradeScreen();
hud.Refresh(player, runTime);
}
}
А вот так было «правильно» изначально (EventBus + ServiceLocator + IUpgradeProvider + TimeScaleService) и каждая правка занимала несколько часов. Надо было понять кто подписан на PlayerLevelUp, в каком порядке отработают подписчики, почему TimeScaleService уже применился к снарядам, и добавить четвёртый сервис, чтобы этого не происходило.
Абстракция это ставка на то, что вы угадаете ось изменений на месяц, два или год вперед. В движке, в рендере, в загрузке ресурсов вы её угадываете, там оси известны, но в логике игры вы её не угадаете почти никогда, особенно если команда небольшая и ищет свою нишу, а дизайнер меняет формулировку задачи от итерации к итерации. Тогда красивый интерфейс превращается в препятствие, которое надо сначала разобрать, а потом собрать обратно. Но монолитный менеджер очень легко превращается в глобальный синглтон, к которому обращаются из ста мест, и вот тогда всё действительно становится плохо, но это совсем другая история, и чинить вы её будете после выхода игры, если будете…
Синхронно и линейно это нормально

Событийные шины, реактивность и асинхронность в геймплее продаются под лозунгом «слабая связанность». Продают эти штукенции обычно тем ребятам, которые заняты написанием IAudioSystem и у которых в запасе есть годик, чтобы это осмыслить и внедрить, но инди покупает вместе с этим только невозможность прочитать порядок выполнения кадра по коду. Классический баг тут выглядит что урон применился после того, как UI обновил полоску здоровья, поэтому игрок некоторое время видит старое значение, а на слабом железе видит его еще дольше и таких багов и зависимостей становится все больше и больше, и вам нужен отдельный человек, чтобы следить за асинхронными системами.
// скучно, зато весь кадр помещается в голову
void Tick(float dt) {
input.Poll();
player.Move(dt);
enemies.Move(dt);
physics.Step(dt);
combat.ResolveDamage(); // урон считается здесь и только здесь
world.RemoveDead();
hud.Refresh(); // UI всегда видит финальное состояние
}
А в событийной версия порядок определяется тем, кто в какой момент успел подписаться, а это, в свою очередь, определяется порядком загрузки сцены, который меняется при добавлении префаба или системы. Но как только вам понадобилось настраивать порядок выполнения скриптов, вы уже потеряли контроль над кадром и теперь приходится договариваться с разными системами, делать посредников, заводить фейковые сущность. Просто запомните, что один явный Tick в большинстве случаев дешевле любых настроек.
Платить приходится за всё и линейный Tick растёт вместе с игрой, становясь в какой‑то момент размером в двести вызовов и половина из них условные. Это неприятно, но это можно прочитать сверху вниз за минуту, в отличие от графа подписок, который читается под отладчиком и только в конкретном запуске.
Ложная сложность

Всё вышеописанное имеет смысл только в том случае, если высвободившиеся силы уходят в саму игру, что к счастью почти всегда верно и силы разработчиков уходят в более интересные механики и «сочный» геймплей, что в докладах называют juice. На самом деле это с десяток дешёвых приёмов вроде тряски камеры, паузы на ударах, правильного масштабирования, частиц, отдельный звук на каждое событие. Ни один из этих приёмов не требует ни архитектуры, ни сложных технических систем… но все они требуют времени на подбор чисел, и вдумчивого тестирования на живом игроке.
А сложная система с пулом на десять тысяч юнитов, потребует профилирования и неординарного инженерного мышления, но никак не повлияет на fps игры, потому что fps это нелинейная величина и падение со 120 до 90 стоит вам 2.8 мс, а падение с 30 до 25 стоит 6.7 мс. В реальности это вообще разные величины и ваши выигранные 0.2мс дадут оптимизацию меньше одного процента фпс и займут пять процентов кода, потому что ваша интуиция про горячие места ошиблась до запуска профайлера.
Где сложность оправдана так это системы сборки и тестирования, и автоматическая сборка с воспроизводимым билдом, автотестами, соблюдением платформенных требований, которые у Sony, Microsoft и Nintendo разные — всё это скучно, всё это не видно в трейлере, не видно программистам и дизайнерам, но та работа, которая отделяет «мы сделали игру» от «игра вышла». Можно сделать игру, но не выпустить её в срок или попасть на штрафы паблишера, провалив сертификацию из‑за неправильной обработки отключения геймпада. Согласитесь это намного обиднее, чем не написать свою ECS.
Где простая архитектура ломается
Было бы нечестно закончить на том, что надо просто писать попроще и всё будет хорошо. Не будет, потому что у простоты есть свои проблемы, а вот мириться с ними или нет, уже выбирает каждый сам.
Простой код со временем превращается в болото от отсутствия границ. И даже минимальный набор границ окупается всегда и везде, а стоит примерно ничего: чтобы данные игры были отдельно от игровой логики, игровая логика отдельно от представления, а представления от пользовательского интерфейса. У вас получается ноль интерфейсов, но уже нельзя случайно поменять здоровье игрока из обработчика нажатия кнопки.
И еще сохранения… это, пожалуй, единственное место, где я готов защищать проектирование заранее, потому что тут вы будете обязаны сделать обратную совместимость, а её не сделать нормально без хорошей архитектуры. И вам в любом случае придется её делать, потому что игроку, который потратил сорок часов и потерявший сейв… мягко скажем, может очень много накинуть на вентилятор, а если таких игроков не один, и даже не сто?
Все остальное может настолько дубово, насколько у вас хватит совести. Можете спросить у автора очень занимательной игрушки VVVVVV, игра отличная, но опять сделана из субстанций и деревяшек. (github.com/TerryCavanagh/VVVVVV/blob/master/desktop_version/src/Logic.cpp)

Архитектура это инструмент
Игра как код существует только в вашей голове. Игрок не откроет репозиторий, и не узнает, был ли у вас ECS, использовался попахивающий интерфейс, и как часто вы мучалиGameManager в рендере. Ресурс времени разработчика ограничен и его надо тратить не на сложность, а на то, что видно на экране, и понимать, что любое красивое инженерное решение имеет свою цену. Вы видели в рецензиях Steam отзыв «Геймплей отличный, визуал потрясающий, но за синглтон в игровом цикле ставлю двух мейерсов из десяти»? Вот и я не видел…
Автор: dalerank

