У моей игры в Steam было 480 439 показов, 49 788 переходов на страницу и CTR 10,4%.

И восемь отзывов.

На первый взгляд виновата витрина: слабые скриншоты, старый трейлер, описание из трёх абзацев. Всё это действительно было плохо. Но один из игроков сформулировал проблему точнее: трассы маленькие и повторяются, цели нет, достижения поверхностные, а обещанная «бесконечная пустыня» заканчивается ощущением короткого кольца.

Он был прав.

Я выпустил Drift Desert Race как игру-мем: садишься в машину и восемь часов едешь через пустыню, чтобы в конце узнать смысл жизни. Идея объясняется одной фразой, хорошо выглядит в ролике и почти не отвечает на главный вопрос игры:

А что игрок должен делать на второй минуте?

Я мог заменить пару моделей, добавить ещё одну машину и назвать это большим обновлением. Вместо этого начал переделывать проект в кооперативный road trip на 2–4 человека: с короткими поездками, общей дорогой, случайными событиями, нормальной картой и миром, который постепенно сходит с ума.

Под катом — не история «я добавил мультиплеер за выходные». Здесь Steam Lobby, P2P, дорога с floating origin, детерминированная генерация и баг, из-за которого соединение между двумя компьютерами было идеальным, ping составлял 2 мс, а игра всё равно не могла передать даже кнопку Ready.

Новая витрина Drift Desert Race

Что именно было не так с первой версией

В оригинальной игре была одна сильная идея: восьмичасовая поездка.

Она же была её главной проблемой.

Человек покупал игру, выбирал машину, выезжал на дорогу и довольно быстро видел почти всё, что игра могла ему показать. Восемь часов не создавали глубину — они растягивали отсутствие событий. Это как сделать загрузочный экран длиной в полный рабочий день и назвать его 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 минут. Восемь часов остаются экстремальным режимом, но больше не прикрывают собой отсутствие выбора.

А ещё я наконец понял разницу между продающей идеей и игровой петлёй.

«Ехать восемь часов ради смысла жизни» — продающая идея.

«Удержать конвой, пережить песчаную бурю, не дать автобусу разнести друзей, доехать до следующей странности и сравнить результат» — потенциальная игровая петля.

Первой достаточно для клика.

Для игры нужна вторая.

Выводы, которые я бы хотел знать до релиза

  1. Мем объясняет игру, но не заменяет её. Если концепт помещается в одну фразу, это хорошо для страницы Steam. На второй минуте всё равно нужна цель.

  2. Длинная сессия начинается с короткой. Пока 15 минут не работают, растягивать их до восьми часов бессмысленно.

  3. Connected — не тест протокола. Проверяйте round trip каждого payload, а не только состояние транспорта.

  4. Процедурный мир должен быть детерминированным. Особенно если завтра в него войдёт второй компьютер.

  5. Не синхронизируйте то, что можно одинаково вычислить. Seed и индекс сегмента дешевле тысячи сетевых объектов.

  6. Честный список незаконченного полезнее roadmap на двадцать обещаний. Он не даёт принять собранный фундамент за готовую игру.

Сейчас обновление уже гораздо ближе к той игре, которую обещает название и страница Steam. Но главный тест впереди: захотят ли два человека после первой Quick Ride нажать ещё раз — и решатся ли однажды на Ultimate Journey.

Если хотите посмотреть, как выглядела исходная восьмичасовая идея и во что она превращается, вот страница Drift Desert Race в Steam.