Steam показал мою игру 480 тысяч раз. В ней было нечего делать — поэтому я переделал её под мультиплеер

У моей игры в Steam было 480 439 показов, 49 788 переходов на страницу и CTR 10,4%.
И восемь отзывов.
На первый взгляд виновата витрина: слабые скриншоты, старый трейлер, описание из трёх абзацев. Всё это действительно было плохо. Но один из игроков сформулировал проблему точнее: трассы маленькие и повторяются, цели нет, достижения поверхностные, а обещанная «бесконечная пустыня» заканчивается ощущением короткого кольца.
Он был прав.
Я выпустил Drift Desert Race как игру-мем: садишься в машину и восемь часов едешь через пустыню, чтобы в конце узнать смысл жизни. Идея объясняется одной фразой, хорошо выглядит в ролике и почти не отвечает на главный вопрос игры:
А что игрок должен делать на второй минуте?
Я мог заменить пару моделей, добавить ещё одну машину и назвать это большим обновлением. Вместо этого начал переделывать проект в кооперативный road trip на 2–4 человека: с короткими поездками, общей дорогой, случайными событиями, нормальной картой и миром, который постепенно сходит с ума.
Под катом — не история «я добавил мультиплеер за выходные». Здесь Steam Lobby, P2P, дорога с floating origin, детерминированная генерация и баг, из-за которого соединение между двумя компьютерами было идеальным, ping составлял 2 мс, а игра всё равно не могла передать даже кнопку Ready.

Что именно было не так с первой версией
В оригинальной игре была одна сильная идея: восьмичасовая поездка.
Она же была её главной проблемой.
Человек покупал игру, выбирал машину, выезжал на дорогу и довольно быстро видел почти всё, что игра могла ему показать. Восемь часов не создавали глубину — они растягивали отсутствие событий. Это как сделать загрузочный экран длиной в полный рабочий день и назвать его endurance-механикой.
У меня уже были работающая физика автомобиля, таймер, Steam Achievements и лидерборды. Формально список возможностей выглядел нормально. Игрового цикла внутри него не было.
Особенно неприятно это выглядело рядом со статистикой страницы:
Метрика | Значение |
|---|---|
Показы в Steam | 480 439 |
Переходы на страницу | 49 788 |
CTR | 10,4% |
Отзывы на момент разбора | 8 |
CTR говорил, что обложка и сама идея заставляют людей кликнуть. Количество отзывов — что дальше я их не убеждаю.
Это не точная воронка продаж: показы, посещения и отзывы измеряют разные этапы и сами по себе не объясняют, где ушёл каждый человек. Но разрыв был достаточно большим, чтобы перестать обвинять алгоритмы Steam и посмотреть на игру.
Решение: не делать маленькую Forza
Первая реакция была предсказуемой: больше машин, лучше физика, больше трасс, тюнинг.
То есть попробовать в одиночку конкурировать с играми, у которых сотни разработчиков, лицензированные автомобили и годы производства.
Отличный план, если хочется гарантированно не закончить проект.
Я выбрал другое направление:
Сможете ли вы с друзьями восемь часов ехать через бесконечную пустыню, пока мир постепенно сходит с ума?
Восьмичасовой режим остался, но перестал быть единственным:
Quick Ride — 15 минут;
Desert Run — 45 минут;
Endurance — 2 часа;
Ultimate Journey — 8 часов;
Free Ride — без финиша.
Все режимы используют одну сцену и один таймер. Длительность — данные выбранной поездки, а не очередная ветка if (ultimate) duration = 28800.
Это кажется мелочью, но раньше вся игра была построена вокруг магического числа 28800f. Теперь короткий режим можно пройти, протестировать и дать человеку, который не планировал посвятить инди-игре смену на заводе.

