Русский · English
Топ-даун .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). Ранее: античит-метрики (Prometheusarena_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). Киллстрики + окно неуязвимости (свежий спавн неуязвим, снаряды проходят насквозь; щит спадает от выстрела; серия убийств подряд даёт хил и короткий щит, reliableMsgKillstreak; всё вChecksum, итер. 20). Оружие/пикапы (аптечки, ускорение стрельбы и веер на фиксированных точках; спавн детерминированw.rng, буфы и состояние точек вChecksum; провод — отдельный reliableMsgPickupState, снапшот не тронут, итер. 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).
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).
Поднять сервер вместе с 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:16686 — Jaeger (трейсы OTel;
сервер шлёт OTLP на jaeger:4318, итер. 34). Остановить — make compose-down (тома
сохраняются; V=1 сносит и данные). Подробности — в deploy/README.md.
make run- Откройте http://localhost:8080 в двух вкладках браузера.
- Подключитесь в обеих. Двигайтесь 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 сервер сообщает клиенту в сигналинге, поэтому обе стороны согласованы.
Персистентный бэкенд для аккаунтов, статистики и истории матчей, архитектурно отделённый от игрового ядра (модульный монолит с жёсткими границами):
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»).
Всё в .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.ZGoReleaser собирает бинари (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.ymldocs/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 (без сборщика)
Чтобы игрок, зашедший в одиночку, не скучал, наполнитель 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 (там нужна массовость, не бой).
По арене разбросаны бонусы на фиксированных точках: аптечка (мгновенный хил),
ускорение стрельбы и веер (оба — временные буфы, чистятся при респауне).
Наступив на пикап, игрок его подбирает. Это часть симуляции: точки и алгоритм —
детерминированные (тип розыгрывается w.rng, тайминг спавна/респауна — по w.Tick,
подбор — обходом точек по индексу и игроков по order), поэтому буфы игрока и
состояние точек (занята/тип/таймер) входят в Checksum и безопасны для реплеев.
На проводе пикапы не едут в снапшоте (иначе раздули бы дельту и пер-тик счётчики
сущностей): раскладка точек фиксирована и зеркалится клиентом (PICKUP_SPOTS, как
WALLS), а какие точки заняты и чем — сервер шлёт отдельным reliable-сообщением
MsgPickupState событийно (как табло матча). Клиент рисует активные пикапы в их
точках и на миникарте — чистый рендер, подбор авторитетен на сервере.
Две связанные боевые механики, обе детерминированы и входят в Checksum:
- Окно неуязвимости (spawn protection): свежереспаунившийся игрок пару секунд
неуязвим — снаряды проходят сквозь него (
findHitего пропускает), урона нет. Это анти-спавн-килл: нельзя фармить того, кто только возродился в гуще боя. Щит спадает, как только игрок сам стреляет (tryFire) — отсидеться под щитом и безнаказанно бить нельзя. Даётся на респауне (не на первичном входе) и на вехе стрика. - Киллстрики: серия убийств без смертей (
Player.streak). КаждыеkillstreakStepфрагов подряд — веха: мгновенный хил до 100 + короткий щит (power spike), а всем уходит reliable-событиеMsgKillstreak(фид/объявление). Смерть и старт нового матча обнуляют серию.
Клиент рисует пульсирующее щит-кольцо (по событиям MsgSpawn/MsgKillstreak, длительность
зеркалится) и баннер серии — чистый рендер: неуязвимость авторитетна на сервере, в
предсказание не входит.
Незалогиненные 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 — выключить). Игру/провод не трогает.
Игровой вход (/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 — выключить). Игру/провод не трогает.
Распределённая трассировка 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.
Можно присоединиться к комнате наблюдателем: кнопка spectate (или Join с флагом
spectator). Наблюдатель — сессия без Player в мире: он не спавнится, не участвует в бою,
не считается игроком (Players() его не видит) и в Checksum/симуляцию не входит вовсе —
это чисто сетевой концепт на уровне комнаты. Ему шлётся весь мир (вне AOI, у него нет
позиции) и reliable-события (смерти, спавны, табло, пикапы, киллстрики). MsgJoinAck несёт
YourID == 0 — сигнал «своей сущности нет».
Наблюдатель ввод не подаёт (сервер его игнорирует, даже если враждебный клиент пришлёт):
камера свободная, панорамируется WASD на клиенте (чистый рендер, сеть не задействуется). Ключ
сессии наблюдателя room-local и в пространство id сущностей не попадает, поэтому в мир протечь
не может. Изменения — только в session/room и клиенте; симуляция и World не тронуты.
Включается флагом ARENA_TEAM_MODE. Игроки делятся на две команды; вход балансирует
(новичок попадает в меньшую команду, детерминированно по w.order). Дружественный огонь
выключен — снаряд проходит сквозь союзника (findHit пропускает цель той же команды). Матч
считается по командам: победитель — команда с большим суммарным счётом убийств (при равенстве —
команда 0).
Команда игрока (Player.team) и команда снаряда — состояние симуляции, входят в Checksum
(влияют на дружественный огонь и счёт). Сам флаг teamMode — фиксированный параметр мира (как
tickRate): в Checksum не входит, но пишется в лог реплея v2, чтобы реплей реконструировал
бой верно (декодер принимает и v1-логи FFA). Провод: команда едет клиенту через MsgMatchState
(флаг teamMode + team в каждой строке табло) — снапшот/дельта и их пер-тик счётчики сущностей
не тронуты (горячий путь без регресса). Клиент строит карту id→team из табло и красит по
ней бойцов, миникарту, табло и баннер победителя (свои синие, враги красные).
Чистый фронтенд: твин-стик поверх 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-изменений нет.
Prometheus-счётчик arena_anticheat_events_total{kind} делает видимыми срабатывания серверного
анти-чит-клампа перемотки (lag compensation). Метки: rewind_stale — клиент прислал ViewTick
дальше окна перемотки в прошлое (высокая задержка, артефакт интерполяции или lag-switch);
rewind_future — ViewTick из будущего (рассинхрон часов или подмена времени клиентом). Это
наблюдение, не решение: сервер и без метрики авторитетно зажимает сдвиг (clampRewind) — счётчик
лишь считает попытки. Счётчики живут во World как транзиентное поле (ac), в Checksum не входят
и в лог реплея не пишутся (на симуляцию не влияют — реплей-безопасно); инкрементятся в tryFire и
сливаются комнатой в Recorder после каждого тика (DrainAntiCheat), всё на горутине комнаты. Провод/
клиент не тронуты. Доступно на /metrics рядом с arena_tick_duration_seconds и прочими.
Четыре типа оружия, задающих картину выстрела: пистолет (базовый одиночный),
дробовик (веер дробин, урон в упор), снайперка (одна быстрая пуля, большой урон),
ракета (детонация по площади при попадании). Всё детерминированная симуляция (в
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.
Рывок (dash) — короткое ускорение в сторону движения по клавише Space, с кулдауном. Детерминированная симуляция, но, в отличие от пикапов, предсказывается клиентом (как и обычное движение).
- Input-driven, а не буф: рывок запускается вводом (бит
ActDashв отдельном байтеInput.Actions— вButtonsсвободных бит нет). Клиент предсказывает его из своего же ввода тем жеStep, что и сервер, — сходятся без пересылки серверного буфа. - Состояние в
MoveState: таймерыdashCD/dashT(секунды) спадают наdtкаждый шаг, входят вChecksum, сбрасываются при респауне. Сервер гейтит рывок своим кулдауном (анти-чит: клиент может спамитьActDash, но чаще не рванёт). - Реконсиляция без двойного счёта: таймеры рывка на клиенте — локальны; реплей
неподтверждённых вводов их не пересчитывает, а переигрывает движение по сохранённому в
очереди флагу
dashActive. Обратная совместимость: старый ввод без байтаactionsчитается как «без абилок» (боты/e2e не ломаются). Тик по-прежнему 0 allocs/op.
Устойчивый буст скорости и щит-пикап (server-authoritative буфы) — отдельная будущая итерация: у них предсказание сложнее (движение зависит от буфа, которого клиент не знает).
Включается флагом 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.
Включается флагом 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).
Включается флагом 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).