Skip to content

Repository files navigation

Arena

Русский · English

CI CodeQL License: MIT

Топ-даун .io-шутер с авторитетным сервером. Сервер на Go, клиент на canvas, настоящий неткод: client prediction, server reconciliation, lag compensation и interest management — добавляются итерация за итерацией.

Статус: итерация 31 — Capture the Flag (командный режим захвата флага: у каждой команды база с флагом; игрок подбирает флаг врага касанием, несёт к своей базе и при своём флаге дома совершает захват; смерть носителя роняет флаг, союзник возвращает касанием, брошенный авто-возвращается; победитель — команда с большей суммой захватов; Player.Captures и полное состояние флагов в Checksum, флаг ctfMode — параметр мира в логе реплея v6; динамика флагов едет клиенту событийным MsgFlagState, захват — MsgCapture, снапшот не тронут; клиент рисует базы/флаги/носителей, баннер и звук захвата; ARENA_CTF_MODE, подразумевает командный режим). До этого: доминация (несколько контрольных точек — обобщение King of the Hill с одной зоны на N; очки суммируются в DomScore по всем удерживаемым зонам, победитель по сумме; DomScore в Checksum, флаг domMode в логе реплея v5; зоны едут через MsgMatchState, снапшот не тронут; ARENA_DOM_MODE, итер. 30). Ещё раньше: King of the Hill (захват одной зоны в центре арены; HillScore в Checksum, флаг hillMode в логе реплея v4; ARENA_HILL_MODE, итер. 29). До этого: умный ИИ ботов (боты наполнителя видят мир из снапшотов, идут к ближайшему врагу по A* вокруг стен и целятся; ИИ клиентский — симуляцию/провод не трогает, геометрию стен берёт из game.Obstacles(); internal/bot не импортирует game, итер. 28). До этого: рывок (dash по Space: ускорение в сторону движения с кулдауном; input-driven и предсказывается клиентом; таймеры в MoveState/Checksum, бит ActDash в опциональном байте Input.Actions; сервер гейтит кулдаун — анти-чит, итер. 27). Ещё раньше: система оружия (4 типа: пистолет/дробовик/снайперка/ракета со сплэшем; выбор клавишами 1–4 едет в старших битах Buttons — формат ввода не меняется; Player.weapon/ projectile.weapon в Checksum, оружие едет клиенту reliable-сообщением MsgWeaponState, снапшот не тронут, итер. 26). Ранее: античит-метрики (Prometheus arena_anticheat_events_total{kind}: считает срабатывания серверного клампа перемотки — ViewTick из будущего или дальше окна; наблюдение поверх существующего анти-чита, счётчики во World вне Checksum, слив комнатой в Recorder после тика, итер. 25). А до того: мобильное управление (твин-стик поверх canvas: левый стик — движение, правый — прицел и огонь; кормят те же state.keys/state.aim, что мышь/клавиатура, поэтому предсказание/провод не тронуты; canvas масштабируется под экран телефона — чистый фронтенд, итер. 24). Командный режим (две команды, баланс при входе, дружественный огонь выключен, счёт и победитель по командам; Player.team и снаряд в Checksum, флаг teamMode — параметр мира в логе реплея v2; команда едет клиенту через MsgMatchState, снапшот не тронут; клиент красит бойцов/миникарту/табло по командам; ARENA_TEAM_MODE, итер. 23). Спектатор/наблюдатель (присоединиться к комнате без спавна: Join с флагом spectator, сессия без Player — не в симуляции/бое, получает весь мир и события; клиент — свободная камера на WASD, кнопка spectate; наблюдатели вне AOI и вне Checksum, итер. 22). Рейт-лимит на auth (пер-IP токен-бакет на /api/register/login/guest — брут-форс/ спам ограничены, 429 + Retry-After; без фоновых горутин; включён по умолчанию, итер. 21). Киллстрики + окно неуязвимости (свежий спавн неуязвим, снаряды проходят насквозь; щит спадает от выстрела; серия убийств подряд даёт хил и короткий щит, reliable MsgKillstreak; всё в Checksum, итер. 20). Оружие/пикапы (аптечки, ускорение стрельбы и веер на фиксированных точках; спавн детерминирован w.rng, буфы и состояние точек в Checksum; провод — отдельный reliable MsgPickupState, снапшот не тронут, итер. 19). Звук боя (выстрел/попадание/смерть/фраг/респаун через Web Audio на уже приходящих событиях — чистый фронтенд, синтез без ассетов, тумблер в HUD, итер. 18). Серверные боты (наполнитель держит в занятой комнате ARENA_BOT_FILL игроков, подсаживая ИИ-ботов и уступая место людям — боты обычные клиенты через Pipe, мир не трогают, итер. 17), профиль игрока (модалка со статистикой и историей матчей — итер. 16), клиент/UX (экран логина/регистрации на REST-бэкенде с токен-сессией, лидерборд, миникарта — чистый canvas/JS без сборщиков, итер. 15), persister (комната шлёт смерти и итоги матчей в канал, internal/persist пишет статистику/историю в БД вне горутины комнаты; джойн несёт токен-сессию — итер. 14B), жизненный цикл матча (FFA deathmatch с таймером: раунд на время, счёт убийств/смертей, детерминированный победитель, антракт и авторестарт; табло/таймер/баннер — итер. 14). Ещё раньше: фундамент бэкенда (аккаунты, статистика, история матчей — итер. 13), WebRTC доведён до продакшена (снапшоты по отдельному unreliable DataChannel, TURN и relay-only — итер. 12), транспорт WebRTC DataChannel рядом с WebSocket (итер. 11, ?transport=webrtc), статичные стены (итер. 10), field-level дельта снапшота (итер. 9), широкофазная коллизия снаряд×игрок по сим-сетке (итер. 8), реплеи (cmd/replay, итер. 7), масштаб (interest management, дельты снапшотов, нагрузка 200 ботов — итер. 6), бой и lag compensation (итер. 5). План — в CLAUDE.md.

