Схема снапшота и форматы передачи
Данные, которые игра кладёт на провод: номера портов, бинарный snapshot-кадр и схема, которая его определяет, формат ввода и форматы мета-данных (авторизация, панель, статистика, чат, голосование). Номера портов и опкоды принадлежат движку; схема и ключи меты — игре.
Порты
Сервер → клиент
| Порт | Имя | Формат | Описание |
|---|---|---|---|
| 0 | CONFIG_DATA | JSON | Клиентский конфиг (merge vimp-engine/src/config/clientDefaults.js + src/config/client.js игры-плагина (например, в @vimp-games/tanks) + prediction) |
| 1 | AUTH_DATA | JSON | Данные формы авторизации |
| 2 | AUTH_RESULT | JSON | Ошибки авторизации (или null) |
| 3 | MAP_DATA | JSON | Данные карты |
| 4 | FIRST_SHOT_DATA | JSON | Первый кадр игры (одноразовый, минует буфер интерполяции): [gameSnapshot, 0, serverTime, 0] |
| 5 | SHOT_DATA | бинарный | Snapshot-кадр игры (см. ниже) |
| 6 | SOUND_DATA | JSON | Имя системного звука (roundStart, victory, frag, …) |
| 7 | GAME_INFORM_DATA | JSON | Игровые сообщения на экране ([код, параметры?]: 0 победа команды, 1 старт раунда, 2 game over — источник истины vimp-engine/src/config/gameCodes.js, общий для хоста и клиента) |
| 8 | TECH_INFORM_DATA | JSON | Технические сообщения «чёрного экрана» ([код, параметры?]: сервер полон, загрузка, кики); без данных — скрыть экран |
| 11 | CLEAR | JSON | Полная или частичная (по setId) очистка полотен |
| 13 | PANEL_DATA | JSON | Панель HUD (per-user, только при изменении) |
| 14 | STAT_DATA | JSON | Статистика (broadcast, только при изменении) |
| 15 | CHAT_DATA | JSON | Сообщение чата (общее или персональное) |
| 16 | VOTE_DATA | JSON | Данные голосования |
| 17 | KEYSET_DATA | JSON | Активный набор клавиш: 0 — наблюдатель, 1 — игрок; шлётся при смене статуса, а также при смене карты (набор наблюдателя непосредственно перед CLEAR) |
| 18 | ACCOLADES_DATA | JSON | Места участников комнаты в глобальном топе, формируются host/meta/modules/Accolades.js и применяются на клиенте lib/accolades.js |
Клиент → сервер
| Порт | Имя | Описание |
|---|---|---|
| 1 | AUTH_RESPONSE | Данные формы авторизации плюс лобби-JWT личности ({model, ..., token}); ник хост берёт из проверенного токена, а не из поля формы |
| 5 | KEYS_DATA | Ввод: строка "seq:action:name" (см. ниже) |
| 6 | CHAT_DATA | Текст сообщения / чат-команда |
| 7 | VOTE_DATA | Ответ голосования [voteName, value] или запрос списка ('maps', 'teams') |
Остальные порты — рукопожатие соединения, возобновление сессии, RTT ping/pong, обновление токена и отладочный лог хоста — внутренние для движка: плагин их не шлёт и не обрабатывает. Их номера остаются зарезервированными и не переиспользуются.
Разделение каналов: горячий снапшот и мета
Каждый тик снапшота (networkSendRate: 4 → 30 пакетов/сек) хост шлёт всем готовым пользователям бинарный кадр порта 5. Мета-данные идут своими JSON-каналами и только при изменении:
- panel (13) — per-user; массив строк
'ключ:значение', ключи из схемы панели игры (t— время раунда — единственный ключ, заданный движком; напр.@vimp-games/tanksдобавляетh— здоровье,w1/w2— боезапас,wa— активное оружие). Полная панель шлётся при входе в игру, пустая (только ключи) — наблюдателю. - stat (14) — broadcast, дельта изменений (см. формат ниже).
- chat (15) — общее сообщение либо персональное.
- vote (16) — общее либо персональное голосование.
- keyset (17) — точечно при смене статуса спектатор↔игрок.
Бинарный snapshot-кадр (порт 5)
Кодек целиком в Rust-ядре (vimp-engine-core): вы объявляете схему, движок упаковывает и декодирует кадр. Реестр ключей — данные игры: src/config/snapshot.js плагина (gameConfig.snapshot); версия формата остаётся у движка (SNAPSHOT_FORMAT_VERSION = 5), и при несовпадении версии клиент отбрасывает кадр.
Блоки сущностей (kind из снапшот-схемы игры)
Ключи сущностей, значения kind и форматы данных целиком заданы схемой снапшота игры-плагина — движок лишь навязывает раскладку блока (id + типизированные поля). Пример из референсного плагина (@vimp-games/tanks):
| Ключ | id | kind | Формат данных |
|---|---|---|---|
m1 | 1 | tanks | {gameId: [x, y, angle, gunRotation, vx, vy, engineLoad, condition, size, teamId, angvel] | null}; null — удалить с полотна |
w1 | 2 | tracers | массив [startX, startY, endX, endY, bodyX, bodyY, wasHit, shooterId] |
w2 | 3 | bombs | {shotId(base36): [x, y, angle, size, time, ownerId] | null} |
w2e | 4 | explosions | массив [x, y, radius] |
c1/c2 | 5/6 | dynamics | {'dN': [x, y, angle] | [x, y, angle, vx, vy, angvel]} — динамические элементы карты; хвост со скоростями опционален (см. ниже) |
Все float исходно округлены хостом до 2 знаков; декодер восстанавливает значения повторным округлением Float32 (player-блок — без округления). События игровых сущностей могут нести id автора (@vimp-games/tanks использует shooterId/ownerId, добавлены в v3), чтобы клиент подавлял авторитетные дубли локально заспавненных сущностей (клиентское ядро, core/src/client/shot.rs игры-плагина, например в @vimp-games/tanks).
Опциональный хвост строки (optionalFrom, v4). Схема вправе объявить, что поля строки начиная с индекса optionalFrom пишутся, только когда несут смысл: такая строка начинается с флаг-байта (1 — дальше хвост, 0 — строка кончилась на обязательной части). @vimp-games/tanks использует его для динамических элементов карты: покоящийся ящик шлёт [x, y, angle] и не платит 12 байт за [vx, vy, angvel] каждый кадр, а только что получивший удар — шлёт скорости: клиент предсказывает динамику карты рядом со своим танком, а оценка скорости конечной разностью между кадрами 30 Гц сильнее всего врёт именно в момент удара. Распаковка всегда отдаёт строку полной ширины: отсутствующий хвост читается нулями («тело покоится»), поэтому интерполятор, hot-буфер и JS остаются фиксированной ширины.
Запись схемы — не только {id, kind}: class ('hot' — интерполируется клиентом между кадрами, 'event' — одноразовый, кадром как есть) и fields — схема полей строки (name, ty: f32/u8/u16/u32, interp: lerp/lerpAngle/discrete для class: 'hot'). fields обязана точно совпадать по количеству и порядку типов с Row-структурой ключа в vimp-engine-core/src/snapshot.rs (GameCore/ClientCore отклоняют конструктор при расхождении), а optionalFrom (если задан) обязан указывать на непустой хвост внутри fields.
Поле вправе объявить и role — свою роль в движковом контракте (в отличие от name, которое принадлежит игре). Ролей сегодня две, и они ходят парой в блоке динамики карты: role: 'z' на позиции 3 и role: 'level' на позиции 4 делают строку динамики слоёной (высота тела над своим уровнем и сам уровень). Раньше движок узнавал эту пару по именам полей, и переименование поля молча возвращало плоскую строку; теперь авторитет — только роли, а загрузка карты падает с ошибкой, если роль стоит не на своей позиции, если объявлена лишь одна из пары или если на этих двух местах стоят поля с именами z/level без ролей.
Третья роль, role: 'state', — байт состояния тела карты: u8, который движок пишет сразу за головой строки — на позиции 5 у слоёной строки, 3 у плоской — и до необязательного хвоста скоростей, поэтому optionalFrom обязан быть больше его позиции (покоящееся тело хвост не шлёт и потеряло бы байт). Строка становится [x, y, angle, z?, level?, state, vx?, vy?, angvel?]. Значение даёт игра (SimCtx::map_body_state), смысл значений — её. Схема без этой роли сохраняет прежнюю строку бит-в-бит; роль не на своей позиции или не того типа роняет загрузку карты.
Свойства схемы
| Свойство | Значения | Примечания |
|---|---|---|
id | u8, уникальный | ключевой байт на проводе; валидация проверяет только уникальность |
kind | indexed8 · indexed32 · list16 · indexedNoNull8 | форма блока на проводе, см. ниже |
class | hot · event | hot — непрерывное состояние, event — одноразовое |
fields[].ty | f32 · u8 · u16 · u32 | big-endian |
fields[].interp | lerp · lerpAngle · discrete (по умолчанию) | интерполируются только f32-поля и только hot-блоки |
fields[].role | z · level · state | поля строки динамики карты, которые пишет движок |
Порядок полей позиционно привязан к построению Rust-Row игры, и соответствие не проверяется глубже количества и типов полей: перестановка двух полей одного типа только с одной стороны молча даёт мусор.
Четыре вида блоков
hasData — u8-маркер пустоты: 0 — сущности нет (JSON null), 1 — далее строка полей. Тело — конкатенация [u8 keyId][блок] для каждого ключа, у которого в этом тике есть содержимое.
| Kind | Префикс счётчика | Строка | Для чего |
|---|---|---|---|
indexed8 | u8 count | u8 id, u8 hasData, поля | до 255 адресуемых сущностей (игроки) |
indexed32 | u16 count | u32 id, u8 hasData, поля | много короткоживущих сущностей (снаряды) |
list16 | u16 count | поля | анонимные события (трассеры, взрывы) |
indexedNoNull8 | u8 count | u8 index, поля | данные с фиксированными слотами, не исчезающие (динамика карты) |
Интерполяция
Клиент рендерит мир в момент serverNow − delay (interpolation.delay 100 мс, interpolation.maxFrameAge 1000 мс в clientDefaults.js); при 30 кадрах/с (networkSendRate: 4) это около 3 кадров буфера. f32-поля с lerp интерполируются линейно, lerpAngle — по кратчайшей дуге окружности, всё остальное — скачком.
Округление
Каждый передаваемый f32 фактически несёт 2 знака после запятой, кроме пользовательского player-блока (предикту нужна полная точность). Округление выполняет игра, а не упаковщик: упаковщик пишет f32 как есть, а декодер пропускает каждое поле через round2. Значение, упакованное без округления, дойдёт до клиента другим числом, чем то, что хранит хост, — вызывайте vimp_engine_core::physics::round2 для float'ов, которые передаёте в build_snapshot_blocks. Для блока динамики карты, который ведёт сам движок, он делает это сам.
Проектирование схемы
- Один
hot-ключindexed8на каждый постоянный тип актора (игроки, машины). indexedNoNull8— для мирового состояния с фиксированными слотами (динамика карты).eventlist16— для анонимных одноразовых эффектов.eventindexed32— для именованных короткоживущих сущностей, которым нужны обновления (летящие гранаты).- Держите число hot-полей малым: каждое поле стоит байт 30 раз в секунду на сущность.
- Id автора ставьте последним в каждом блоке события оружия — на нём держится клиентское подавление дублей.
При добавлении нового оружия/сущности его snapshot-ключ обязан быть зарегистрирован в снапшот-схеме игры-плагина (src/config/snapshot.js, например в @vimp-games/tanks) — с полным fields для своего kind, иначе pack_body/конструктор ядра бросят ошибку. Если существующие kind не подходят — добавить новую раскладку блока в vimp-engine-core/src/snapshot.rs + vimp-engine-core/src/client/unpack.rs и поднять версию формата.
Формат ввода: "seq:action:name"
Клиент шлёт каждое событие клавиши строкой на порт 5 (client → server):
seq— инкрементный номер ввода (Uint32), пишется в локальную историю предиктора;action—down|up;name— команда (forward,fire,nextPlayer, …).
Сервер сохраняет lastInputSeq пользователя и возвращает его в player-блоке кадра — так клиент узнаёт, какие вводы уже учтены authoritative-состоянием, и переигрывает (reconciliation) только более поздние. Подробно — в client-api.md.
У наблюдателя те же строки обрабатываются сервером как переключение наблюдаемого игрока (nextPlayer/prevPlayer).
RTT и кики
Хост пингует каждого клиента (rttPingInterval, 3 с), клиент отвечает; сглаженная (EMA) задержка публикуется в статистику столбцом latency. Клиента кикает:
- при сглаженной
latency > maxLatency(1000 мс; порог рассчитан на P2P-хостинг с домашних каналов и спайки при смене карты) — код4003; - при
maxMissedPings(5) подряд пропущенных ответах — код4004.
Все три значения — дефолты хоста движка, игра вправе их переопределить (см. game-config.md).
Форматы мета-данных
Авторизация (порт 1)
Payload AUTH_DATA (PS_AUTH_DATA): { elems, params, texts } (hostPlugin.authSchema — см. plugin-api.md):
elems— DOM-id, которые нейтральный каркас экране авторизации открывает игре-плагину (authId,errorId,enterId,fieldsId, и для текстов ниже —titleId/informsId); значения плагина должны совпадать с реальными id в экране авторизации.params[]—{ name, value, options }, схема полей игрока (тот же контракт дескрипторов, что иroomForm):optionsнесётcontrol/label/unit/numeric/min/max/options/source/storage/regExp/required/maxlength/hiddenплюс уже существующийvalidator(ключ вauthSchema.validators, проверяется только на хосте — код валидатора по проводу не идёт). Клиент строит по одному контролу на param черезformBuilder.jsи вставляет в#+elems.fieldsId, подставляя значение изlocalStorage[storage], если оно есть.texts— опционально{ title, sections }, заполняетelems.titleId/elems.informsIdнейтрального каркаса.
Панель (порт 13)
Массив строк 'ключ:значение', например ['t:97', 'h:100', 'w1:200', 'wa:w1']. Отправляются только изменившиеся ключи; t (время раунда, сек) — при каждом изменении секунды. Пустая панель (наблюдателю) — время + список ключей без значений (контейнеры скрываются).
Статистика (порт 14)
statArray = [tBodies, tHead, fullUpdate?] (формирует Stat):
statArray[0]— строки таблиц:[id строки, номер таблицы, массив ячеек | null, номер tbody].nullвместо ячеек — удалить строку; пустая строка в ячейке — очистить значение;undefined/пропуск — не менять.statArray[1]— шапки:[номер таблицы, массив ячеек, номер строки tHead].statArray[2]— флаг полного обновления (boolean, опционально).
Ячейки строки игрока: [name, status, score, deaths, latency] (порядок — key из game:stat).
Чат (порт 15)
- Пользовательское сообщение:
[текст, имя автора, teamId]. - Системное сообщение: строка
'группа:номер:параметры,через,запятую'— клиент собирает текст из шаблоновmessagesигры, а для движковых групп (s,v,m,c) при отсутствии текста берёт английское умолчание движка (src/config/chatMessages.js). У игровой группы (например,bу танков) умолчания нет: нет текста — нет сообщения.
Голосование (порт 16)
Сервер шлёт payload:
name— имя/тип голосования (клиент ищет шаблон вclient.js → modules.vote.params.templates);params— опционально; строки для подстановки в плейсхолдеры{0},{1}заголовка;values— опционально; массив готовых вариантов или строка-команда ('maps','teams') — клиент запрашивает актуальный список у сервера (порт 7 client → server).
Ответ клиента: [voteName, selectedValue]. Запрос динамического списка: строка 'maps' | 'teams'.
Голосование «Change host» (лобби-режим) этим портом не пользуется: оно идёт между клиентами и платформой, поэтому хост, о котором оно, его не видит. Его окно — голосование движка с именем @changeHost; ответ на него не уходит как [voteName, value] по порту 7. Имена с @ зарезервированы за голосованиями движка.
← Предыдущая: Справочник конфигурации игры · Следующая: Rust-ядро движка (vimp-engine-core) →