Перейти к содержимому

Архитектура мультиплеера

Мультиплеер в En Parlant~ использует WebSocket-ретранслятор для соединения двух игроков в реальном времени. Прямого соединения между игроками нет — вся коммуникация проходит через ретранслятор. Это упрощает сетевое взаимодействие и обеспечивает надёжную работу за файрволами и NAT.

Player A (host) Relay Server Player B (joiner)
| | |
|--- create_game(name) ----------->| |
|<-- game_created(code) -----------| |
| |<--- join_game(code, name) -----|
|<-- game_joined(name) ------------|--- game_joined(name) -------->|
| | |
|--- game_move(uci, times) ------->|--- game_move(uci, times) --->|
|<-- game_move(uci, times) --------|<-- game_move(uci, times) ----|
| | |
|--- heartbeat ------------------>|--- peer_heartbeat ----------->|

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

  • Фронтенд (клиент): socket.io-client — стандартный JavaScript-клиент Socket.IO
  • Бэкенд (ретранслятор): socketioxide — Socket.IO сервер на Rust, построенный на Axum
  • Протокол: Socket.IO поверх WebSocket (с автоматическим откатом на HTTP long-polling при необходимости)

Socket.IO был выбран вместо чистого WebSocket, поскольку из коробки предоставляет автоматическое переподключение, управление комнатами/пространствами имён и структурированную обработку событий.

Хост нажимает Multiplayer и вводит своё отображаемое имя. Клиент отправляет событие create_game с именем. Сервер:

  1. Генерирует уникальный 6-символьный код комнаты
  2. Создаёт комнату и добавляет хоста как первого игрока
  3. Отвечает событием game_created(code), чтобы хост мог поделиться кодом

Присоединяющийся игрок вводит код комнаты и своё отображаемое имя. Клиент отправляет join_game(code, name). Сервер:

  1. Находит комнату по коду
  2. Добавляет присоединяющегося как второго игрока
  3. Отправляет game_joined(name) обоим игрокам — каждый получает отображаемое имя другого

Когда оба игрока в комнате:

  • Хост всегда играет белыми, присоединившийся — чёрными. Это определяется порядком подключения, а не согласованием.
  • Ходы отправляются в формате UCI (например, e2e4) вместе с показаниями часов обоих игроков.
  • Ретранслятор пересылает каждое событие game_move противнику. Оба игрока также получают свои собственные ходы обратно в качестве подтверждения.

Несколько событий обрабатывают сценарии завершения партии:

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

После окончания партии любой игрок может отправить событие ready, чтобы сообщить о желании сыграть реванш. Когда оба игрока отправили ready, клиенты сбрасывают доску и меняют цвета (или сохраняют их — это определяется на стороне клиента).

Коды комнат состоят из 6 символов и форматируются как XX-XX-XX для удобства при устном сообщении. Набор символов исключает визуально неоднозначные знаки:

  • Нет 0 и O (ноль и буква O)
  • Нет 1, I и L (единица, заглавная I и заглавная L)

Это позволяет избежать проблемы «это ноль или буква O?», когда кто-то диктует код в голосовом чате или набирает его из сообщения друга.

Мультиплеерным соединениям необходимо обнаруживать отключение игрока — будь то из-за потери сети, закрытия приложения или перехода ноутбука в спящий режим. En Parlant~ использует для этого систему heartbeat:

  1. Каждый клиент отправляет событие heartbeat на сервер каждые 5 секунд
  2. Сервер подтверждает heartbeat и пересылает его противнику как peer_heartbeat
  3. Клиент отслеживает, когда последний раз получал peer_heartbeat от противника
  4. Функция isPeerAlive(timeoutMs) проверяет, укладывается ли последний heartbeat противника в допустимый порог

Это управляет индикатором состояния подключения в интерфейсе. Если heartbeat перестают приходить, игрок видит, что противник, возможно, отключился, и может решить подождать или покинуть игру.

Ретранслятор автоматически удаляет неактивные комнаты для предотвращения утечек памяти:

  • Комната считается неактивной после 30 минут без какой-либо активности
  • Задача очистки запускается каждые 60 секунд, удаляя все комнаты, превысившие порог неактивности
  • При очистке комнаты все оставшиеся соединения разрываются

Постоянного хранилища нет. При перезапуске сервера все комнаты исчезают. Это сделано намеренно — ретранслятор не хранит состояния и является эфемерным. Незавершённые партии придётся начать заново, но на практике это случается редко.

Ретранслятор по умолчанию работает на Fly.io, обеспечивая низкую задержку WebSocket-соединений с автоматическим TLS. Инструкции по запуску собственного ретранслятора смотрите в руководстве по настройке мультиплеерного сервера.

Для тестирования мультиплеера в процессе разработки:

  1. Клонируйте и запустите ретранслятор:

    Окно терминала
    git clone https://github.com/DarrellThomas/en-parlant-relay.git
    cd en-parlant-relay
    cargo run

    Сервер запускается на порту 3210.

  2. В En Parlant~ измените URL ретранслятора на ws://localhost:3210.

  3. Откройте два экземпляра приложения (или один в обычном режиме и один в режиме разработки) для имитации обоих игроков.

Ходы, heartbeat и все события работают идентично продакшн-ретранслятору — единственное отличие — URL подключения.