Требования

  • Go 1.22+ (разрабатывается на 1.26)
  • Современный браузер
  • Опционально: golangci-lint для полного линт-гейта (цели make lint/check откатываются на go vet, если его нет)

Запуск

make run          # или: go run ./cmd/server

Откройте http://localhost:8080. Можно войти или зарегистрироваться (тогда матчи идут под аккаунтом и копят статистику) — или просто ввести имя и играть гостём. Нажмите connect. Движение — WASD; камера следует за вашим игроком (синий), остальные — красные. Мышь целится, ЛКМ — огонь; снаряды жёлтые. Серые блоки — статичные стены: сквозь них не пройти и не прострелить. HP, вспышка урона и экран смерти с респауном — в HUD; лидерборд — сбоку, миникарта — снизу справа (итерация 15). Клик по игроку в лидерборде (или кнопка profile у залогиненного) открывает профиль — статистику и историю матчей (итерация 16). Кнопка sound в HUD включает/выключает звук боя (выстрел, попадание, смерть, фраг, респаун — Web Audio, синтез без ассетов, итерация 18).

Docker

make docker                                   # собрать образ arena-server:dev
docker run --rm -p 8080:8080 arena-server:dev # запустить, открыть http://localhost:8080

Многостадийная сборка даёт статический бинарь на distroless-образе (nonroot, без shell). Готовые образы публикуются в GHCR: ghcr.io/azazlokus/rewind (push в main и по тегу vX.Y.Z).

Стек наблюдаемости (docker-compose, итерации 32, 34)

Поднять сервер вместе с PostgreSQL, Prometheus, Grafana и Jaeger одной командой — с преднастроенным дашбордом, алертами (метрики) и трассировкой (OTel):

cp .env.example .env        # правьте пароли/секреты под себя
make compose-up             # docker compose up -d --build

Порты: http://localhost:8080 — игра, http://localhost:9090 — Prometheus (вкладка Alerts), http://localhost:3000 — Grafana (логин admin, пароль из .env; дашборд Arena → Overview: тик p50/p99 с бюджетом 15 мс, игроки, боты, трафик снапшотов, сущности в снапшоте, античит-клампы), http://localhost:16686Jaeger (трейсы OTel; сервер шлёт OTLP на jaeger:4318, итер. 34). Остановить — make compose-down (тома сохраняются; V=1 сносит и данные). Подробности — в deploy/README.md.

Ручная проверка на двух вкладках (приёмка итерации 1)

  1. make run
  2. Откройте http://localhost:8080 в двух вкладках браузера.
  3. Подключитесь в обеих. Двигайтесь WASD в одной — во второй этот игрок (красный) движется плавно, и наоборот.

Чужие игроки рендерятся на 100 мс в прошлом с интерполяцией между снапшотами — плавно даже при задержке 100–200 мс. Свой игрок предсказывается локально: отклик на WASD мгновенный, а сервер лишь корректирует позицию (реконсиляция со сглаживанием). Проверьте throttling'ом в devtools: свой персонаж не «залипает» на задержке, а чужие остаются плавными.

Конфигурация (переменные окружения)

