Как устроен Master Server в моей MMO-выживалке: Heartbeat, Redis, UDP-пинг и потоковое обновление списка

Как устроен Master Server в моей MMO-выживалке: Heartbeat, Redis, UDP-пинг и потоковое обновление списка

Привет! Я делаю Skwolets уже больше двух лет. Это мой первый выход из тени, потому что эти два года я учился техническому английскому, C++, Unreal Engine API, а также сотням терминов и борьбе с паническими атаками из-за этого.

Практически каждая многопользовательская игра начинается с одного простого действия — игрок открывает серверный браузер.

Несколько кликов, список серверов появляется на экране, остаётся выбрать подходящий и нажать «Подключиться».

Со стороны игрока всё выглядит элементарно.

Но за этим списком скрывается отдельная инфраструктура, которая должна постоянно знать, какие серверы сейчас работают, сколько игроков находится на каждом из них, есть ли свободные места, можно ли подключиться прямо сейчас и что делать, если сервер внезапно выключился.

Когда я начал работать над мультиплеером Skwolets, я решил не использовать готовые мастер-серверы и написать собственную систему полностью с нуля.

Сегодня хочу приоткрыть ширму и показать, как устроена эта система, какие задачи она решает и почему некоторые решения оказались эффективнее стандартного подхода.

Почему вообще собственный Master Server?

В этой статье я сосредоточусь на технической реализации.

Если интересны причины выбора собственной инфраструктуры и её влияние на игру, об этом можно почитать в моём девлоге:

Спойлер: чужие решения — это чужие ограничения. Я не хочу просыпаться утром и узнавать, что игроки не могут зайти, потому что внешний сервис решил сменить политику.

Общая архитектура

В упрощённом виде система выглядит так.

Игровой сервер │ │ Heartbeat ▼ Мастер сервер │ │ REST API ▼ Клиент │ │ UDP Ping ▼ Серверный браузер

Игровые серверы постоянно сообщают мастер-серверу своё состояние.

Клиент получает только список известных серверов.

После этого самостоятельно проверяет их доступность и отображает результат игроку.

Heartbeat

После запуска каждый игровой сервер регистрируется в системе.

Затем через определённый интервал начинает отправлять Heartbeat.

Каждый Heartbeat фактически подтверждает, что игровой сервер всё ещё работает и его состояние остаётся актуальным.

Это обычный HTTP-запрос с текущим состоянием сервера.

Например:

{ "name": "RU Hardcore #1", "ip": "209.220.115.5", "port": 7777, "players": 57, "slots": 80, "queue": 0, "is_day": true, "free_slot": true, "last_update": 1785587898 }

Мастер-сервер обновляет запись в Redis и продлевает её TTL (Time-To Live)

Если Heartbeat перестаёт приходить — Redis удаляет запись автоматически.

Никаких ручных действий.

Никаких "висящих" серверов в списке.

Если игровой сервер выключился — игрок его больше не увидит.

Почему Redis, а не глобальная переменная?

Master Server работает на Gunicorn с несколькими воркерами. Каждый воркер — отдельный процесс. Если хранить состояние в памяти приложения, данные не синхронизируются:

❌ Без Redis Worker 1 Worker 2 ┌──────────────┐ ┌──────────────┐ │ local_servers│ │ local_servers│ │ = { │ │ = {} │ │ "ru-01": {} │ │ │ │ } │ │ │ └──────────────┘ └──────────────┘ ↑ ↑ Heartbeat Запрос клиента ✅ Обновил ❌ Не видит сервер

Воркер 1 получил Heartbeat → обновил свой словарь. Воркер 2 обрабатывает запрос клиента → не видит этот сервер, потому что данные лежат в памяти воркера 1.

Redis решает эту проблему:

✅ С Redis Worker 1 Worker 2 ┌──────────────┐ ┌──────────────┐ │ │ │ │ │ Пишет/ │ │ Читает/ │ │ читает │ │ читает │ └──────┬───────┘ └──────┬───────┘ │ │ └──────────┬────────────┘ ↓ ┌────────────────┐ │ REDIS │ │ servers = { │ │ "ru-01": {} │ │ } │ └────────────────┘