Инцидент 1. Лобби работает, игроки видят друг друга, игра не начинается
Для мультиплеера я взял Steam Lobby и ISteamNetworkingMessages. Без выделенного сервера: один игрок создаёт комнату и становится host, остальные подключаются к нему через Steam P2P.
Host отвечает за:
состояние комнаты;
выбранную поездку и карту;
seed мира;
время сессии;
запуск и завершение игры.
Каждый игрок локально считает физику своей машины. Положение отправляется остальным 25 раз в секунду, а удалённая машина отображается с интерполяционной задержкой 120 мс. Старый snapshot не подтверждается и не пересылается: через 40 мс всё равно придёт новый.
На бумаге всё было готово. Unit-тесты проходили. С одним Steam-аккаунтом создавалось лобби, обновлялись metadata, работал поиск комнат.
На первом тесте с двумя настоящими аккаунтами игрок вошёл в комнату — и всё развалилось.
Статусы не обновлялись. Kick мигал. Host не мог начать игру. Сессия то принималась, то отваливалась по таймауту.
Причин оказалось несколько.
Первая: запрос P2P приходил раньше callback Steam о новом участнике. Host проверял список комнаты, не находил там отправителя и честно считал его посторонним.
Вторая: после десяти секунд тишины код удалял peer не только из сетевой сессии, но и из собственного whitelist. Человек всё ещё находился в Steam Lobby, однако игра уже решила, что его не существует.
Третья: heartbeat отправлялся с флагом UnreliableNoDelay. Steam имеет право выбросить такой пакет, пока маршрут ещё строится. Пакет, который должен был оживить соединение, не отправлялся именно потому, что соединение ещё не ожило.
Я исправил порядок допуска peer, разделил roster комнаты и состояние транспорта, добавил повторную отправку состояния игрока и автоперезапуск сломанной сессии.
Следующий тест показал Connected, ping 2 мс и входящие сообщения.
Кнопка Ready всё ещё не работала.
Инцидент 2. Я отправлял по сети только заголовки пакетов
У каждого сообщения был общий заголовок: версия протокола, тип, sequence, sender, tick и timestamp. Затем callback дописывал payload конкретного сообщения.
Упрощённо код выглядел так:
void Send(MessageType type, Action<NetworkWriter> writePayload)
{
var writer = new NetworkWriter(buffer);
WriteHeader(writer, type);
writePayload(writer);
transport.Send(writer.ToArray());
}
NetworkWriter был struct.
Action<NetworkWriter> получал его по значению.
Payload исправно записывался — в копию writer, которая сразу исчезала. В сеть уходил только header. Поэтому транспорт видел пакеты и считал соединение живым, а Ready, выбранная машина и GameStart оставались пустыми.
Снаружи это выглядело особенно убедительно:
Connecting -> FindingRoute -> Connected
Ping: 2 ms
Messages received: yes
Game state synchronized: no
Починил передачей writer по ссылке:
delegate void PayloadWriter(ref NetworkWriter writer);
void Send(MessageType type, PayloadWriter writePayload)
{
var writer = new NetworkWriter(buffer);
WriteHeader(ref writer, type);
writePayload(ref writer);
transport.Send(writer.ToArray());
}
И добавил тест, который проверяет не только то, что пакет «отправился», но и то, что после заголовка действительно существует payload и он читается обратно.
Вывод неприятный, но полезный: состояние Steam-сессии ничего не говорит о состоянии вашей игры. Connected подтверждает маршрут между компьютерами. Оно не подтверждает, что вы сериализовали хотя бы один байт полезных данных.
Чтобы экран комнаты не зависел от P2P, готовность, машина, journey и команда старта теперь дублируются через lobby metadata. P2P остаётся нужен для движения автомобилей, но потеря одного пакета больше не превращает кнопку Start в декоративный элемент.
Инцидент 3. Бесконечную дорогу нельзя хранить в координатах Unity
Старая карта была короткой и повторяющейся. Если просто вытянуть её на восемь часов, появляется другая проблема: чем дальше объект уезжает от начала координат, тем меньше точность float. Физика начинает дрожать, колёса живут своей жизнью, камера подтверждает существование четвёртого измерения.
Поэтому мир теперь состоит из индексированных сегментов:
segment -2, -1, 0, 1, 2, 3 ...
В памяти находится только окно вокруг машин. Сегменты берутся из пула и возвращаются туда же — без постоянного Instantiate/Destroy во время поездки.
Когда игрок уезжает примерно на несколько километров, FloatingOrigin переносит весь активный мир обратно ближе к нулю. Но постоянная позиция машины хранится не в Unity-координатах, а в логических:
logical position = local position + accumulated origin offset
Именно логическая позиция уходит по сети. Иначе два компьютера после разных origin shift считали бы, что одна машина одновременно находится в разных местах.
Тут нашёлся отдельный визуальный баг. Световые следы RCC хранили точки в мировых координатах. После переноса мира на пять километров старые точки оставались на месте, и от машины через половину карты тянулась красная линия.
Выглядело эффектно.
Особенно если не знать, что это баг.
Теперь trails очищаются при teleport и origin shift, причём несколько кадров подряд: интерполируемый rigidbody успевал ещё раз нарисоваться в старой позиции уже после первого сброса.
Одинаковый мир без синхронизации каждого кактуса
Отправлять по сети положение каждого камня, знака и заброшенной заправки бессмысленно. Мир строится как чистая функция:
content = Generate(worldSeed, segmentIndex)
Host передаёт seed один раз. Каждый peer независимо получает ту же геометрию дороги, landmark и декорации для конкретного индекса.
Сейчас на дороге есть rest stop, заправка, кладбище машин, разбитый самолёт, мотель, город-призрак и другие ориентиры. Соседние сегменты не выбирают один и тот же landmark, а число активных объектов остаётся ограниченным длиной окна, а не пройденным расстоянием.
Карта при этом стала палитрой. Та же система дороги и streaming работает для пустыни, зимы и Японии. В комнате карту выбирает host; её ID передаётся вместе с seed и командой старта.