Переменная По умолчанию Смысл
ARENA_ADDR :8080 адрес HTTP/WebSocket
ARENA_PPROF_ADDR 127.0.0.1:6060 адрес pprof (пусто — отключить)
ARENA_WEB_DIR web каталог, отдаваемый на /
ARENA_TICK_RATE 30 частота симуляции, Гц
ARENA_SNAPSHOT_RATE 20 частота снапшотов, Гц (интерполяция скрывает разрыв с тикрейтом)
ARENA_MAX_PLAYERS 64 игроков на комнату
ARENA_MAX_ROOMS 16 комнат на hub
ARENA_BOT_FILL 0 держать столько игроков (люди+боты) в занятой комнате; 0 — выключено (итер. 17)
ARENA_TEAM_MODE false командный режим: 2 команды, дружественный огонь выключен, счёт по командам (итер. 23)
ARENA_HILL_MODE false King of the Hill: захват зоны в центре, счёт и победитель по очкам холма (итер. 29)
ARENA_DOM_MODE false доминация: захват нескольких контрольных точек, счёт и победитель по сумме очков зон (итер. 30)
ARENA_CTF_MODE false Capture the Flag: две базы с флагами, счёт и победитель по числу захватов; подразумевает командный режим (итер. 31)
ARENA_AOI_RADIUS 640 радиус interest management, юниты (0 — выкл)
ARENA_SEED 1 seed мира (детерминизм)
ARENA_ALLOW_ALL_ORIGIN true пропускать проверку origin (для разработки)
ARENA_STUN (пусто) STUN URL для WebRTC через запятую; пусто — только host-кандидаты (localhost/LAN)
ARENA_TURN (пусто) TURN URL через запятую (для обхода NAT)
ARENA_TURN_USER (пусто) имя пользователя TURN
ARENA_TURN_PASS (пусто) пароль TURN
ARENA_FORCE_RELAY false WebRTC только через TURN-relay (host/srflx отброшены; жёсткие сети/приватность)
ARENA_DB_DRIVER sqlite бэкенд хранилища: sqlite (dev/CI) или postgres (prod)
ARENA_DB_DSN arena.db путь к файлу SQLite (или :memory:) либо DSN Postgres
ARENA_AUTH_SECRET (пусто) ключ подписи access-токенов; пусто — эфемерный на запуск (токены не переживут рестарт)
ARENA_ACCESS_TTL 15m время жизни access-токена (join + API; короткий) (итер. 36)
ARENA_REFRESH_TTL 720h время жизни refresh-токена (обновление access с ротацией) (итер. 36)
ARENA_VERIFY_TTL 24h время жизни токена верификации email (итер. 37)
ARENA_RESET_TTL 1h время жизни токена сброса пароля (итер. 37)
ARENA_ADMIN_USERNAME (пусто) при старте повысить этот аккаунт до admin (бутстрап модерации; итер. 39)
ARENA_ANTICHEAT_BAN_THRESHOLD 0 сумма античит-событий аккаунта для автобана; 0 — выключено (итер. 40)
ARENA_ANTICHEAT_BAN_DURATION 24h срок автобана за античит (0 — навсегда) (итер. 40)
ARENA_AUTH_RATE_BURST 10 пер-IP рейт-лимит auth: запросов «в упор»; 0 — выключить (итер. 21)
ARENA_AUTH_RATE_WINDOW 1m время полного восстановления бакета (скорость ≈ burst/window)
ARENA_AUTH_RATE_IP_HEADER (пусто) заголовок с IP клиента за прокси (напр. X-Forwarded-For); пусто — из RemoteAddr. Включать только за доверенным прокси
ARENA_JOIN_MAX_PER_IP 16 пер-IP кап одновременно живых игровых соединений (/ws+/rtc); 0 — выключить (итер. 33)
ARENA_JOIN_RATE_BURST 30 пер-IP рейт-лимит новых игровых соединений: «в упор»; 0 — выключить (итер. 33)
ARENA_JOIN_RATE_WINDOW 1m время полного восстановления бакета новых соединений (скорость ≈ burst/window)
ARENA_JOIN_RATE_IP_HEADER (пусто) заголовок с IP клиента за прокси для шлюза входа; пусто — из RemoteAddr. Включать только за доверенным прокси
ARENA_OTEL_ENABLED false OpenTelemetry-трассировка: экспорт трейсов по OTLP (итер. 34); выключено — no-op
ARENA_OTEL_ENDPOINT (пусто) OTLP endpoint (host:4318 или URL); пусто — из OTEL_EXPORTER_OTLP_ENDPOINT/дефолт
ARENA_OTEL_INSECURE true OTLP без TLS (локальный коллектор)
ARENA_OTEL_STDOUT false dev: печатать трейсы в stdout (можно вместе с OTLP)
ARENA_OTEL_SAMPLE_RATIO 1.0 доля семплируемых корневых трейсов (1.0 — все)
ARENA_OTEL_SERVICE_NAME arena-server имя сервиса в трейсах
ARENA_LOG_LEVEL info debug/info/warn/error

Транспорт

По умолчанию игра идёт по WebSocket. Итерация 11 добавила WebRTC DataChannel как альтернативный транспорт: откройте http://localhost:8080/?transport=webrtc — клиент поднимет DataChannel (сигналинг offer/answer по WS /rtc), а игровой протокол пойдёт уже по нему. Фолбэка нет: транспорт выбирается явно, WebSocket остаётся дефолтом (и путём для ботов/e2e).

Итерация 12 довела WebRTC до продакшена. Каналов теперь два: "game" (ordered+reliable) несёт события и вводы, а снапшоты идут по отдельному "state" (unordered+unreliable) — потерянный снапшот не ретрансмитится и не держит head-of-line blocking для следующих. На localhost/LAN хватает host-кандидатов; для обхода NAT задайте STUN (ARENA_STUN) или TURN (ARENA_TURN + ARENA_TURN_USER/ ARENA_TURN_PASS). ARENA_FORCE_RELAY=true заставляет соединяться только через TURN-relay (жёсткие сети/приватность — реальный IP пира не светится). ICE-серверы и политику relay сервер сообщает клиенту в сигналинге, поэтому обе стороны согласованы.

Бэкенд (итерация 13)