Любой воркер может читать и записывать данные в Redis, и они будут видны всем остальным.

Ключевая фича Redis: каждая запись имеет TTL (например, 30 секунд). Пока Heartbeat приходит регулярно (каждые 5-10 секунд), TTL продлевается. Если Heartbeat прекращается — Redis удаляет запись автоматически, без дополнительной логики очистки на бэкенде.

Это позволяет не запускать отдельные процессы очистки и всегда работать только с актуальными данными при высокой производительности.

Получение списка серверов

Когда игрок открывает серверный браузер, клиент выполняет обычный HTTP-запрос к Master Server.

GET /api/servers

После успешного ответа — клиент парсит и сохраняет.

void UServerBrowser::OnServersReceived(FHttpResponsePtr Response) { TSharedPtr<FJsonObject> JsonObject; TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Response->GetContentAsString()); if (FJsonSerializer::Deserialize(Reader, JsonObject)) { TArray<TSharedPtr<FJsonValue>> ServersArray = JsonObject->GetArrayField("servers"); for (auto& ServerValue : ServersArray) { TSharedPtr<FJsonObject> ServerObject = ServerValue->AsObject(); FServerInfo Info; Info.Name = ServerObject->GetStringField("name"); Info.IP = ServerObject->GetStringField("ip"); Info.Port = ServerObject->GetNumberField("port"); Info.Players = ServerObject->GetNumberField("players"); Info.Slots = ServerObject->GetNumberField("slots"); Info.Queue = ServerObject->GetNumberField("queue"); Info.bIsDay = ServerObject->GetBoolField("is_day"); Info.bIsFreeSlot = ServerObject->GetBoolField("free_slot"); Info.LastUpdate = ServerObject->GetNumberField("last_update"); // Сохраняем в список CachedServers.Emplace(MoveTemp(Info)); } } }

В ответ приходит список всех зарегистрированных серверов.

Но на этом работа не заканчивается.

HTTP говорит лишь о том, что мастер знает о существовании сервера.

Это ещё не означает, что игровой сервер действительно доступен именно сейчас.

Поэтому начинается следующий этап — UDP пинг.

Почему UDP, а не TCP?

Когда клиент получает список серверов от мастера, его задача
— быстро выяснить, какие из них действительно работают и
с какой задержкой. Для этого он шлёт UDP-пакет с запросом
каждому серверу из списка.


Использовать TCP было бы не прагматично, потому что TCP — это как полноценный телефонный разговор:

сначала нужно позвонить,

дождаться ответа,

представиться,

и только потом говорить по делу.

А UDP — это как крикнуть в окно и услышать ответ: быстро и просто.

Как это работает на клиенте (Unreal Engine):

// Отправка UDP Ping FIPv4Endpoint Endpoint = FIPv4Endpoint(FIPv4Address(ServerIP), ServerPort); // Magic number: 'PING' (0x50 0x49 0x4E 0x47) // Используется для идентификации пакета на стороне сервера uint8 PingData[] = { 'P', 'I', 'N', 'G' }; int32 BytesSent = 0; Socket->SendTo(PingData, 4, BytesSent, Endpoint); // Сохраняем время отправки для расчета пинга double SendTime = FPlatformTime::Seconds(); // Замер времени между Send и Receive float Ping = (FPlatformTime::Seconds() - SendTime) * 1000.0f; // мс

Именно поэтому для проверки доступности игровых серверов используется UDP — он даёт реальную задержку без лишних накладных расходов.

Потоковое обновление списка

Самая интересная часть браузера — обновление интерфейса.

Сначала я попробовал сделать так:

Получить список ↓ Отправить все пинги ↓ Дождаться всех ответов ↓ Отсортировать ↓ Показать игроку

Работает. Но UX выглядит не очень — интерфейс пустой, потом резко заполняется. Это создаёт ощущение тормозов.

Я сделал иначе.

После каждого успешного UDP-ответа клиент сразу создаёт элемент сервера и вставляет его в уже отсортированный список.

То есть схема выглядит так:

Получить HTTP-список ↓ Запустить UDP-пинги параллельно ↓ RU #1 → 20 мс → Ответ ✅ → Insert() в список RU #2 → 50 мс → Ответ ✅ → Insert() RU #3 → 150 мс → Ответ ✅ → Insert() RU #4 → таймаут (3 сек) → ❌ Пропускаем

Если сервер отвечает через 20 мс — он появляется через 20 мс.

Если другой отвечает через секунду — он аккуратно вставляется на своё место, не нарушая уже отображённый список.

В коде виджета это выглядит примерно так:

UServerListEntryObject* EntryObject = CreateEntryObject(ServerInfo); const int32 InsertIndex = FindInsertIndex(EntryObject->GetEntry()); ListEntryObjectArray.Insert(EntryObject, InsertIndex); // Обновляем UI только для вставки, а не весь список RefreshListViewItem(InsertIndex);

Результат: время обновления каждого элемента зависит только от времени ответа конкретного сервера, а не от завершения проверки всего списка.

Чтобы избежать постоянной перерисовки, список никогда полностью не пересоздаётся. Новый сервер просто вставляется в уже отсортированный массив.

Почему Slate, а не UMG?

Сам браузер полностью написан на Slate.

Без Blueprint.

Без UMG.

Основные элементы реализованы через SWidget и SBorder, а взаимодействие компонентов построено на TDelegate и TFunction.

Это позволило полностью контролировать жизненный цикл элементов интерфейса и избежать лишних аллокаций при постоянном обновлении списка.

Для постоянно обновляющегося списка серверов такой подход оказался значительно удобнее.

Финальным этапом я реализовал централизованную систему обработки ошибок с числовой кодировкой на мастер-сервере. Это позволяет быстро парсить логи, строить мониторинг и отслеживать проблемные сценарии.

enum class EErrorCode : int32 { TooManyRequests = 1300 // ... другие коды }; static FText GetMessage(EErrorCode Code) { switch (Code) { case EErrorCode::TooManyRequests: return NSLOCTEXT("Error", "TooManyRequests", "Too many requests"); default: return NSLOCTEXT("Error", "Unknown", "Unknown error"); } }

На клиенте (C++) код ошибки декодируется в локализованное пользовательское сообщение с выводом в кастомное окно ошибки.

Такой подход обеспечивает единую точку контроля ошибок, упрощает добавление новых языков и не требует пересборки сервера при изменении текстов или UX-поведения ошибок.

Как устроен Master Server в моей MMO-выживалке: Heartbeat, Redis, UDP-пинг и потоковое обновление списка

Итог

+----------------------+ | Мастер сервер | | Flask + Gunicorn | | Redis (TTL) | +----------+-----------+ | REST API (HTTP) | +----------+----------------------+ | | v v +----------------+ +----------------+ | Игровой сервер | | Игровой сервер | | (UE Server) | | (UE Server) | | Heartbeat JSON | | Heartbeat JSON | +----------------+ +----------------+ ^ | UDP Ping (проверка доступности) | +------------------------------------------+ | Клиент (Unreal Engine) | | - HTTP запрос к Master Server | | - UDP Ping к каждому серверу | | - Потоковая вставка в Slate-список | +------------------------------------------+

Сегодня игрок открывает серверный браузер и видит обычный список серверов.

Но за этим списком работает отдельная инфраструктура: Heartbeat, хранение состояния, REST API, Redis, Gunicorn с воркерами, UDP-проверка доступности и потоковое обновление интерфейса.

Именно эта система отвечает за то, чтобы игрок видел только актуальные серверы, быстро получал информацию об их состоянии и мог подключиться без лишних задержек.

Для меня это был один из самых интересных этапов разработки Skwolets, потому что удалось не просто реализовать очередной игровой интерфейс, а построить фундамент, на котором в дальнейшем можно развивать новые сетевые возможности игры.

P.S. Если у вас есть опыт в создании серверной инфраструктуры для игр, буду рад обсудить ваши решения и подходы в комментариях. Как вы решали проблему масштабирования списка серверов? 👇

5