Это дешевле, чем делать три отдельные игры внутри игры, и надёжнее для сети: меняются материалы, декорации и набор landmark, но не фундамент мира.
Дорога должна не только продолжаться, но и отвечать
Процедурная генерация сама по себе не создаёт контент. Если бесконечно переставлять одни и те же кактусы, получится очень длинная пустота.
Поэтому поверх дороги появился RandomEventSystem:
Sandstorm;
Wrong Way Driver;
Engine Trouble;
Radio Signal;
The Bus;
UFO.
После каждого события следующее предупреждение появляется через 40–120 секунд. Расписание зависит от seed и host-time, поэтому у всех игроков событие начинается в одной точке путешествия.
Сами события разделены на definition, scheduling и presentation. Планировщик можно тестировать без сцены, Steam и автомобиля. Визуальный слой уже решает, включить ли туман, создать встречную машину или показать предупреждение.
В короткой поездке игра старается сначала показать разные события, а не пять раз подряд сломать двигатель. В длинной растёт уровень странности: сначала это песчаная буря и странный сигнал, позже — автобус, НЛО и вещи, которые сложнее объяснить дорожными работами.

Главная цель не в том, чтобы каждое событие было отдельной AAA-миссией. Оно должно нарушить ритм, заставить друзей заговорить и создать момент, который можно вырезать в короткий ролик.
Четыре человека спокойно едут сорок минут. Затем сзади появляется автобус на скорости 180 км/ч.
Вот это уже игровой эпизод. Ещё один перекрашенный автомобиль — нет.
Что ещё пришлось переделать
Мультиплеер быстро вытянул за собой половину проекта.
Меню и комнаты. Появились создание и поиск Steam Lobby, Friends Only и Private, приглашения, выбор машины, карты и поездки, ready-state и понятные состояния CONNECTING / READY / NOT READY. Строки интерфейса больше не разбросаны литералами по MonoBehaviour.
Пауза. В одиночной игре она останавливает время. В мультиплеере — только снимает локальное управление. Иначе один человек нажал Escape, а пустыня замерла у всех четырёх.
Late join. Игрок, вошедший в уже начатую поездку, получает snapshot: journey, seed, прошедшее время, состояние комнаты и roster. Его локальное время присутствия начинается с нуля, поэтому он не получает часовое достижение за семь часов, которые проехал host.
Машины. Новые low-poly модели адаптируются через собственные компоненты поверх RCC. Vendor-код не переписывается под каждую идею проекта.
Локализация. Каталоги подготовлены для английского, русского, упрощённого китайского, немецкого, испанского, бразильского португальского, французского, японского, корейского и польского. Автоматическая проверка ловит пропущенный ключ и несовпавшие параметры форматирования.
Сборка и диагностика. Появились проверки сцен, journey-каталога, локализации, сетевой smoke test и отдельный UI/world tour, который сам проходит экраны и фотографирует карту. После случая с «исправил код, загрузил старый build» скрипт Steam upload отказывается отправлять сборку, которая старше исходников.
Последний пункт родился не из любви к инфраструктуре.
Я действительно один раз тестировал исправление, которого физически не было в загруженном exe.
Почему я не называю мультиплеер готовым
Код умеет создать комнату, синхронизировать настройки, загрузить общую карту, передавать snapshots автомобилей, восстанавливать состояние late joiner и переживать отключение клиента.
Но полный приёмочный прогон между двумя Steam-аккаунтами после последних исправлений ещё нужен. Также нужны тесты с задержкой и потерей пакетов, disconnect посреди заезда и восьмичасовой soak test.
Есть и игровые системы, которые пока существуют только как фундамент:
drift scoring посчитан и покрыт тестами, но ещё не подключён к машине и HUD;
Stay Together и Convoy имеют host-authoritative lifecycle, но не встроены в реальный заезд;
интерфейс leaderboards и offline fallback готовы, Steam adapter ещё не написан;
переводы требуют визуальной проверки шрифтов и ручной редакторской вычитки;
collision удалённых машин работает через запаздывающую позу и пока не имеет prediction.
Это важное различие. «Реализовано» означает, что код существует. «Проверено» — что два человека действительно прошли через него от кнопки Create Room до финиша и получили ожидаемый результат.
После истории с writer-копией я стараюсь не путать эти слова.
Что в итоге изменилось
До переработки Drift Desert Race была восьмичасовой шуткой с таймером.
Теперь это каркас кооперативного road trip:
Lobby
-> выбор journey, карты и машин
-> общий seed
-> детерминированная дорога
-> landmarks и события
-> короткий финиш или восьмичасовое испытание
У проекта появилась не просто длина, а темп. Человек может получить законченную поездку за 15 минут. Восемь часов остаются экстремальным режимом, но больше не прикрывают собой отсутствие выбора.
А ещё я наконец понял разницу между продающей идеей и игровой петлёй.
«Ехать восемь часов ради смысла жизни» — продающая идея.
«Удержать конвой, пережить песчаную бурю, не дать автобусу разнести друзей, доехать до следующей странности и сравнить результат» — потенциальная игровая петля.
Первой достаточно для клика.
Для игры нужна вторая.
Выводы, которые я бы хотел знать до релиза
Мем объясняет игру, но не заменяет её. Если концепт помещается в одну фразу, это хорошо для страницы Steam. На второй минуте всё равно нужна цель.
Длинная сессия начинается с короткой. Пока 15 минут не работают, растягивать их до восьми часов бессмысленно.
Connected— не тест протокола. Проверяйте round trip каждого payload, а не только состояние транспорта.Процедурный мир должен быть детерминированным. Особенно если завтра в него войдёт второй компьютер.
Не синхронизируйте то, что можно одинаково вычислить. Seed и индекс сегмента дешевле тысячи сетевых объектов.
Честный список незаконченного полезнее roadmap на двадцать обещаний. Он не даёт принять собранный фундамент за готовую игру.
Сейчас обновление уже гораздо ближе к той игре, которую обещает название и страница Steam. Но главный тест впереди: захотят ли два человека после первой Quick Ride нажать ещё раз — и решатся ли однажды на Ultimate Journey.
Если хотите посмотреть, как выглядела исходная восьмичасовая идея и во что она превращается, вот страница Drift Desert Race в Steam.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.