Персистентный бэкенд для аккаунтов, статистики и истории матчей, архитектурно отделённый от игрового ядра (модульный монолит с жёсткими границами):

  • internal/store — интерфейс Store + одна SQL-реализация под SQLite (dev/CI, pure-Go, без внешней СУБД) и PostgreSQL (prod); миграции встроены и применяются на старте.
  • internal/account — идентичность: гость + аккаунты (argon2id). Токены (итер. 36): короткий self-contained access (HMAC, проверяется без БД — им авторизуются и API, и game-join) обновляется долгим refresh с ротацией и детекцией переиспользования (хранится только SHA-256; повторное предъявление отозванного токена гасит всё семейство; logout отзывает). Гости эфемерны (имя в токене, без строки в БД, без refresh). Email + сброс пароля (итер. 37): при регистрации можно указать email — на него уходит одноразовый токен верификации; «забыл пароль» шлёт токен сброса, а сброс меняет пароль и разлогинивает все сессии (все refresh-токены отзываются). Одноразовые токены тоже хранятся только как SHA-256. Реального SMTP нет — прод подключает свой Mailer, dev по умолчанию печатает токен в лог (LogMailer). Роли, баны, репорты (итер. 39): роль аккаунта (user/moderator/admin); модераторы банят игроков (бан отзывает все сессии), админы меняют роли; игроки шлют репорты. Забаненному шлюз отказывает в join (игровое ядро про баны не знает — проверка на границе). Первый админ — через ARENA_ADMIN_USERNAME.
  • internal/api — REST на чистом net/http.
  • internal/persist (итерация 14B) — шов игра → БД: комнаты шлют смерти, итоги матчей и привязанные к аккаунту античит-события (итер. 40) в канал, persister пишет их в store в своей горутине; при пороге (ARENA_ANTICHEAT_BAN_THRESHOLD, по умолчанию выкл) — автобан с отзывом сессий.

Игровое ядро (internal/game) бэкенд не импортирует. Связь идёт через persister: комната шлёт game.PersistMsg в Config.PersistSink неблокирующе (переполнение роняет статистику, но никогда не тормозит тик), а internal/persist переводит их в вызовы store вне горутины комнаты. Джойн несёт токен-сессию — по нему шлюз привязывает игрока к аккаунту (см. MsgJoin в docs/protocol.md).

REST (/api):

Метод + путь Что делает
POST /api/register регистрация {username,password} → access+refresh
POST /api/login логин → access+refresh
POST /api/guest гостевой access-токен {name} (без refresh)
POST /api/refresh {refresh_token} → новая пара (ротация; итер. 36)
POST /api/logout {refresh_token} → отзыв семейства (204; итер. 36)
POST /api/verify-email {token} → подтверждение email (204; итер. 37)
POST /api/request-password-reset {email} → письмо сброса (всегда 204; итер. 37)
POST /api/reset-password {token,password} → смена пароля + разлогин (204; итер. 37)
GET /api/me профиль по access-токену (Bearer; несёт role/ban)
GET /api/leaderboard топ по убийствам (?limit)
GET /api/players/{id}/stats статистика игрока
GET /api/players/{id}/matches история матчей игрока (?limit)
POST /api/report пожаловаться на игрока {target_id,reason} (итер. 39)
POST /api/mod/ban бан {account_id,reason,duration_seconds} (moderator+; итер. 39)
POST /api/mod/unban снять бан {account_id} (moderator+)
POST /api/mod/role сменить роль {account_id,role} (admin)
GET /api/mod/reports список жалоб (?status; moderator+)
GET /api/mod/anticheat топ по античит-событиям (?limit; moderator+; итер. 40)

