Как устроен Master Server в моей MMO-выживалке: Heartbeat, Redis, UDP-пинг и потоковое обновление списка
Привет! Я делаю Skwolets уже больше двух лет. Это мой первый выход из тени, потому что эти два года я учился техническому английскому, C++, Unreal Engine API, а также сотням терминов и борьбе с паническими атаками из-за этого.
Практически каждая многопользовательская игра начинается с одного простого действия — игрок открывает серверный браузер.
Несколько кликов, список серверов появляется на экране, остаётся выбрать подходящий и нажать «Подключиться».
Со стороны игрока всё выглядит элементарно.
Но за этим списком скрывается отдельная инфраструктура, которая должна постоянно знать, какие серверы сейчас работают, сколько игроков находится на каждом из них, есть ли свободные места, можно ли подключиться прямо сейчас и что делать, если сервер внезапно выключился.
Когда я начал работать над мультиплеером Skwolets, я решил не использовать готовые мастер-серверы и написать собственную систему полностью с нуля.
Сегодня хочу приоткрыть ширму и показать, как устроена эта система, какие задачи она решает и почему некоторые решения оказались эффективнее стандартного подхода.
Почему вообще собственный Master Server?
В этой статье я сосредоточусь на технической реализации.
Если интересны причины выбора собственной инфраструктуры и её влияние на игру, об этом можно почитать в моём девлоге:
Спойлер: чужие решения — это чужие ограничения. Я не хочу просыпаться утром и узнавать, что игроки не могут зайти, потому что внешний сервис решил сменить политику.
Общая архитектура
В упрощённом виде система выглядит так.
Игровые серверы постоянно сообщают мастер-серверу своё состояние.
Клиент получает только список известных серверов.
После этого самостоятельно проверяет их доступность и отображает результат игроку.
Heartbeat
После запуска каждый игровой сервер регистрируется в системе.
Затем через определённый интервал начинает отправлять Heartbeat.
Каждый Heartbeat фактически подтверждает, что игровой сервер всё ещё работает и его состояние остаётся актуальным.
Это обычный HTTP-запрос с текущим состоянием сервера.
Например:
Мастер-сервер обновляет запись в Redis и продлевает её TTL (Time-To Live)
Если Heartbeat перестаёт приходить — Redis удаляет запись автоматически.
Никаких ручных действий.
Никаких "висящих" серверов в списке.
Если игровой сервер выключился — игрок его больше не увидит.
Почему Redis, а не глобальная переменная?
Master Server работает на Gunicorn с несколькими воркерами. Каждый воркер — отдельный процесс. Если хранить состояние в памяти приложения, данные не синхронизируются:
Воркер 1 получил Heartbeat → обновил свой словарь. Воркер 2 обрабатывает запрос клиента → не видит этот сервер, потому что данные лежат в памяти воркера 1.
Redis решает эту проблему:
Любой воркер может читать и записывать данные в Redis, и они будут видны всем остальным.
Ключевая фича Redis: каждая запись имеет TTL (например, 30 секунд). Пока Heartbeat приходит регулярно (каждые 5-10 секунд), TTL продлевается. Если Heartbeat прекращается — Redis удаляет запись автоматически, без дополнительной логики очистки на бэкенде.
Это позволяет не запускать отдельные процессы очистки и всегда работать только с актуальными данными при высокой производительности.
Получение списка серверов
Когда игрок открывает серверный браузер, клиент выполняет обычный HTTP-запрос к Master Server.
После успешного ответа — клиент парсит и сохраняет.
В ответ приходит список всех зарегистрированных серверов.
Но на этом работа не заканчивается.
HTTP говорит лишь о том, что мастер знает о существовании сервера.
Это ещё не означает, что игровой сервер действительно доступен именно сейчас.
Поэтому начинается следующий этап — UDP пинг.
Почему UDP, а не TCP?
Когда клиент получает список серверов от мастера, его задача
— быстро выяснить, какие из них действительно работают и
с какой задержкой. Для этого он шлёт UDP-пакет с запросом
каждому серверу из списка.
Использовать TCP было бы не прагматично, потому что TCP — это как полноценный телефонный разговор:
сначала нужно позвонить,
дождаться ответа,
представиться,
и только потом говорить по делу.
А UDP — это как крикнуть в окно и услышать ответ: быстро и просто.
Как это работает на клиенте (Unreal Engine):
Именно поэтому для проверки доступности игровых серверов используется UDP — он даёт реальную задержку без лишних накладных расходов.
Потоковое обновление списка
Самая интересная часть браузера — обновление интерфейса.
Сначала я попробовал сделать так:
Работает. Но UX выглядит не очень — интерфейс пустой, потом резко заполняется. Это создаёт ощущение тормозов.
Я сделал иначе.
После каждого успешного UDP-ответа клиент сразу создаёт элемент сервера и вставляет его в уже отсортированный список.
То есть схема выглядит так:
Если сервер отвечает через 20 мс — он появляется через 20 мс.
Если другой отвечает через секунду — он аккуратно вставляется на своё место, не нарушая уже отображённый список.
В коде виджета это выглядит примерно так:
Результат: время обновления каждого элемента зависит только от времени ответа конкретного сервера, а не от завершения проверки всего списка.
Чтобы избежать постоянной перерисовки, список никогда полностью не пересоздаётся. Новый сервер просто вставляется в уже отсортированный массив.
Почему Slate, а не UMG?
Сам браузер полностью написан на Slate.
Без Blueprint.
Без UMG.
Основные элементы реализованы через SWidget и SBorder, а взаимодействие компонентов построено на TDelegate и TFunction.
Это позволило полностью контролировать жизненный цикл элементов интерфейса и избежать лишних аллокаций при постоянном обновлении списка.
Для постоянно обновляющегося списка серверов такой подход оказался значительно удобнее.
Финальным этапом я реализовал централизованную систему обработки ошибок с числовой кодировкой на мастер-сервере. Это позволяет быстро парсить логи, строить мониторинг и отслеживать проблемные сценарии.
На клиенте (C++) код ошибки декодируется в локализованное пользовательское сообщение с выводом в кастомное окно ошибки.
Такой подход обеспечивает единую точку контроля ошибок, упрощает добавление новых языков и не требует пересборки сервера при изменении текстов или UX-поведения ошибок.
Итог
Сегодня игрок открывает серверный браузер и видит обычный список серверов.
Но за этим списком работает отдельная инфраструктура: Heartbeat, хранение состояния, REST API, Redis, Gunicorn с воркерами, UDP-проверка доступности и потоковое обновление интерфейса.
Именно эта система отвечает за то, чтобы игрок видел только актуальные серверы, быстро получал информацию об их состоянии и мог подключиться без лишних задержек.
Для меня это был один из самых интересных этапов разработки Skwolets, потому что удалось не просто реализовать очередной игровой интерфейс, а построить фундамент, на котором в дальнейшем можно развивать новые сетевые возможности игры.
P.S. Если у вас есть опыт в создании серверной инфраструктуры для игр, буду рад обсудить ваши решения и подходы в комментариях. Как вы решали проблему масштабирования списка серверов? 👇