Эндпоинты

  • / — веб-клиент
  • /ws — игровое WebSocket-соединение
  • /rtc — сигналинг WebRTC (игровой транспорт — DataChannel: "game" reliable + "state" unreliable, итер. 11–12)
  • /api/* — REST-бэкенд (итер. 13): аккаунты, профиль, лидерборд, история матчей
  • /metrics — метрики Prometheus
  • /healthz — проба живости
  • pprof на ARENA_PPROF_ADDR (только localhost)

Разработка

make check        # обязательный гейт перед коммитом: lint + race-тесты
make test         # go test -race -count=1 ./...
make integration  # e2e (реальный сервер + WS-боты), build tag `integration`
make fuzz         # фаззинг декодера протокола
make bench        # бенчмарки с -benchmem (см. BENCHMARKS.md)
make loadtest     # нагрузка: 200 ботов in-process, tick p99 и трафик (итер. 6C)
make replay       # демо реплея: записать сессию и проиграть headless (итер. 7)
make profile      # запуск с pprof и печать эндпоинта
make compose-up   # локальный стек: server+postgres+prometheus+grafana (итер. 32)
make compose-down # снести стек (V=1 — вместе с томами данных)
make gen          # регенерировать зеркальные константы web/game.js из Go (итер. 41)
make help         # список всех целей

Коммиты — на русском, по Conventional Commits.

Работаем на feature-ветках (feat/…, fix/…, docs/…, ci/…), не коммитим прямо в main: ветка → make check → PR → merge зелёным. Подробнее — в CLAUDE.md («Ветки и PR»).

CI/CD и безопасность

Всё в .github/:

  • CI (ci.yml) — на каждый PR: make check, make integration, короткий make fuzz.
  • CodeQL (codeql.yml) — SAST от GitHub.
  • Security (security.yml) — govulncheck (уязвимости в зависимостях/коде) и gitleaks (утечки секретов).
  • Dependabot (dependabot.yml) — еженедельные обновления Go-модулей и экшенов.
  • Docker (docker.yml) — сборка образа и публикация в GHCR.
  • Release (release.yml + .goreleaser.yaml) — по тегу vX.Y.Z GoReleaser собирает бинари (server/loadtest/replay) под linux/darwin/windows × amd64/arm64, архивы с web/ и checksums, и создаёт GitHub Release. Отчёт об уязвимостях — приватно, см. SECURITY.md.

Выпуск релиза:

git tag v0.1.0 && git push origin v0.1.0   # запускает release.yml

Документация

  • docs/architecture.md — компоненты, модель конкурентности, владение горутинами, детерминизм.
  • docs/protocol.md — формат сообщений v1.
  • docs/testing.md — harness, детерминизм, fuzz, golden, e2e.
  • deploy/README.md — локальный стек наблюдаемости (compose, Prometheus, Grafana, Jaeger), дашборд, алерты и трейсы (итер. 32, 34).
  • docs/history/CHANGELOG.md — история развития проекта по итерациям (changelog): что, когда и в каком PR появилось.
  • CLAUDE.md — зафиксированные решения, границы пакетов, правила.
  • BENCHMARKS.md — замеры по итерациям.

Структура

cmd/server/        wiring, конфиг из env, WS-gateway, graceful shutdown
cmd/loadtest/      нагрузочный прогон: N ботов in-process, замер tick p99 и трафика
cmd/replay/        headless-проигрыш лога сессии со сверкой Checksum (реплеи, итер. 7)
internal/
  transport/       интерфейс Conn + WebSocket + WebRTC DataChannel + in-memory Pipe
  protocol/        типы сообщений + кодек (бинарный, дельта-снапшоты)
  game/            game loop, world, systems, clock, sessions — без сети
  hub/             менеджер комнат, распределение игроков
  bot/             headless-клиент (реконструкция дельт; автопилот swarm/ботов)
  botfill/         наполнитель комнат ИИ-ботами (итер. 17) — боты как обычные клиенты
  metrics/         инструменты Prometheus
  tracing/         bootstrap OpenTelemetry-трассировки (control-plane, итер. 34)
  ratelimit/       пер-ключ токен-бакет + кап соединений (auth и игровой вход, итер. 33)
  store/           персистентность (SQLite/PostgreSQL), миграции — вне игры (итер. 13)
  account/         аккаунты и гости: argon2id, HMAC-токены (итер. 13)
  api/             REST на net/http: register/login/leaderboard/profile (итер. 13)
  persist/         шов игра→БД: статистика и история матчей вне горутины комнаты (итер. 14B)
web/               index.html + game.js (без сборщика)

Боты (итерация 17)

Чтобы игрок, зашедший в одиночку, не скучал, наполнитель internal/botfill держит в занятой комнате ARENA_BOT_FILL игроков: пока в комнате есть хотя бы один живой человек, он подсаживает ИИ-ботов до цели и снимает их, когда приходят люди или комната пустеет (пустую ботами не оживляет). Боты — обычные клиенты: наполнитель подключает их in-process через transport.Pipe и room.Join (тот же путь, что у людей и нагрузочного swarm) и гоняет ИИ из internal/bot; мир комнаты он не трогает — только Players()/Join/State и закрытие соединения. По умолчанию выключено (0). Метрика — arena_active_bots.

Умный ИИ (итерация 28). Боты наполнителя больше не бродят случайно: они видят мир из снапшотов, идут к ближайшему врагу по A вокруг стен* и целятся в него. ИИ остаётся клиентским (симуляцию/провод не трогает); геометрию стен получает из game.Obstacles() параметром (internal/bot не импортирует game). Навигация — сетка 32×32, путь пересчитывается редко (~2 раза/с на бота), сетка строится один раз и делится между ботами. Простой случайный автопилот оставлен для cmd/loadtest (там нужна массовость, не бой).

Оружие/пикапы (итерация 19)

По арене разбросаны бонусы на фиксированных точках: аптечка (мгновенный хил), ускорение стрельбы и веер (оба — временные буфы, чистятся при респауне). Наступив на пикап, игрок его подбирает. Это часть симуляции: точки и алгоритм — детерминированные (тип розыгрывается w.rng, тайминг спавна/респауна — по w.Tick, подбор — обходом точек по индексу и игроков по order), поэтому буфы игрока и состояние точек (занята/тип/таймер) входят в Checksum и безопасны для реплеев.

На проводе пикапы не едут в снапшоте (иначе раздули бы дельту и пер-тик счётчики сущностей): раскладка точек фиксирована и зеркалится клиентом (PICKUP_SPOTS, как WALLS), а какие точки заняты и чем — сервер шлёт отдельным reliable-сообщением MsgPickupState событийно (как табло матча). Клиент рисует активные пикапы в их точках и на миникарте — чистый рендер, подбор авторитетен на сервере.

Киллстрики и окно неуязвимости (итерация 20)

Две связанные боевые механики, обе детерминированы и входят в Checksum:

  • Окно неуязвимости (spawn protection): свежереспаунившийся игрок пару секунд неуязвим — снаряды проходят сквозь него (findHit его пропускает), урона нет. Это анти-спавн-килл: нельзя фармить того, кто только возродился в гуще боя. Щит спадает, как только игрок сам стреляет (tryFire) — отсидеться под щитом и безнаказанно бить нельзя. Даётся на респауне (не на первичном входе) и на вехе стрика.
  • Киллстрики: серия убийств без смертей (Player.streak). Каждые killstreakStep фрагов подряд — веха: мгновенный хил до 100 + короткий щит (power spike), а всем уходит reliable-событие MsgKillstreak (фид/объявление). Смерть и старт нового матча обнуляют серию.

Клиент рисует пульсирующее щит-кольцо (по событиям MsgSpawn/MsgKillstreak, длительность зеркалится) и баннер серии — чистый рендер: неуязвимость авторитетна на сервере, в предсказание не входит.

Рейт-лимит на auth (итерация 21)

Незалогиненные POST'ы, минтящие токены (/api/register, /api/login, /api/guest), защищены пер-IP токен-бакетом: у клиента ёмкость ARENA_AUTH_RATE_BURST запросов «в упор», восстанавливающаяся со скоростью burst/window. Исчерпал — 429 Too Many Requests с заголовком Retry-After. Так брут-форс пароля и спам регистраций/гостевых токенов ограничены по частоте, а обычный игрок лимита не замечает.

Живёт как middleware в internal/api (не игровой код), конкурентно-безопасен под мьютексом и без фоновых горутин: простаивающие бакеты подчищаются ленивым свипом на самом запросе, поэтому карта не растёт при живом трафике и статична в тишине. Ключ клиента — из RemoteAddr; за обратным прокси можно указать ARENA_AUTH_RATE_IP_HEADER (напр. X-Forwarded-For) — только если прокси сам перезаписывает этот заголовок, иначе IP подделают. Включён по умолчанию (ARENA_AUTH_RATE_BURST=0 — выключить). Игру/провод не трогает.

Рейт-лимит на игровой вход (итерация 33)

Игровой вход (/ws и /rtc) защищён пер-IP шлюзом (joinGate) — против флуда соединений и удержания горутин/комнат/сокетов одним источником. Две проверки до апгрейда: кап живых соединений (ARENA_JOIN_MAX_PER_IP, дефолт 16) — слот занят ровно пока сессия жива (обработчик сессии блокируется на всё её время), и токен-бакет скорости новых соединений (ARENA_JOIN_RATE_BURST/ARENA_JOIN_RATE_WINDOW, дефолт 30 «в упор», восстановление за 60с). Исчерпал — 429 (у рейта — с Retry-After), не доходя до рукопожатия и комнаты. Отклонения видны в метрике arena_join_rejected_total{reason} (rate/concurrent).

Оба лимитера общие для /ws и /rtc (соединения одного IP по обоим транспортам считаются вместе). Токен-бакет и кап — общий примитив internal/ratelimit (тот же, что у auth-лимита итер. 21); конкурентно-безопасны под мьютексом, без фоновых горутин (карты не растут: бакеты чистятся ленивым свипом, счётчик удаляет обнулившийся ключ). Ключ клиента — из RemoteAddr; за обратным прокси задать ARENA_JOIN_RATE_IP_HEADER (иначе все клиенты слипнутся в один IP и кап их заблокирует). Включён по умолчанию (ARENA_JOIN_MAX_PER_IP=0 + ARENA_JOIN_RATE_BURST=0 — выключить). Игру/провод не трогает.

Трассировка (OpenTelemetry, итерация 34)

Распределённая трассировка control-plane через OpenTelemetry (internal/tracing): пакет поднимает глобальный TracerProvider с OTLP/HTTP-экспортёром и W3C-пропагатором. Инструментируются только операции control-plane, НЕ горячий игровой путь: HTTP-API (otelhttp на /api/* — серверный спан на запрос), рукопожатие join (ограниченный спан game.join на шлюзе — upgrade→токен→бан→комната, завершается ДО долгоживущей сессии) и SQL-запросы (otelsql на store — дочерние спаны от контекста запроса). Игровой тик 30 Гц НЕ трассируется — zero-alloc горячего пути неприкосновенен (тик по-прежнему 0 allocs/op, internal/game про OTel не знает).

Выключено по умолчанию: без экспортёра остаётся глобальный no-op провайдер (нулевые накладные), поэтому инструментовка стоит безусловно, без ветвлений по флагу. Включить — ARENA_OTEL_ENABLED=true + OTLP endpoint (ARENA_OTEL_ENDPOINT, напр. jaeger:4318) или ARENA_OTEL_STDOUT=true для dev; доля семплирования — ARENA_OTEL_SAMPLE_RATIO. В compose-стеке (ниже) трейсы включены и видны в Jaeger UI (:16686) и в Grafana.

Спектатор/наблюдатель (итерация 22)

Можно присоединиться к комнате наблюдателем: кнопка spectate (или Join с флагом spectator). Наблюдатель — сессия без Player в мире: он не спавнится, не участвует в бою, не считается игроком (Players() его не видит) и в Checksum/симуляцию не входит вовсе — это чисто сетевой концепт на уровне комнаты. Ему шлётся весь мир (вне AOI, у него нет позиции) и reliable-события (смерти, спавны, табло, пикапы, киллстрики). MsgJoinAck несёт YourID == 0 — сигнал «своей сущности нет».

Наблюдатель ввод не подаёт (сервер его игнорирует, даже если враждебный клиент пришлёт): камера свободная, панорамируется WASD на клиенте (чистый рендер, сеть не задействуется). Ключ сессии наблюдателя room-local и в пространство id сущностей не попадает, поэтому в мир протечь не может. Изменения — только в session/room и клиенте; симуляция и World не тронуты.

Командный режим (итерация 23)

Включается флагом ARENA_TEAM_MODE. Игроки делятся на две команды; вход балансирует (новичок попадает в меньшую команду, детерминированно по w.order). Дружественный огонь выключен — снаряд проходит сквозь союзника (findHit пропускает цель той же команды). Матч считается по командам: победитель — команда с большим суммарным счётом убийств (при равенстве — команда 0).

Команда игрока (Player.team) и команда снаряда — состояние симуляции, входят в Checksum (влияют на дружественный огонь и счёт). Сам флаг teamMode — фиксированный параметр мира (как tickRate): в Checksum не входит, но пишется в лог реплея v2, чтобы реплей реконструировал бой верно (декодер принимает и v1-логи FFA). Провод: команда едет клиенту через MsgMatchState (флаг teamMode + team в каждой строке табло) — снапшот/дельта и их пер-тик счётчики сущностей не тронуты (горячий путь без регресса). Клиент строит карту id→team из табло и красит по ней бойцов, миникарту, табло и баннер победителя (свои синие, враги красные).

Мобильное управление (итерация 24)

Чистый фронтенд: твин-стик поверх canvas на сенсорных экранах. Касание левой половины — виртуальный стик движения (направление → 8 направлений WASD в state.keys), правой — стик прицела (угол → state.aim) с удержанием огня. Оба кормят те же state.keys/state.aim, что клавиатура/мышь, поэтому путь ввода (предсказание, кодирование encodeInput, отправка на 60 Гц) не тронут — сенсор просто ещё один источник тех же состояний. Стики рисуются, только пока касание удерживается (на десктопе не видны). Обрабатываются Pointer Events с pointerType === 'touch' (мышь/клавиатура идут прежним путём); touchAiming не даёт рендеру перетереть state.aim позицией мыши, пока активен правый стик. Canvas сохраняет внутреннее разрешение 800×600, но max-width: 100% масштабирует его под узкий экран телефона; touchPoint() пересчитывает касание из экранных координат в canvas по отношению сторон, поэтому стики точны при любом масштабе. Провод/симуляция/константы не тронуты — Go-изменений нет.

Античит-метрики (итерация 25)

Prometheus-счётчик arena_anticheat_events_total{kind} делает видимыми срабатывания серверного анти-чит-клампа перемотки (lag compensation). Метки: rewind_stale — клиент прислал ViewTick дальше окна перемотки в прошлое (высокая задержка, артефакт интерполяции или lag-switch); rewind_futureViewTick из будущего (рассинхрон часов или подмена времени клиентом). Это наблюдение, не решение: сервер и без метрики авторитетно зажимает сдвиг (clampRewind) — счётчик лишь считает попытки. Счётчики живут во World как транзиентное поле (ac), в Checksum не входят и в лог реплея не пишутся (на симуляцию не влияют — реплей-безопасно); инкрементятся в tryFire и сливаются комнатой в Recorder после каждого тика (DrainAntiCheat), всё на горутине комнаты. Провод/ клиент не тронуты. Доступно на /metrics рядом с arena_tick_duration_seconds и прочими.

Система оружия (итерация 26)

Четыре типа оружия, задающих картину выстрела: пистолет (базовый одиночный), дробовик (веер дробин, урон в упор), снайперка (одна быстрая пуля, большой урон), ракета (детонация по площади при попадании). Всё детерминированная симуляция (в Checksum, реплей-безопасно).

  • Выбор — клавишами 1–4, едет в старших битах Input.Buttons (биты 5..7): формат ввода на проводе не меняется. Player.weapon (выбранное) и projectile.weapon (чем стреляли — снаряд несёт его в полёте) входят в Checksum; таблица характеристик weaponSpecs фиксирована (как walls) и в хэш не идёт.
  • Ракета взрывается о игрока ИЛИ стену: explode бьёт по площади с линейным спадом урона, пропуская владельца (без самоурона), неуязвимых и союзников; цели перематываются тем же rewind, что и прямое попадание (lag comp).
  • Провод: текущее оружие всех игроков едет reliable-сообщением MsgWeaponState (0x18) событийно при смене/входе — снапшот/дельта не тронуты. Клиент рисует своё оружие в HUD и подпись над бойцами.
  • С бафами (итер. 19): ускорение режет кулдаун любого оружия; веер превращает однодробинное оружие в веер (прежнее «пистолет + веер = 3» сохранено). Тик по-прежнему 0 allocs/op.

Рывок (итерация 27)

Рывок (dash) — короткое ускорение в сторону движения по клавише Space, с кулдауном. Детерминированная симуляция, но, в отличие от пикапов, предсказывается клиентом (как и обычное движение).

  • Input-driven, а не буф: рывок запускается вводом (бит ActDash в отдельном байте Input.Actions — в Buttons свободных бит нет). Клиент предсказывает его из своего же ввода тем же Step, что и сервер, — сходятся без пересылки серверного буфа.
  • Состояние в MoveState: таймеры dashCD/dashT (секунды) спадают на dt каждый шаг, входят в Checksum, сбрасываются при респауне. Сервер гейтит рывок своим кулдауном (анти-чит: клиент может спамить ActDash, но чаще не рванёт).
  • Реконсиляция без двойного счёта: таймеры рывка на клиенте — локальны; реплей неподтверждённых вводов их не пересчитывает, а переигрывает движение по сохранённому в очереди флагу dashActive. Обратная совместимость: старый ввод без байта actions читается как «без абилок» (боты/e2e не ломаются). Тик по-прежнему 0 allocs/op.

Устойчивый буст скорости и щит-пикап (server-authoritative буфы) — отдельная будущая итерация: у них предсказание сложнее (движение зависит от буфа, которого клиент не знает).

King of the Hill (итерация 29)

Включается флагом ARENA_HILL_MODE. В центре арены — фиксированный круглый холм. Пока его контролирует ровно одна сторона (в командном режиме — команда, иначе — отдельный игрок), каждый её игрок в зоне копит очко за тик; при оспаривании (в зоне две и более сторон) очки не растут. Победитель матча в этом режиме — по очкам холма (в FFA — игрок, в командном — команда с большей суммой), а не по фрагам.

  • Состояние в Checksum: Player.HillScore копится в stepHill (фаза 5 общего Step, после пикапов, до Tick++) — детерминированно, без w.rng, обнуляется при старте матча. Сам флаг hillMode — фиксированный параметр мира (как teamMode): в Checksum не входит, но пишется в лог реплея v4 (декодер принимает v1–v3). Геометрия холма (центр/радиус) статична и одинакова во всех мирах — в Checksum не входит (в хэш идёт лишь HillScore).
  • Провод: холм едет клиенту через MsgMatchState — флаг hillMode в байте флагов и поле HillScore в каждой строке табло; снапшот/дельта и их пер-тик счётчики сущностей не тронуты (горячий путь без регресса). В hillMode табло сортируется и победитель считается по очкам холма.
  • Клиент: рисует зону (константы SIM.Hill* зеркалят серверные), подсвечивая контролёра локально по игрокам в зоне — тот же расчёт, что на сервере, без нового поля в проводе; табло/баннер/миникарта показывают очки холма. Тик по-прежнему 0 allocs/op.

Доминация (итерация 30)

Включается флагом ARENA_DOM_MODE. Прямое обобщение King of the Hill с одной зоны на несколько: на арене — несколько фиксированных круглых контрольных точек (сейчас три, треугольником в открытых квадрантах). Для каждой зоны действует та же логика контроля, что у холма: пока её держит ровно одна сторона, каждый её игрок в зоне копит очко за тик; при оспаривании — нет. Очки суммируются в Player.DomScore по всем удерживаемым зонам, так что удержание бо́льшего числа зон приносит больше очков. Победитель матча — по сумме очков зон (в FFA — игрок, в командном — команда). Совместимо с командным режимом (контроль по командам).

  • Состояние в Checksum: Player.DomScore копится в stepDomination (фаза 6 общего Step, после холма, до Tick++) — детерминированно, обходом domPoints по индексу и w.order, без w.rng; обнуляется при старте матча. Флаг domMode — фиксированный параметр мира (как hillMode): в Checksum не входит, но пишется в лог реплея v5 (декодер принимает v1–v4). Геометрия точек (domPoints/domRadius) статична — в Checksum не входит (в хэш идёт лишь DomScore).
  • Провод: НОВЫХ сообщений нет. В MsgMatchState добавлен флаг domMode (бит 2), а очки зон едут в том же слоте objScore, что и очки холма (режимы взаимоисключающи — байтовая раскладка провода не менялась). Снапшот/дельта не тронуты.
  • Клиент: рисует все зоны (SIM.DomPoints/SIM.DomRadius зеркалят серверные), подсвечивая контролёра каждой локально; табло/баннер/миникарта показывают очки зон. Тик по-прежнему 0 allocs/op (stepDomination — ранний выход вне domMode).

Capture the Flag (итерация 31)

Включается флагом ARENA_CTF_MODE; подразумевает командный режим (NewRoom включает и teamMode, и ctfMode). У каждой из двух команд — база с флагом по краям арены. Игрок касанием подбирает вражеский флаг, несёт его и захватывает на своей базе — но только если свой флаг дома (канон CTF: нельзя занести чужой флаг, пока свой унесли). За захват Player.Captures +1. Носитель роняет флаг на месте смерти; брошенный флаг стоит flagReturnTicks (20 c) и сам возвращается на базу, либо союзник возвращает свой брошенный флаг касанием раньше. Дисконнект носителя возвращает флаг на базу. Победитель матча — команда с бо́льшим числом захватов.

  • Состояние в Checksum: Player.Captures и все поля обоих флагов (статус/носитель/ позиция/срок авто-возврата) считаются в stepCTF (фаза 7 общего Step, после доминации, до Tick++) — детерминированно, обходом w.order по минимальному id, без w.rng; Captures и флаги сбрасываются при старте матча. Флаг ctfMode — фиксированный параметр мира (как domMode): в Checksum не входит, но пишется в лог реплея v6 (декодер принимает v1–v5). Геометрия баз (flagBases/радиусы) статична — в Checksum не входит.
  • Провод: два новых reliable-сообщения. MsgFlagState (0x19) — полный набор флагов (команда/статус/носитель/позиция), событийно при подборе/захвате/возврате и новичку при входе (как MsgPickupState). MsgCapture (0x1a) — событие захвата (игрок+команда) через тот же пайплайн, что Death/Killstreak. В MsgMatchState добавлен флаг ctfMode (бит 3), а число захватов едет в том же слоте objScore, что очки холма/зон (режимы взаимоисключающи — байтовая раскладка не менялась). Снапшот/дельта и их пер-тик счётчики сущностей не тронуты (горячий путь без регресса).
  • Клиент: рисует базы и флаги (SIM.FlagBases/SIM.FlagBaseRadius зеркалят серверные), флаг у носителя следует за ним, брошенный лежит на земле; баннер и звук (sfx.capture) на MsgCapture; табло/баннер/миникарта показывают захваты. Тик по-прежнему 0 allocs/op (stepCTF — ранний выход вне ctfMode).

Все режимы дорожной карты пройдены (FFA deathmatch, командный, King of the Hill, доминация, Capture the Flag).

About

No description, website, or topics provided.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages