Zloy Backend

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

← Документация API приложений

Бекенд собирается в Студии#

Кубики не подключают библиотекой и не разворачивают на сервере — их ставят на холст.

Откройте проект в приложении Zloy Studio и нажмите значок ⬡ «Zloy Backend» в верхней панели — он есть и на экране проекта, и в редакторе кода. Откроется холст: узлы — это кубики, стрелки — связи между ними, справа — инспектор выбранного узла.

Всё, что вы соберёте на холсте, платформа хранит в одном файле — zloy_server.json в корне проекта. Он лежит в том же git-репозитории, что и игра, и каждое «Сохранить» — это коммит. Поэтому у сборки бекенда три равноправных входа, и все три правят один и тот же файл:

  • Редактор «Бекенд» — мышью, с подсказками и проверками. Основной путь.
  • ИИ-агент — «сделай лобби на восемь человек с чатом»; он ходит теми же инструментами (см. ниже).
  • Рукамиzloy_server.json в редакторе кода, если так быстрее.

Серверного кода в этом файле нет и быть не может: вы описываете, какие кубики включены и как они соединены, а исполняет это наш код. Отсюда и главное свойство — бекенд игры невозможно «уронить».

zloy_server.json
{
  "version": 1,
  "cubes": {
    "lobby": {"cube": "room", "props": {"capacity": 8, "min_start": 8}},
    "chat":  {"cube": "chat"}
  },
  "links": [
    {"from": "lobby.onJoin", "to": "chat.announce",
     "with": {"channel": "room", "room": "{room}",
              "text": "{name} вошёл"}}
  ],
  "ui": {
    "lobby": {"x": 40,  "y": 40},
    "chat":  {"x": 320, "y": 40}
  }
}
flowchart LR
  A["Студия · вкладка «Бекенд»"]:::accent
  B["ИИ-агент"]:::cube
  C["Редактор кода"]:::cube
  M["zloy_server.json<br>в git-репозитории игры"]:::data
  RT["Рантайм кубиков<br>api.zloy.net/rt"]:::accent
  K["room · chat · store · timer<br>bank · board · sync · session"]:::cube
  G["Игра у игрока"]:::cube
  A -->|"Сохранить"| M
  B -->|"add_backend_cube"| M
  C -->|"правка руками"| M
  M -->|"dev: перечитывается за ~15 с"| RT
  M -->|"prod: снапшот при публикации"| RT
  RT --> K
  G <-->|"WebSocket api.zloy.net/rt/ws"| RT

  classDef accent fill:#1a1114,stroke:#ff3b30,stroke-width:1.5px,color:#f6e9e8
  classDef cube fill:#14161c,stroke:#3a3f47,color:#e8eae6
  classDef data fill:#101218,stroke:#2f3540,color:#aab0b8
Путь настройки: холст → zloy_server.json в репозитории игры → рантайм кубиков. Игра к рантайму подключается сама, одним сокетом.

Блок ui в файле — только координаты узлов на холсте, рантайм его не читает. Правка руками ничего не сломает: узел без координат редактор просто разложит сам.

Файла нет? Тогда включены все кубики с настройками по умолчанию, а имя инстанса совпадает с именем кубика. Игра может подключиться и позвать chat.send, не открывая редактор ни разу — настройки нужны, когда умолчания перестали устраивать.

Шаг 1. Поставить кубик#

Тип берут из каталога, инстанс называют — под этим именем игра к нему и обращается.

Слева вверху холста — чип «+ Кубик». В списке живой каталог сервера (тот же, что отдаёт GET /rt/cubes), поэтому новые кубики платформы появляются в меню без обновления приложения. Выбрали тип — на холсте появился узел.

Тип и инстанс — разные вещи. room — тип, «комната вообще». hall и lobby — два инстанса этого типа с разными настройками: холл на 64 места без старта и боевая комната на восемь. Ставьте столько инстансов, сколько нужно игре: у них раздельные данные, события и связи.

Имя инстанса — это адрес. Первый узел называется как тип (room), второй — room2; переименуйте карандашом в инспекторе, пока имя не разошлось с ролью. Именно это имя игра указывает первым аргументом при вызове, и оно же стоит слева от точки в связях — при переименовании редактор чинит связи сам.

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

zloy_server.json
{
  "cubes": {
    "hall":  {"cube": "room",
              "props": {"capacity": 64, "min_start": 64}},
    "lobby": {"cube": "room",
              "props": {"capacity": 8, "min_start": 8,
                        "auto_start": true}},
    "chat":  {"cube": "chat", "props": {"rate_per_10s": 5}}
  }
}
flowchart LR
  T["Тип room<br>из каталога платформы"]:::accent
  I1["hall<br>capacity 64 · без старта"]:::cube
  I2["lobby<br>capacity 8 · min_start 8"]:::cube
  G["Игра"]:::data
  T -->|"+ Кубик"| I1
  T -->|"+ Кубик ещё раз"| I2
  I1 -.->|"op hall.quick"| G
  I2 -.->|"op lobby.quick"| G

  classDef accent fill:#1a1114,stroke:#ff3b30,stroke-width:1.5px,color:#f6e9e8
  classDef cube fill:#14161c,stroke:#3a3f47,color:#e8eae6
  classDef data fill:#101218,stroke:#2f3540,color:#aab0b8
Один тип — сколько угодно инстансов. Игра различает их по имени.

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

# Общий холл: просто попасть куда-нибудь
send_op("hall", "quick")

# Боевая комната на восьмерых
send_op("lobby", "quick")
send_op("lobby", "ready", {"v": true})
// Общий холл: просто попасть куда-нибудь
await zloy.rt.op('hall', 'quick');

// Боевая комната на восьмерых
const {room} = await zloy.rt.op('lobby', 'quick');
await zloy.rt.op('lobby', 'ready', {v: true});
const hall = await zloy.rt.op<{room: string}>('hall', 'quick');
const game = await zloy.rt.op<{room: string; slot: number}>('lobby', 'quick');

await zloy.rt.op('lobby', 'ready', {v: true});

Шаг 3. Мир игры (кубик session)#

У session есть свой экран: сущности, время, действия, правила и условия конца.

Комната, чат и таймер настраиваются парой полей. Кубик session — это уже правила партии, поэтому его свойство rules редактируется отдельным экраном: выберите узел и нажмите «Редактор мира…» в инспекторе.

Пять вкладок:

  • Сущности — из чего состоит мир: игрок, грядка, ковёр-самолёт, предмет. У сущности есть владелец, состояние и свойства.
  • Время — фазы (день/ночь, ход/подсчёт) и сроки созревания. Мир может жить и без фаз.
  • Действия — что игрок делает: посадить, собрать, украсть, выстрелить. Здесь же условия допуска: фаза, кулдаун, требования к цели.
  • Правила — автоматические реакции: «созрело — сделай», «погиб — верни через три секунды».
  • Конец — при каких условиях партия завершается и кто победил. Здесь же счёт игрока: свойство сущности, которое считается результатом (frags, coins, harvest). Заполнили — и по окончании партии каждый игрок получает событие result со своим числом и местом; связь result → board.submit кладёт это в таблицу рекордов, не спрашивая клиента.

Кнопка «Заготовка» в шапке подставляет готовый мир целиком (социальная дедукция или ферма) — с подтверждением, потому что она заменяет текущее описание. Дальше это просто ваш мир: переименуйте роли, смените фазы, добавьте действия. Движок один и тот же, «мафии» внутри него нет — есть сущности, фазы и эффекты.

Готово — «Применить», и вы возвращаетесь на холст; не забудьте «Сохранить» уже там.

flowchart LR
  E["Сущности<br>игрок · грядка · ковёр"]:::cube
  T["Время<br>фазы и созревание"]:::cube
  D["Действие игрока<br>session.do"]:::accent
  Q{"Проверки"}
  X["error — отказ игроку"]:::data
  F["Эффекты<br>set · add · state<br>take · spawn · bank"]:::accent
  R["Правила<br>when → if → then"]:::cube
  N{"Конец?"}
  S["ended + result<br>→ board.submit"]:::accent
  E --> D
  T --> D
  D --> Q
  Q -->|"не прошло"| X
  Q -->|"прошло"| F
  F --> R
  R --> N
  N -->|"нет"| D
  N -->|"да"| S

  classDef accent fill:#1a1114,stroke:#ff3b30,stroke-width:1.5px,color:#f6e9e8
  classDef cube fill:#14161c,stroke:#3a3f47,color:#e8eae6
  classDef data fill:#101218,stroke:#2f3540,color:#aab0b8
Цикл мира: действие проходит проверки, применяет эффекты, будит правила — и после каждого шага проверяются условия конца.

Шаг 4. Сохранить, проверить, опубликовать#

Черновик применяется сам за пятнадцать секунд, прод — только через публикацию.

Кнопка «Сохранить» оживает, как только вы что-то изменили; рядом горит «есть несохранённые изменения», а попытка выйти с холста переспросит. Сохранение пишет zloy_server.json коммитом backend: граф кубиков — историю видно в проекте.

dev применяется на лету. Рантайм перечитывает манифест черновика не реже чем раз в ~15 секунд, перезапускать ничего не нужно: подождите и переоткройте превью. Данные dev отдельные — свои комнаты, свои сохранения, «песочные» деньги банка. Ломайте спокойно.

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

Проверить всё вместе: на экране проекта (сборка → черновик → превью) либо вкладка «Публикация» — там же лог изменений и кнопка отправки на модерацию. Сама игра — сборка, файлы, кубики — живёт в Zloy Cloud: своего хостинга заводить не нужно.

flowchart LR
  E["Правка графа<br>на холсте"]:::cube
  S["Сохранить<br>git-коммит zloy_server.json"]:::accent
  D["dev-рантайм<br>перечитал за ~15 с"]:::cube
  P["▶ Превью<br>играете на dev-данных"]:::cube
  U["Вкладка «Публикация»"]:::accent
  M["Модерация"]:::cube
  R["prod-рантайм<br>снапшот манифеста версии"]:::accent
  E --> S --> D --> P --> U --> M --> R

  classDef accent fill:#1a1114,stroke:#ff3b30,stroke-width:1.5px,color:#f6e9e8
  classDef cube fill:#14161c,stroke:#3a3f47,color:#e8eae6
  classDef data fill:#101218,stroke:#2f3540,color:#aab0b8
Что происходит после «Сохранить».

Пример целиком: лобби, чат, таймер, рекорды#

Восемь кликов от пустого холста до работающего матча на пятнадцать минут.

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

  1. + Кубик → Комната. Переименуйте в lobby. Свойства: capacity 8, min_start 8, auto_start включить.
  2. + Кубик → Чат. Оставьте имя chat.
  3. + Кубик → Таймер (timer), + Кубик → Партия (match — это тип session), + Кубик → Рекорды (board, свойство client_submit выключить: результат пишет сервер, не игрок).
  4. ⇢ с lobby на chat: событие onJoin, операция announce, поля — как справа.
  5. Ещё раз ⇢ с lobby на chat: onStartannounce, «Матч начался».
  6. ⇢ с lobby на match: onStartstart, поля пустые.
  7. ⇢ с lobby на timer: onStartafter, {"sec": 900, "tag": "match_end", "room": "{room}"}. И ⇢ с timer на match: firedadvance.
  8. ⇢ с match на board: событие result → операция submit, поля {"board": "kills"}. Событие result приходит по одному на игрока — с его счётом и местом; чтобы движок знал, что считать счётом, в редакторе мира на вкладке «Конец» укажите свойство-счёт. Сохранить.

Пятнадцать минут отсчитывает сервер, а не устройство игрока: закрытая вкладка, спящий телефон и подкрученные часы на матч не влияют.

zloy_server.json целиком
{
  "version": 1,
  "cubes": {
    "lobby": {"cube": "room",
              "props": {"capacity": 8, "min_start": 8,
                        "auto_start": true}},
    "chat":  {"cube": "chat"},
    "timer": {"cube": "timer"},
    "match": {"cube": "session", "props": {"min_players": 8}},
    "board": {"cube": "board", "props": {"client_submit": false}}
  },
  "links": [
    {"from": "lobby.onJoin",  "to": "chat.announce",
     "with": {"channel": "room", "room": "{room}",
              "text": "{name} в комнате"}},
    {"from": "lobby.onStart", "to": "chat.announce",
     "with": {"channel": "room", "room": "{room}",
              "text": "Матч начался — 15 минут!"}},
    {"from": "lobby.onStart", "to": "match.start"},
    {"from": "lobby.onStart", "to": "timer.after",
     "with": {"sec": 900, "tag": "match_end",
              "data": {"room": "{room}"}}},
    {"from": "timer.fired",   "to": "match.advance"},
    {"from": "match.result",  "to": "board.submit",
     "with": {"board": "kills"}}
  ]
}
flowchart LR
  lobby["lobby · room<br>8 мест, авто-старт"]:::accent
  chat["chat · chat"]:::cube
  timer["timer · timer"]:::cube
  match["match · session"]:::accent
  board["board · board<br>client_submit off"]:::cube
  lobby -->|"onJoin → announce"| chat
  lobby -->|"onStart → announce"| chat
  lobby -->|"onStart → start"| match
  lobby -->|"onStart → after 900 с"| timer
  timer -->|"fired → advance"| match
  match -->|"result → submit"| board

  classDef accent fill:#1a1114,stroke:#ff3b30,stroke-width:1.5px,color:#f6e9e8
  classDef cube fill:#14161c,stroke:#3a3f47,color:#e8eae6
  classDef data fill:#101218,stroke:#2f3540,color:#aab0b8
Тот же манифест глазами холста: пять узлов и шесть стрелок.

Вся клиентская часть этого каркаса — справа. Игра зовёт две операции (quick и ready) и слушает четыре события; кто кому что объявил, когда стартовать и куда записать результат, решают связи.

func _ready() -> void:
    _connect()          # см. раздел «Подключение»

func _on_connected() -> void:
    send_op("lobby", "quick")

func _on_event(env: Dictionary) -> void:
    match [env.cube, env.op]:
        ["chat", "message"]:
            chat_log.add(env.payload)
        ["room", "state"]:
            lobby_ui.update(env.payload)     # кто в комнате, кто готов
        ["session", "phase"]:
            hud.set_deadline(env.payload.ends_at)
        ["session", "ended"]:
            show_results(env.payload.result)

func _on_ready_pressed() -> void:
    send_op("lobby", "ready", {"v": true})
await zloy.rt.connect();
await zloy.rt.op('lobby', 'quick');

zloy.rt.on('chat', 'message', (m) => chat.add(m));
zloy.rt.on('lobby', 'state', (s) => lobbyUi.update(s));

// Матч: сервер сам скажет, когда начался и сколько осталось
zloy.rt.on('match', 'phase', (p) => hud.setDeadline(p.ends_at));
zloy.rt.on('match', 'ended', (p) => showResults(p.result));

readyBtn.onclick = () => zloy.rt.op('lobby', 'ready', {v: true});
interface PhasePayload { room: string; phase: string; round: number; ends_at: number }
interface EndedPayload { room: string; result: Record<string, unknown> }

await zloy.rt.connect();
await zloy.rt.op('lobby', 'quick');

zloy.rt.on('match', 'phase', (p: PhasePayload) => hud.setDeadline(p.ends_at));
zloy.rt.on('match', 'ended', (p: EndedPayload) => showResults(p.result));

// Таблица рекордов — обычное чтение, писать в неё игра не может
const top = await zloy.rt.op<{rows: BoardRow[]}>('board', 'top', {board: 'kills', limit: 20});

То же самое словами: ИИ-агент#

У агента в студии есть инструменты для кубиков — он собирает граф сам.

В чате проекта агент видит те же кубики, что и вы, через MCP-инструменты студии:

  • backend_cubes — каталог кубиков и протокол подключения; с него агент начинает, когда игре нужен мультиплеер.
  • describe_cube — свойства, операции и события одного кубика (та же правда, что на этой странице).
  • add_backend_cube — поставить кубик в проект.
  • link_backend_cubes — соединить событие с операцией.

Инструменты правят тот же zloy_server.json и делают тот же git-коммит, поэтому после работы агента откройте вкладку «Бекенд» — граф будет уже собран, узлы можно двигать и донастраивать мышью. Обратное тоже верно: агент видит ваш холст и продолжает с него.

Ключи от ИИ-провайдера остаются на вашем устройстве; на сервер уезжает только результат — файлы проекта.

диалог в студии
Вы:
Сделай лобби на 8 человек с чатом: объявляй вход и выход,
матч на 15 минут, результат — в таблицу рекордов.

Агент:
→ backend_cubes
→ add_backend_cube  {cube: "room",    instance: "lobby",
                     props: {capacity: 8, min_start: 8}}
→ add_backend_cube  {cube: "chat"}
→ add_backend_cube  {cube: "timer"}
→ add_backend_cube  {cube: "board",
                     props: {client_submit: false}}
→ link_backend_cubes {from: "lobby.onJoin", to: "chat.announce",
                      with: {channel: "room", room: "{room}",
                             text: "{name} в комнате"}}
→ link_backend_cubes {from: "lobby.onStart", to: "timer.after",
                      with: {sec: 900, tag: "match_end"}}
Готово: 4 кубика, 3 связи. В превью — сразу, в прод — с публикацией.

Подключение#

Один сокет на игру. Личность игрока платформа уже подписала — просто передайте её дальше.

Игра открывается по адресу с четырьмя параметрами: app_id, user_id, ts, sign. Это подписанная личность игрока (та же, что у REST API приложений). Возьмите их из адреса страницы и передайте в адрес сокета без изменений.

В dev-превью вместо user_id приходит подпись черновика — её тоже достаточно передать как есть: сервер сам поймёт, что это песочница.

Сразу после подключения приходит событие hello со списком доступных кубиков и вашим user_id.

# Godot 4: WebSocketPeer + параметры из адреса страницы
extends Node

var ws := WebSocketPeer.new()
var seq := 0

func _ready() -> void:
    var q := JavaScriptBridge.eval("location.search.slice(1)", true)
    ws.connect_to_url("wss://api.zloy.net/rt/ws?" + str(q))

func _process(_dt: float) -> void:
    ws.poll()
    while ws.get_available_packet_count() > 0:
        var env: Dictionary = JSON.parse_string(
            ws.get_packet().get_string_from_utf8())
        _on_event(env)

func send_op(cube: String, op: String, payload: Dictionary = {}) -> int:
    seq += 1
    ws.send_text(JSON.stringify({
        "cube": cube, "op": op, "seq": seq, "payload": payload,
    }))
    return seq

func _on_event(env: Dictionary) -> void:
    match env.get("op"):
        "hello":
            print("я игрок ", env.payload.user_id,
                  ", кубики: ", env.payload.cubes)
<script src="https://zloy.net/assets/zloy-sdk.js"></script>
<script>
  // SDK сам берёт подписанные параметры из адреса страницы
  zloy.rt.connect();

  zloy.rt.on('', 'hello', (p) => {
    console.log('я игрок', p.user_id, 'кубики:', p.cubes);
  });

  // Вызов операции — промис с ответом сервера
  const rooms = await zloy.rt.op('room', 'list');
</script>
import 'https://zloy.net/assets/zloy-sdk.js';

declare const zloy: {
  rt: {
    connect(): void;
    close(): void;
    connected(): boolean;
    on(cube: string, op: string, fn: (payload: any) => void): void;
    op<T = any>(cube: string, op: string, payload?: object): Promise<T>;
    onReconnect: ((hello: HelloPayload) => void) | null;
  };
};

interface HelloPayload {
  user_id: number;
  name: string;
  env: 'dev' | 'prod';
  cubes: string[];
}

zloy.rt.connect();
zloy.rt.on('', 'hello', (p: HelloPayload) => {
  console.log('я игрок', p.user_id, 'кубики:', p.cubes);
});

Конверт и ответы#

Одинаковый формат в обе стороны.

Каждое сообщение — конверт из четырёх полей:

cubeимя инстанса кубика из вашего zloy_server.json. По умолчанию совпадает с именем кубика: room, chat, …
opоперация кубика
seqваш номер запроса; ответ придёт с тем же номером. Серверные события приходят с seq: 0
payloadпараметры операции

Ответ на запрос — конверт с тем же seq и операцией-результатом (например list.result). События приходят сами, без запроса. Проверка связи — {"op":"ping"}, в ответ pong.

# запрос
{"cube": "room", "op": "join", "seq": 7, "payload": {"code": "AB12CD"}}

# ответ (тот же seq)
{"cube": "room", "op": "joined", "seq": 7, "payload": {"room": "r1"}}

# событие (seq = 0, приходит само)
{"cube": "room", "op": "state", "seq": 0, "payload": {"room": {...}}}

# ошибка
{"cube": "room", "op": "error", "seq": 7,
 "payload": {"code": "room_full", "message": "комната заполнена"}}
// Промис завершится ответом с тем же seq
try {
  const r = await zloy.rt.op('room', 'join', {code: 'AB12CD'});
  console.log('вошли в', r.room);
} catch (e) {
  console.warn('не вышло:', e.code, e.message);
}

// События — подпиской
zloy.rt.on('room', 'state', (p) => render(p.room));
interface RoomJoined { room: string }
interface RtError { code: string; message: string }

try {
  const r = await zloy.rt.op<RoomJoined>('room', 'join', {code: 'AB12CD'});
  console.log('вошли в', r.room);
} catch (e) {
  const err = e as RtError;
  console.warn('не вышло:', err.code, err.message);
}

zloy.rt.on('room', 'state', (p: {room: RoomState}) => render(p.room));

Ошибки и лимиты#

Отказ — это всегда конверт с op: error и машиночитаемым кодом.

Общие коды: no_cube (кубика нет в манифесте), no_op (нет такой операции), bad_payload, rate_limited (слишком часто), overloaded (кубик перегружен, повторите), internal. Коды конкретных операций описаны ниже у каждого кубика.

Лимиты соединения, которые стоит держать в голове:

  • до 30 сообщений в секунду (кратковременно до 60);
  • размер сообщения — до 16 КБ;
  • очередь исходящих — 256 сообщений: клиент, который не успевает читать, отключается (это защита от «медленных» вкладок);
  • число одновременных подключений на игру ограничено тарифом.

Остальные лимиты — свойства кубиков (вместимость комнаты, длина сообщения, размер сохранения): их видно и меняется во вкладке «Бекенд» студии.

func _on_event(env: Dictionary) -> void:
    if env.get("op") == "error":
        var p: Dictionary = env.payload
        match p.code:
            "rate_limited": _show("Слишком часто, подождите")
            "room_full":    _show("Комната заполнена")
            _:              push_warning("ошибка: %s" % p.message)
zloy.rt.op('chat', 'send', {text}).catch((e) => {
  if (e.code === 'rate_limited') showToast('Слишком часто');
  else if (e.code === 'not_in_room') showToast('Сначала войдите в комнату');
  else console.error(e);
});
type ErrCode = 'rate_limited' | 'not_in_room' | 'no_cube' | 'internal';

zloy.rt.op('chat', 'send', {text}).catch((e: {code: ErrCode}) => {
  if (e.code === 'rate_limited') showToast('Слишком часто');
});

Манифест zloy_server.json#

Какие кубики есть у игры и как они соединены между собой.

Файл zloy_server.json в корне проекта описывает бекенд игры: какие кубики включены, с какими настройками и как связаны. Его собирает визуальный редактор во вкладке «Бекенд» студии — или ИИ-агент по вашей просьбе. Писать руками не обязательно.

Связь — это «событие одного кубика запускает операцию другого». Роутинг исполняет наш код, вы описываете только граф. Значения в with могут подставлять поля события: {name}.

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

{
  "version": 1,
  "cubes": {
    "lobby": {"cube": "room", "props": {"capacity": 10, "min_start": 4}},
    "chat":  {"cube": "chat", "props": {"rate_per_10s": 5}},
    "save":  {"cube": "store"},
    "bank":  {"cube": "bank", "props": {"allow_bets": true}}
  },
  "links": [
    {"from": "lobby.onJoin", "to": "chat.announce",
     "with": {"channel": "room", "text": "{name} вошёл"}},
    {"from": "lobby.onStart", "to": "session.start"}
  ]
}
{
  "version": 1,
  "cubes": {
    "lobby": {"cube": "room", "props": {"capacity": 10, "min_start": 4}},
    "chat":  {"cube": "chat", "props": {"rate_per_10s": 5}},
    "save":  {"cube": "store"},
    "bank":  {"cube": "bank", "props": {"allow_bets": true}}
  },
  "links": [
    {"from": "lobby.onJoin", "to": "chat.announce",
     "with": {"channel": "room", "text": "{name} вошёл"}},
    {"from": "lobby.onStart", "to": "session.start"}
  ]
}

// Обращение к инстансу — по его имени из манифеста:
await zloy.rt.op('lobby', 'quick');
// Тот же zloy_server.json; в коде инстанс адресуется по имени:
await zloy.rt.op('lobby', 'quick');
await zloy.rt.op('save', 'set', {scope: 'player', key: 'progress', value});

dev и prod#

Черновик и опубликованная игра работают с разными данными.

Превью из студии подключается к dev-окружению: там свои комнаты, свои сохранения и «песочные» деньги банка. Опубликованная игра работает с prod. Данные не пересекаются — тестируйте спокойно.

Настройки бекенда в dev применяются на лету (правка zloy_server.json подхватывается за ~15 секунд), а в prod уезжают вместе с публикацией версии и проходят ту же модерацию, что игра.

Банк bank#

Внутриигровая экономика: счета в валютах игры (монеты, опыт, уровни), покупки, начисления связями, переводы, СТАВКИ (котёл матча: скинулись — победители забрали). Строгий ledger, идемпотентность. Приём реальных денег — свойство игры, не кубика.

Хранение: postgres

Настройки

Задаются в студии (вкладка «Бекенд») или в zloy_server.json. Значения вне границ сервер приводит к допустимым — лимит можно ослабить, но не пробить.

СвойствоТипПо умолчаниюЧто делает
currenciesjson[{"code":"gold","initial":0,"name":"Золото"}]валюты игры: [{code, name, initial}] — код, название, стартовый баланс нового игрока
allow_transferboolfalseпереводы между игроками
max_amountint1000000 1…1e+09максимум одной операции
daily_debit_limitint0 0…1e+09лимит списаний игрока в сутки (0 = без лимита)
allow_betsboolfalseставки: игроки скидываются в котёл матча
max_betint10000 1…1e+09максимальная ставка

Операции

bank.balanceклиент#

балансы всех счетов

# балансы всех счетов
send_op("bank", "balance")

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// балансы всех счетов
const r = await zloy.rt.op('bank', 'balance');
console.log(r);
// балансы всех счетов
const r = await zloy.rt.op<BalanceResult>('bank', 'balance');

bank.statementклиент#

последние операции игрока

Параметры: {"limit"?: 20}

# последние операции игрока
send_op("bank", "statement", {"limit": 20})

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// последние операции игрока
const r = await zloy.rt.op('bank', 'statement', {limit: 20});
console.log(r);
// последние операции игрока
const r = await zloy.rt.op<StatementResult>('bank', 'statement', {limit: 20});

bank.debitклиент#

списать (покупка); идемпотентно по op_id

Параметры: {"currency", "amount", "reason"?, "op_id"}

# списать (покупка); идемпотентно по op_id
send_op("bank", "debit", {"currency": "gold", "amount": 150, "reason": "меч", "op_id": "buy-sword-1"})

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// списать (покупка); идемпотентно по op_id
const r = await zloy.rt.op('bank', 'debit', {amount: 150, currency: 'gold', op_id: 'buy-sword-1', reason: 'меч'});
console.log(r);
// списать (покупка); идемпотентно по op_id
const r = await zloy.rt.op<DebitResult>('bank', 'debit', {amount: 150, currency: 'gold', op_id: 'buy-sword-1', reason: 'меч'});

bank.creditтолько связи#

начислить (только связи/сервер)

Параметры: {"user_id", "currency", "amount", "reason"?, "op_id"?}

// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "bank.credit",
     "with": {"…": "…"}}
  ]
}
// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "bank.credit",
     "with": {"…": "…"}}
  ]
}
// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "bank.credit",
     "with": {"…": "…"}}
  ]
}

bank.transferклиент#

перевод игроку (если включено)

Параметры: {"to_user", "currency", "amount", "op_id"}

# перевод игроку (если включено)
send_op("bank", "transfer", {"to_user": 42, "currency": "gold", "amount": 50, "op_id": "gift-1"})

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// перевод игроку (если включено)
const r = await zloy.rt.op('bank', 'transfer', {amount: 50, currency: 'gold', op_id: 'gift-1', to_user: 42});
console.log(r);
// перевод игроку (если включено)
const r = await zloy.rt.op<TransferResult>('bank', 'transfer', {amount: 50, currency: 'gold', op_id: 'gift-1', to_user: 42});

bank.betклиент#

поставить в котёл текущей комнаты

Параметры: {"currency", "amount", "op_id"}

# поставить в котёл текущей комнаты
send_op("bank", "bet", {"currency": "gold", "amount": 100, "op_id": "bet-round-7"})

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// поставить в котёл текущей комнаты
const r = await zloy.rt.op('bank', 'bet', {amount: 100, currency: 'gold', op_id: 'bet-round-7'});
console.log(r);
// поставить в котёл текущей комнаты
const r = await zloy.rt.op<BetResult>('bank', 'bet', {amount: 100, currency: 'gold', op_id: 'bet-round-7'});

bank.potклиент#

сколько в котле комнаты и кто внёс

Параметры: {"room"?}

# сколько в котле комнаты и кто внёс
send_op("bank", "pot")

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// сколько в котле комнаты и кто внёс
const r = await zloy.rt.op('bank', 'pot');
console.log(r);
// сколько в котле комнаты и кто внёс
const r = await zloy.rt.op<PotResult>('bank', 'pot');

bank.payoutтолько связи#

раздать котёл победителям (для связей: session.ended)

Параметры: {"room", "winners": [user_id,…], "op_id"?}

// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "bank.payout",
     "with": {"…": "…"}}
  ]
}
// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "bank.payout",
     "with": {"…": "…"}}
  ]
}
// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "bank.payout",
     "with": {"…": "…"}}
  ]
}

bank.refundтолько связи#

вернуть ставки (партия не состоялась)

Параметры: {"room"}

// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "bank.refund",
     "with": {"…": "…"}}
  ]
}
// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "bank.refund",
     "with": {"…": "…"}}
  ]
}
// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "bank.refund",
     "with": {"…": "…"}}
  ]
}

События

Приходят сами, с seq: 0. Эти же события можно связывать с операциями других кубиков в zloy_server.json.

СобытиеЧто значитДанные
changedбаланс изменился (игроку){"currency", "balance", "delta", "reason"}
potкотёл комнаты изменился{"room", "currency", "total", "players"}
paidкотёл роздан{"room", "winners", "each", "total"}
func _on_event(env: Dictionary) -> void:
    if env.cube == "bank" and env.op == "changed":
        _on_changed(env.payload)
zloy.rt.on('bank', 'changed', (p) => {
  console.log('bank.changed', p);
});

// Все события кубика сразу:
zloy.rt.on('bank', '*', (p, env) => console.log(env.op, p));
zloy.rt.on('bank', 'changed', (p: BankChangedPayload) => {
  update(p);
});

Рекорды board#

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

Хранение: postgres

Настройки

Задаются в студии (вкладка «Бекенд») или в zloy_server.json. Значения вне границ сервер приводит к допустимым — лимит можно ослабить, но не пробить.

СвойствоТипПо умолчаниюЧто делает
periodstring"all" all, day, week, monthпериод таблицы
orderstring"desc" desc, ascлучший результат: desc (больше — лучше) | asc (меньше — лучше)
keep_bestbooltrueхранить только лучший результат игрока (иначе последний)
max_scoreint1000000000 1…1e+09потолок результата (защита от абсурда)
client_submitbooltrueразрешить отправку результата с клиента (иначе только связи — честнее)
top_limitint50 1…500максимум строк в топе

Операции

board.submitклиент и связи#

отправить результат

Параметры: {"board"?: "main", "score", "user_id"? (только сервер), "data"?: {}}

# отправить результат
send_op("board", "submit", {"board": "main", "score": 1200})

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// отправить результат
const r = await zloy.rt.op('board', 'submit', {board: 'main', score: 1200});
console.log(r);
// отправить результат
const r = await zloy.rt.op<SubmitResult>('board', 'submit', {board: 'main', score: 1200});

board.topклиент#

лучшие результаты

Параметры: {"board"?, "limit"?}

# лучшие результаты
send_op("board", "top", {"board": "main", "limit": 10})

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// лучшие результаты
const r = await zloy.rt.op('board', 'top', {board: 'main', limit: 10});
console.log(r);
// лучшие результаты
const r = await zloy.rt.op<TopResult>('board', 'top', {board: 'main', limit: 10});

board.rankклиент#

свой результат и место

Параметры: {"board"?}

# свой результат и место
send_op("board", "rank", {"board": "main"})

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// свой результат и место
const r = await zloy.rt.op('board', 'rank', {board: 'main'});
console.log(r);
// свой результат и место
const r = await zloy.rt.op<RankResult>('board', 'rank', {board: 'main'});

board.resetтолько связи#

очистить таблицу (для связей/таймеров)

Параметры: {"board"?}

// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "board.reset",
     "with": {"…": "…"}}
  ]
}
// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "board.reset",
     "with": {"…": "…"}}
  ]
}
// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "board.reset",
     "with": {"…": "…"}}
  ]
}

События

Приходят сами, с seq: 0. Эти же события можно связывать с операциями других кубиков в zloy_server.json.

СобытиеЧто значитДанные
submittedрезультат принят{"board", "user_id", "score", "rank"}
func _on_event(env: Dictionary) -> void:
    if env.cube == "board" and env.op == "submitted":
        _on_submitted(env.payload)
zloy.rt.on('board', 'submitted', (p) => {
  console.log('board.submitted', p);
});

// Все события кубика сразу:
zloy.rt.on('board', '*', (p, env) => console.log(env.op, p));
zloy.rt.on('board', 'submitted', (p: BoardSubmittedPayload) => {
  update(p);
});

Чат chat#

Каналы с историей и флуд-контролем: канал текущей комнаты («room») и глобальный («global»). Системные объявления — операцией announce (для связей).

Хранение: redis

Настройки

Задаются в студии (вкладка «Бекенд») или в zloy_server.json. Значения вне границ сервер приводит к допустимым — лимит можно ослабить, но не пробить.

СвойствоТипПо умолчаниюЧто делает
historyint50 0…200хвост истории на канал
rate_per_10sint10 1…60сообщений игрока за 10 с
max_lenint300 1…500длина сообщения
global_enabledbooltrueглобальный канал включён

Операции

chat.sendклиент#

отправить сообщение

Параметры: {"channel": "room"|"global", "text"}

# отправить сообщение
send_op("chat", "send", {"channel": "room", "text": "Всем привет!"})

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// отправить сообщение
const r = await zloy.rt.op('chat', 'send', {channel: 'room', text: 'Всем привет!'});
console.log(r);
// отправить сообщение
const r = await zloy.rt.op<SendResult>('chat', 'send', {channel: 'room', text: 'Всем привет!'});

chat.historyклиент#

хвост истории канала

Параметры: {"channel"}

# хвост истории канала
send_op("chat", "history", {"channel": "room"})

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// хвост истории канала
const r = await zloy.rt.op('chat', 'history', {channel: 'room'});
console.log(r);
// хвост истории канала
const r = await zloy.rt.op<HistoryResult>('chat', 'history', {channel: 'room'});

chat.announceтолько связи#

системное сообщение (для связей)

Параметры: {"channel"?, "room"?, "text"}

// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "chat.announce",
     "with": {"…": "…"}}
  ]
}
// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "chat.announce",
     "with": {"…": "…"}}
  ]
}
// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "chat.announce",
     "with": {"…": "…"}}
  ]
}

События

Приходят сами, с seq: 0. Эти же события можно связывать с операциями других кубиков в zloy_server.json.

СобытиеЧто значитДанные
messageновое сообщение подписчикам канала{"channel", "from": {"user_id","name"}|null, "kind": "PLAYER"|"SYSTEM", "text", "ts"}
func _on_event(env: Dictionary) -> void:
    if env.cube == "chat" and env.op == "message":
        _on_message(env.payload)
zloy.rt.on('chat', 'message', (p) => {
  console.log('chat.message', p);
});

// Все события кубика сразу:
zloy.rt.on('chat', '*', (p, env) => console.log(env.op, p));
zloy.rt.on('chat', 'message', (p: ChatMessagePayload) => {
  update(p);
});

Комната room#

Комнаты и лобби: создать/войти/выйти, слоты с данными (команда, роль, аватар), готовность, хост, старт, приватные коды. Присутствие (connected) включено.

Хранение: memory+redis

Настройки

Задаются в студии (вкладка «Бекенд») или в zloy_server.json. Значения вне границ сервер приводит к допустимым — лимит можно ослабить, но не пробить.

СвойствоТипПо умолчаниюЧто делает
capacityint8 2…64мест в комнате
min_startint2 1…64минимум игроков для старта
auto_startboolfalseстарт автоматически, когда все готовы
allow_late_joinboolfalseможно входить после старта
max_roomsint100 1…1000комнат на приложение
persistbooltrueпереживать перезапуск бекенда: комнаты восстанавливаются из хранилища (иначе живут только пока идёт игра)
keep_hoursint24 1…720сколько часов хранить неактивную комнату

Операции

room.listклиент#

список открытых комнат

# список открытых комнат
send_op("room", "list")

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// список открытых комнат
const r = await zloy.rt.op('room', 'list');
console.log(r);
// список открытых комнат
const r = await zloy.rt.op<ListResult>('room', 'list');

room.createклиент#

создать комнату

Параметры: {"name"?, "private"?: bool, "data"?: {}}

# создать комнату
send_op("room", "create", {"name": "Моя комната", "private": false})

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// создать комнату
const r = await zloy.rt.op('room', 'create', {name: 'Моя комната', private: false});
console.log(r);
// создать комнату
const r = await zloy.rt.op<CreateResult>('room', 'create', {name: 'Моя комната', private: false});

room.joinклиент#

войти по id или коду

Параметры: {"room"?: id, "code"?: string}

# войти по id или коду
send_op("room", "join", {"code": "AB12CD"})

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// войти по id или коду
const r = await zloy.rt.op('room', 'join', {code: 'AB12CD'});
console.log(r);
// войти по id или коду
const r = await zloy.rt.op<JoinResult>('room', 'join', {code: 'AB12CD'});

room.quickклиент#

быстрый вход: первая незаполненная или новая

# быстрый вход: первая незаполненная или новая
send_op("room", "quick")

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// быстрый вход: первая незаполненная или новая
const r = await zloy.rt.op('room', 'quick');
console.log(r);
// быстрый вход: первая незаполненная или новая
const r = await zloy.rt.op<QuickResult>('room', 'quick');

room.leaveклиент#

выйти из комнаты

# выйти из комнаты
send_op("room", "leave")

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// выйти из комнаты
const r = await zloy.rt.op('room', 'leave');
console.log(r);
// выйти из комнаты
const r = await zloy.rt.op<LeaveResult>('room', 'leave');

room.readyклиент#

готовность

Параметры: {"v": bool}

# готовность
send_op("room", "ready", {"v": true})

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// готовность
const r = await zloy.rt.op('room', 'ready', {v: true});
console.log(r);
// готовность
const r = await zloy.rt.op<ReadyResult>('room', 'ready', {v: true});

room.setклиент#

данные своего слота (команда/роль/аватар...)

Параметры: {"data": {}}

# данные своего слота (команда/роль/аватар...)
send_op("room", "set", {"data": {"team": "red", "hero": "knight"}})

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// данные своего слота (команда/роль/аватар...)
const r = await zloy.rt.op('room', 'set', {data: {"hero":"knight","team":"red"}});
console.log(r);
// данные своего слота (команда/роль/аватар...)
const r = await zloy.rt.op<SetResult>('room', 'set', {data: {"hero":"knight","team":"red"}});

room.startклиент#

старт (только хост, если не auto_start)

# старт (только хост, если не auto_start)
send_op("room", "start")

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// старт (только хост, если не auto_start)
const r = await zloy.rt.op('room', 'start');
console.log(r);
// старт (только хост, если не auto_start)
const r = await zloy.rt.op<StartResult>('room', 'start');

room.kickклиент#

выгнать игрока (хост)

Параметры: {"user_id"}

# выгнать игрока (хост)
send_op("room", "kick", {"user_id": 42})

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// выгнать игрока (хост)
const r = await zloy.rt.op('room', 'kick', {user_id: 42});
console.log(r);
// выгнать игрока (хост)
const r = await zloy.rt.op<KickResult>('room', 'kick', {user_id: 42});

room.announceтолько связи#

системное сообщение комнате (для связей)

Параметры: {"room"?, "text"}

// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "room.announce",
     "with": {"…": "…"}}
  ]
}
// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "room.announce",
     "with": {"…": "…"}}
  ]
}
// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "room.announce",
     "with": {"…": "…"}}
  ]
}

room.closeтолько связи#

закрыть комнату (для связей/таймеров)

Параметры: {"room"}

// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "room.close",
     "with": {"…": "…"}}
  ]
}
// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "room.close",
     "with": {"…": "…"}}
  ]
}
// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "room.close",
     "with": {"…": "…"}}
  ]
}

События

Приходят сами, с seq: 0. Эти же события можно связывать с операциями других кубиков в zloy_server.json.

СобытиеЧто значитДанные
stateснимок комнаты всем её участникам{"room": {...}}
onJoinигрок вошёл{"room", "user_id", "name"}
onLeaveигрок вышел/отвалился{"room", "user_id", "name", "reason"}
onEmptyкомната опустела{"room"}
onStartматч стартовал{"room", "players": []}
onFullкомната заполнилась{"room"}
func _on_event(env: Dictionary) -> void:
    if env.cube == "room" and env.op == "state":
        _on_state(env.payload)
zloy.rt.on('room', 'state', (p) => {
  console.log('room.state', p);
});

// Все события кубика сразу:
zloy.rt.on('room', '*', (p, env) => console.log(env.op, p));
zloy.rt.on('room', 'state', (p: RoomStatePayload) => {
  update(p);
});

Мир (движок состояний) session#

Универсальный движок: СУЩНОСТИ (игроки, грядки, предметы — что угодно) со свойствами и состояниями, ВРЕМЯ (фазы мира и созревание через N секунд), ДЕЙСТВИЯ игроков с условиями и эффектами, ПРАВИЛА «когда X — сделать Y». Роли, фазы и предметы описываются данными, движок не знает ни одной конкретной игры.

Хранение: postgres+memory

Настройки

Задаются в студии (вкладка «Бекенд») или в zloy_server.json. Значения вне границ сервер приводит к допустимым — лимит можно ослабить, но не пробить.

СвойствоТипПо умолчаниюЧто делает
rulesjsonописание мира: {entities, clock, actions, rules, end, tables}. Пусто — берётся preset, затем world.json проекта
min_playersint0 0…64минимум игроков для старта партии (0 — мир без партий)
seedint0 0…1e+09фиксированный seed (0 = случайный; воспроизводимость)

Операции

session.doклиент#

выполнить действие мира

Параметры: {"action": "имя", "target"?: "id сущности | user_id", "params"?: {}}

# выполнить действие мира
send_op("session", "do", {"action": "plant", "target": "plot-1", "params": {"crop": "wheat"}})

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// выполнить действие мира
const r = await zloy.rt.op('session', 'do', {action: 'plant', params: {"crop":"wheat"}, target: 'plot-1'});
console.log(r);
// выполнить действие мира
const r = await zloy.rt.op<DoResult>('session', 'do', {action: 'plant', params: {"crop":"wheat"}, target: 'plot-1'});

session.viewклиент#

что игрок видит: свои сущности, чужие (публичные поля), фаза

Параметры: {"room"?, "of"?: user_id (чужой мир — только публичное)}

# что игрок видит: свои сущности, чужие (публичные поля), фаза
send_op("session", "view")

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// что игрок видит: свои сущности, чужие (публичные поля), фаза
const r = await zloy.rt.op('session', 'view');
console.log(r);
// что игрок видит: свои сущности, чужие (публичные поля), фаза
const r = await zloy.rt.op<ViewResult>('session', 'view');

session.startтолько связи#

начать партию (связью room.onStart)

Параметры: {"room", "players": [user_id,…]}

// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "session.start",
     "with": {"…": "…"}}
  ]
}
// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "session.start",
     "with": {"…": "…"}}
  ]
}
// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "session.start",
     "with": {"…": "…"}}
  ]
}

session.advanceтолько связи#

закрыть текущую фазу досрочно

Параметры: {"room"?}

// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "session.advance",
     "with": {"…": "…"}}
  ]
}
// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "session.advance",
     "with": {"…": "…"}}
  ]
}
// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "session.advance",
     "with": {"…": "…"}}
  ]
}

session.stopтолько связи#

остановить партию

Параметры: {"room"?}

// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "session.stop",
     "with": {"…": "…"}}
  ]
}
// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "session.stop",
     "with": {"…": "…"}}
  ]
}
// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "session.stop",
     "with": {"…": "…"}}
  ]
}

session.grantтолько связи#

выдать сущность игроку (для связей: награда, стартовый набор)

Параметры: {"user_id", "type", "props"?: {}, "state"?}

// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "session.grant",
     "with": {"…": "…"}}
  ]
}
// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "session.grant",
     "with": {"…": "…"}}
  ]
}
// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "session.grant",
     "with": {"…": "…"}}
  ]
}

События

Приходят сами, с seq: 0. Эти же события можно связывать с операциями других кубиков в zloy_server.json.

СобытиеЧто значитДанные
stateмир изменился (адресно тем, кого касается){"room", "phase", "entities": [...], "you": {...}}
phaseсменилась фаза мира{"room", "phase", "round", "ends_at"}
actedкто-то выполнил действие (публичная часть){"room", "action", "actor", "target"?, "result"?}
privateприватное сообщение игроку (роль, вердикт проверки, кража){"kind", "data"}
endedпартия завершена{"room", "result", "entities": [...]}
resultитог одного игрока — для связи в рекорды (нужен раздел score в мире){"room", "user_id", "score", "place", "result"}
func _on_event(env: Dictionary) -> void:
    if env.cube == "session" and env.op == "state":
        _on_state(env.payload)
zloy.rt.on('session', 'state', (p) => {
  console.log('session.state', p);
});

// Все события кубика сразу:
zloy.rt.on('session', '*', (p, env) => console.log(env.op, p));
zloy.rt.on('session', 'state', (p: SessionStatePayload) => {
  update(p);
});

Хранилище store#

Cloud-save ключ→документ: scope «player» (личные данные игрока) и «world» (общие). Атомарный incr для счётчиков.

Хранение: postgres

Настройки

Задаются в студии (вкладка «Бекенд») или в zloy_server.json. Значения вне границ сервер приводит к допустимым — лимит можно ослабить, но не пробить.

СвойствоТипПо умолчаниюЧто делает
max_value_bytesint16384 1…65536размер документа
max_keys_playerint64 1…1024ключей на игрока
max_keys_worldint256 1…4096ключей мира
world_writablebooltrueклиенты могут писать в world
searchablebooltrueразрешить поиск по сохранённым документам (op query): фильтры по полям, сортировка, страницы
search_limitint50 1…500максимум документов в ответе поиска

Операции

store.getклиент#

прочитать ключ

Параметры: {"scope": "player"|"world", "key"}

# прочитать ключ
send_op("store", "get", {"scope": "player", "key": "save"})

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// прочитать ключ
const r = await zloy.rt.op('store', 'get', {key: 'save', scope: 'player'});
console.log(r);
// прочитать ключ
const r = await zloy.rt.op<GetResult>('store', 'get', {key: 'save', scope: 'player'});

store.setклиент и связи#

записать документ

Параметры: {"scope", "key", "value"}

# записать документ
send_op("store", "set", {"scope": "player", "key": "save", "value": {"level": 3, "hp": 100}})

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// записать документ
const r = await zloy.rt.op('store', 'set', {key: 'save', scope: 'player', value: {"hp":100,"level":3}});
console.log(r);
// записать документ
const r = await zloy.rt.op<SetResult>('store', 'set', {key: 'save', scope: 'player', value: {"hp":100,"level":3}});

store.incrклиент и связи#

атомарно прибавить к числу

Параметры: {"scope", "key", "by": 1}

# атомарно прибавить к числу
send_op("store", "incr", {"scope": "world", "key": "games_played", "by": 1})

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// атомарно прибавить к числу
const r = await zloy.rt.op('store', 'incr', {by: 1, key: 'games_played', scope: 'world'});
console.log(r);
// атомарно прибавить к числу
const r = await zloy.rt.op<IncrResult>('store', 'incr', {by: 1, key: 'games_played', scope: 'world'});

store.listклиент#

ключи по префиксу

Параметры: {"scope", "prefix"?}

# ключи по префиксу
send_op("store", "list", {"scope": "player", "prefix": "save"})

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// ключи по префиксу
const r = await zloy.rt.op('store', 'list', {prefix: 'save', scope: 'player'});
console.log(r);
// ключи по префиксу
const r = await zloy.rt.op<ListResult>('store', 'list', {prefix: 'save', scope: 'player'});

store.delклиент и связи#

удалить ключ

Параметры: {"scope", "key"}

# удалить ключ
send_op("store", "del", {"scope": "player", "key": "save"})

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// удалить ключ
const r = await zloy.rt.op('store', 'del', {key: 'save', scope: 'player'});
console.log(r);
// удалить ключ
const r = await zloy.rt.op<DelResult>('store', 'del', {key: 'save', scope: 'player'});

store.queryклиент#

поиск по сохранённым документам

Параметры: {"scope", "prefix"?, "where"?: {"поле": значение | {"gt"|"gte"|"lt"|"lte"|"contains": …}}, "sort"?: "поле"|"-поле", "limit"?, "offset"?}

# поиск по сохранённым документам
send_op("store", "query", {"scope": "world", "prefix": "hero:", "where": {"level": {"gte": 5}}, "sort": "-level", "limit": 10})

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// поиск по сохранённым документам
const r = await zloy.rt.op('store', 'query', {limit: 10, prefix: 'hero:', scope: 'world', sort: '-level', where: {"level":{"gte":5}}});
console.log(r);
// поиск по сохранённым документам
const r = await zloy.rt.op<QueryResult>('store', 'query', {limit: 10, prefix: 'hero:', scope: 'world', sort: '-level', where: {"level":{"gte":5}}});

События

Приходят сами, с seq: 0. Эти же события можно связывать с операциями других кубиков в zloy_server.json.

СобытиеЧто значитДанные
changedworld-ключ изменился (всем в приложении){"key", "value"}
func _on_event(env: Dictionary) -> void:
    if env.cube == "store" and env.op == "changed":
        _on_changed(env.payload)
zloy.rt.on('store', 'changed', (p) => {
  console.log('store.changed', p);
});

// Все события кубика сразу:
zloy.rt.on('store', '*', (p, env) => console.log(env.op, p));
zloy.rt.on('store', 'changed', (p: StoreChangedPayload) => {
  update(p);
});

Синхронизация sync#

Состояние игроков с тикрейтом: клиент шлёт своё (позиция, анимация), сервер рассылает общий снимок всем в комнате 5–30 раз в секунду.

Хранение: memory

Настройки

Задаются в студии (вкладка «Бекенд») или в zloy_server.json. Значения вне границ сервер приводит к допустимым — лимит можно ослабить, но не пробить.

СвойствоТипПо умолчаниюЧто делает
tick_hzint10 1…30снимков в секунду
max_state_bytesint512 32…4096размер состояния игрока
drop_after_secint30 5…300забыть игрока без обновлений через N с
send_emptyboolfalseслать снимок, даже если ничего не менялось

Операции

sync.setклиент#

своё состояние (заменяет предыдущее)

Параметры: {"state": {…}}

# своё состояние (заменяет предыдущее)
send_op("sync", "set", {"state": {"x": 10.5, "y": 3.2, "anim": "run"}})

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// своё состояние (заменяет предыдущее)
const r = await zloy.rt.op('sync', 'set', {state: {"anim":"run","x":10.5,"y":3.2}});
console.log(r);
// своё состояние (заменяет предыдущее)
const r = await zloy.rt.op<SetResult>('sync', 'set', {state: {"anim":"run","x":10.5,"y":3.2}});

sync.patchклиент#

обновить часть полей состояния

Параметры: {"state": {…}}

# обновить часть полей состояния
send_op("sync", "patch", {"state": {"x": 11.0}})

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// обновить часть полей состояния
const r = await zloy.rt.op('sync', 'patch', {state: {"x":11}});
console.log(r);
// обновить часть полей состояния
const r = await zloy.rt.op<PatchResult>('sync', 'patch', {state: {"x":11}});

sync.clearклиент#

убрать себя из синхронизации

# убрать себя из синхронизации
send_op("sync", "clear")

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// убрать себя из синхронизации
const r = await zloy.rt.op('sync', 'clear');
console.log(r);
// убрать себя из синхронизации
const r = await zloy.rt.op<ClearResult>('sync', 'clear');

События

Приходят сами, с seq: 0. Эти же события можно связывать с операциями других кубиков в zloy_server.json.

СобытиеЧто значитДанные
snapshotснимок состояний всех игроков комнаты{"room", "ts", "players": {"<user_id>": {…}}}
func _on_event(env: Dictionary) -> void:
    if env.cube == "sync" and env.op == "snapshot":
        _on_snapshot(env.payload)
zloy.rt.on('sync', 'snapshot', (p) => {
  console.log('sync.snapshot', p);
});

// Все события кубика сразу:
zloy.rt.on('sync', '*', (p, env) => console.log(env.op, p));
zloy.rt.on('sync', 'snapshot', (p: SyncSnapshotPayload) => {
  update(p);
});

Таймер timer#

Серверные таймеры: ставятся связями (after/at/cancel), срабатывание — событие fired для следующих связей. Авторитарные дедлайны фаз без кода.

Хранение: redis

Настройки

Задаются в студии (вкладка «Бекенд») или в zloy_server.json. Значения вне границ сервер приводит к допустимым — лимит можно ослабить, но не пробить.

СвойствоТипПо умолчаниюЧто делает
max_activeint100 1…1000активных таймеров на приложение
min_secint1 1…3600минимальная задержка, сек

Операции

timer.afterтолько связи#

сработать через sec секунд

Параметры: {"sec", "tag", "data"?: {}}

// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "timer.after",
     "with": {"…": "…"}}
  ]
}
// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "timer.after",
     "with": {"…": "…"}}
  ]
}
// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "timer.after",
     "with": {"…": "…"}}
  ]
}

timer.atтолько связи#

сработать в unix-время

Параметры: {"ts", "tag", "data"?: {}}

// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "timer.at",
     "with": {"…": "…"}}
  ]
}
// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "timer.at",
     "with": {"…": "…"}}
  ]
}
// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "timer.at",
     "with": {"…": "…"}}
  ]
}

timer.cancelтолько связи#

снять таймеры с тегом

Параметры: {"tag"}

// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "timer.cancel",
     "with": {"…": "…"}}
  ]
}
// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "timer.cancel",
     "with": {"…": "…"}}
  ]
}
// Вызывается связью в zloy_server.json, не из игры:
{
  "links": [
    {"from": "<кубик>.<событие>", "to": "timer.cancel",
     "with": {"…": "…"}}
  ]
}

timer.deadlineклиент#

когда сработает тег (ends_at для UI)

Параметры: {"tag"}

# когда сработает тег (ends_at для UI)
send_op("timer", "deadline", {"tag": "night_end"})

# ответ придёт конвертом с тем же seq — ловите его в _on_event
// когда сработает тег (ends_at для UI)
const r = await zloy.rt.op('timer', 'deadline', {tag: 'night_end'});
console.log(r);
// когда сработает тег (ends_at для UI)
const r = await zloy.rt.op<DeadlineResult>('timer', 'deadline', {tag: 'night_end'});

События

Приходят сами, с seq: 0. Эти же события можно связывать с операциями других кубиков в zloy_server.json.

СобытиеЧто значитДанные
firedтаймер сработал{"tag", "data", "ts"}
func _on_event(env: Dictionary) -> void:
    if env.cube == "timer" and env.op == "fired":
        _on_fired(env.payload)
zloy.rt.on('timer', 'fired', (p) => {
  console.log('timer.fired', p);
});

// Все события кубика сразу:
zloy.rt.on('timer', '*', (p, env) => console.log(env.op, p));
zloy.rt.on('timer', 'fired', (p: TimerFiredPayload) => {
  update(p);
});

Рецепт: лобби и чат#

Собрать игроков, дождаться готовности, начать матч.

Комнаты и чат — два кубика; «объявления о входе» делает связь, а не ваш код. В студии этот граф собирается мышью, здесь показано, что получится в манифесте и как это выглядит из игры.

Порядок такой: quick (или create/join) → игрок отмечает готовность ready → хост вызывает start, либо комната стартует сама (свойство auto_start). Всё это время всем участникам приходит событие state с составом.

# 1. быстрый вход: найдёт незаполненную комнату или создаст новую
send_op("room", "quick")

# 2. состав комнаты приходит событием
func _on_event(env):
    if env.cube == "room" and env.op == "state":
        _render_players(env.payload.room.players)

# 3. готовность и старт
send_op("room", "ready", {"v": true})
send_op("room", "start")          # только хост

# 4. чат комнаты
send_op("chat", "send", {"channel": "room", "text": "Погнали!"})
await zloy.rt.op('room', 'quick');

zloy.rt.on('room', 'state', (p) => renderPlayers(p.room.players));
zloy.rt.on('chat', 'message', (m) => appendMessage(m));

await zloy.rt.op('room', 'ready', {v: true});
await zloy.rt.op('room', 'start');           // только хост

await zloy.rt.op('chat', 'send', {channel: 'room', text: 'Погнали!'});
interface Slot { user_id: number; name: string; ready: boolean;
  connected: boolean; data?: Record<string, unknown> }
interface RoomState { id: string; host_id: number; started: boolean;
  capacity: number; players: Slot[] }

await zloy.rt.op('room', 'quick');
zloy.rt.on('room', 'state', (p: {room: RoomState}) => renderPlayers(p.room.players));

Рецепт: сохранения и рекорды#

Прогресс игрока и таблица лидеров.

Личный прогресс — store со скоупом player: чужие данные не прочитать, свои переживают любые перезапуски. Общие счётчики — скоуп world и атомарный incr.

Рекорды — отдельный кубик board с периодами (навсегда, день, неделя, месяц). Если не хотите доверять счёт клиенту, выключите client_submit — тогда результат отправит связь после окончания матча.

# сохранить прогресс
send_op("store", "set", {"scope": "player", "key": "save",
    "value": {"level": 7, "coins": 120}})

# загрузить при старте
send_op("store", "get", {"scope": "player", "key": "save"})

# общий счётчик сыгранных матчей
send_op("store", "incr", {"scope": "world", "key": "games_played", "by": 1})

# рекорд и таблица
send_op("board", "submit", {"score": 4500})
send_op("board", "top", {"limit": 10})
await zloy.rt.op('store', 'set',
  {scope: 'player', key: 'save', value: {level: 7, coins: 120}});

const {value} = await zloy.rt.op('store', 'get',
  {scope: 'player', key: 'save'});

await zloy.rt.op('board', 'submit', {score: 4500});
const {rows} = await zloy.rt.op('board', 'top', {limit: 10});
interface Save { level: number; coins: number }
interface TopRow { rank: number; user_id: number; name: string; score: number }

await zloy.rt.op('store', 'set',
  {scope: 'player', key: 'save', value: {level: 7, coins: 120} as Save});

const {rows} = await zloy.rt.op<{rows: TopRow[]}>('board', 'top', {limit: 10});

Рецепт: платные партии#

Игроки скидываются, победитель забирает котёл.

Валюта здесь внутриигровая: банк не знает про реальные деньги (их подключают в настройках игры отдельно). Включите свойство allow_bets, и игроки смогут ставить в котёл своей комнаты.

Раздачу выигрыша делает связь: session.ended → bank.payout. Котёл делится между победителями поровну, остаток от деления достаётся первому — деньги не исчезают. Если партия не состоялась, bank.refund вернёт ставки.

# ставка (op_id обязателен — защищает от двойного списания)
send_op("bank", "bet", {"currency": "gold", "amount": 100,
    "op_id": "bet-%d" % match_id})

# посмотреть котёл
send_op("bank", "pot")

# баланс и выписка
send_op("bank", "balance")
send_op("bank", "statement", {"limit": 20})
await zloy.rt.op('bank', 'bet',
  {currency: 'gold', amount: 100, op_id: `bet-${matchId}`});

const pot = await zloy.rt.op('bank', 'pot');
console.log('в котле', pot.total);

zloy.rt.on('bank', 'changed', (p) =>
  showBalance(p.currency, p.balance, p.delta));
interface Pot { room: string; currency: string; total: number;
  players: Record<string, number> }

await zloy.rt.op('bank', 'bet',
  {currency: 'gold', amount: 100, op_id: `bet-${matchId}`});
const pot = await zloy.rt.op<Pot>('bank', 'pot');

Рецепт: игровая логика без кода#

Кубик session: сущности, время, действия, правила.

Пошаговые игры и живущие миры описываются данными: сущности (игрок, грядка, предмет) со свойствами и состояниями, время (фазы мира и созревание «через N секунд»), действия игроков с условиями и эффектами, правила «когда X — сделать Y».

Роль — это обычное свойство сущности-игрока, а «способность роли» — действие, доступное по значению свойства. Поэтому одна и та же машина крутит и социальную дедукцию, и ферму, где урожай зреет часами, а сосед ворует его ночью.

Готовые миры включаются свойством preset (social_deduction, farm), свои собираются в редакторе мира во вкладке «Бекенд». Из игры вам нужны всего две операции: view — что я вижу, и do — что я делаю.

# что вокруг меня: свои сущности целиком, чужие — публичные поля
send_op("session", "view")

# действие мира: посадить пшеницу на грядку
send_op("session", "do", {"action": "plant", "target": "plot-1",
    "params": {"crop": "wheat"}})

# ночью можно украсть у соседа
send_op("session", "do", {"action": "steal", "target": "plot-9"})

# события мира
func _on_event(env):
    if env.cube != "session": return
    match env.op:
        "state":   _render(env.payload.mine, env.payload.others)
        "phase":   _set_phase(env.payload.phase, env.payload.ends_at)
        "private": _show_secret(env.payload.kind, env.payload.data)
const view = await zloy.rt.op('session', 'view');
render(view.mine, view.others, view.phase);

await zloy.rt.op('session', 'do',
  {action: 'plant', target: 'plot-1', params: {crop: 'wheat'}});

zloy.rt.on('session', 'phase', (p) => setPhase(p.phase, p.ends_at));
zloy.rt.on('session', 'private', (p) => showSecret(p.kind, p.data));
zloy.rt.on('session', 'ended', (p) => showResult(p.result));
interface Entity { id: string; type: string; owner: number;
  state: string; ready_at?: number; props: Record<string, unknown> }
interface WorldView { room: string; phase: string; round: number;
  ends_at: number; mine: Entity[]; others: Entity[] }

const view = await zloy.rt.op<WorldView>('session', 'view');
await zloy.rt.op('session', 'do',
  {action: 'plant', target: view.mine[0].id, params: {crop: 'wheat'}});

Рецепт: арена — холл, матч, рекорды#

Полная схема сессионного шутера: общий холл → комната на 8 → бой 15 минут → таблица рекордов → снова холл.

Разберём целиком: холл, где сидят все игроки с общим чатом; игрок создаёт комнату на 8 со своим чатом; как только собрались восемь — начинается матч (полёты, стрельба, респавн, счёт фрагов); через 15 минут матч заканчивается, результат уходит в рекорды, игроки возвращаются в холл.

Кубиков нужно пять: hall и lobby (оба — room, просто с разными настройками), chat, sync, match (это session), timer и board. Справа — готовый zloy_server.json.

Холл — комната с большой вместимостью и без старта: игрок попадает туда сразу после подключения (hall.quick), болтает в общем чате и видит список открытых комнат (lobby.list).

Комната на 8 — второй инстанс того же кубика с capacity: 8, min_start: 8 и auto_start: true: как только восьмой игрок отметился готовым, комната стартует сама. Чат у неё отдельный, потому что канал room привязан к текущей комнате игрока — войдя в лобби, он пишет уже туда, а не в холл.

{
  "version": 1,
  "cubes": {
    "hall":  {"cube": "room",
              "props": {"capacity": 64, "min_start": 64}},
    "lobby": {"cube": "room",
              "props": {"capacity": 8, "min_start": 8,
                        "auto_start": true}},
    "chat":  {"cube": "chat", "props": {"rate_per_10s": 5}},
    "sync":  {"cube": "sync", "props": {"tick_hz": 15,
                                        "max_state_bytes": 256}},
    "match": {"cube": "session", "props": {"min_players": 8}},
    "timer": {"cube": "timer"},
    "board": {"cube": "board",
              "props": {"period": "week", "client_submit": false}}
  },
  "links": [
    {"from": "lobby.onJoin",  "to": "chat.announce",
     "with": {"channel": "room", "room": "{room}",
              "text": "{name} присоединился ({players}/8)"}},

    {"from": "lobby.onStart", "to": "match.start"},
    {"from": "lobby.onStart", "to": "timer.after",
     "with": {"sec": 900, "tag": "match_end",
              "data": {"room": "{room}"}}},
    {"from": "lobby.onStart", "to": "chat.announce",
     "with": {"channel": "room", "room": "{room}",
              "text": "Бой начался! 15 минут."}},

    {"from": "timer.fired",   "to": "match.advance"},

    {"from": "match.result",  "to": "board.submit",
     "with": {"board": "kills"}},
    {"from": "match.ended",   "to": "chat.announce",
     "with": {"channel": "room", "room": "{room}",
              "text": "Бой окончен."}},
    {"from": "match.ended",   "to": "lobby.close",
     "with": {"room": "{room}"}}
  ]
}
// Холл при запуске игры
await zloy.rt.op('hall', 'quick');
zloy.rt.on('chat', 'message', renderChat);

// Список открытых комнат и создание своей
const {rooms} = await zloy.rt.op('lobby', 'list');
await zloy.rt.op('lobby', 'create', {name: 'Ковры 8x8'});
await zloy.rt.op('lobby', 'ready', {v: true});   // 8-й запустит матч сам

// В бою: своё состояние — 15 раз в секунду
setInterval(() => {
  zloy.rt.op('sync', 'set', {state: {x, y, z, yaw, hp, anim}});
}, 66);
zloy.rt.on('sync', 'snapshot', (p) => renderOthers(p.players));

// Попадание засчитывает мир (действие с проверками на сервере)
await zloy.rt.op('match', 'do', {action: 'hit', target: String(victimId)});
zloy.rt.on('session', 'acted', (e) => showKillFeed(e));

// Конец матча
zloy.rt.on('match', 'ended', async (p) => {
  showScoreboard(p.entities);
  await zloy.rt.op('hall', 'quick');            // возвращаемся в холл
});
interface FighterProps { frags: number; deaths: number; hp: number }
interface Fighter {
  id: string; type: 'fighter'; owner: number;
  state: 'alive' | 'dead'; props: FighterProps;
}

// Состояние полёта — маленький объект, 15 раз в секунду
interface FlyState { x: number; y: number; z: number; yaw: number; hp: number }

const tick = () => zloy.rt.op('sync', 'set', {state: flyState as FlyState});
flowchart LR
  H["hall · room<br>общий холл"]:::cube
  C["chat<br>канал room"]:::cube
  L["lobby · room<br>8 мест, авто-старт"]:::accent
  S["sync<br>полёты, 15 Гц"]:::cube
  M["match · session<br>фраги, смерть, респавн"]:::accent
  T["timer<br>900 секунд"]:::cube
  B["board<br>таблица рекордов"]:::cube
  H -->|"игрок создаёт комнату"| L
  L -->|"onJoin/onStart → announce"| C
  L -->|"onStart → start"| M
  L -->|"onStart → after"| T
  T -->|"fired → advance"| M
  M -->|"do hit, ended"| S
  M -->|"result → submit"| B
  M -->|"ended → close"| L
  L -->|"игроки возвращаются"| H

  classDef accent fill:#1a1114,stroke:#ff3b30,stroke-width:1.5px,color:#f6e9e8
  classDef cube fill:#14161c,stroke:#3a3f47,color:#e8eae6
  classDef data fill:#101218,stroke:#2f3540,color:#aab0b8
Круг арены: холл → комната на 8 → бой → рекорды → снова холл. Красным — то, что платформа делает сама по связям.

Сам бой. Здесь важно честно разделить, что делает платформа, а что ваша игра:

ЧтоКто
Полёт, шары, попадания, физикаклиент — рисует и считает столкновения
Разослать позиции всем 8 игрокамsync, тикрейт 10–30 Гц
Засчитать фраг, смерть, респавнsession — действие hit с правилами
Счёт и таблица матчаsession: свойства сущности игрока
Таймер матчаtimer + связь
Итог в рекордыboard (связь, не клиент)

Платформа не считает попадания сама: авторитарной физики полёта у неё нет, это была бы уже своя симуляция. Клиент сообщает «я попал в такого-то», а сервер проверяет декларативно: жива ли цель, не сам ли это стрелок, не чаще ли разрешённого (cooldown_sec), идёт ли фаза боя — и только тогда засчитывает. Для казуального шутера этого достаточно; полный server-authoritative — отдельная история.

Респавн — правило: погиб → через 3 секунды снова жив. Время считает движок (созревание сущности), клиент просто показывает отсчёт.

// Мир матча (редактор мира во вкладке «Бекенд»)
{
  "entities": {
    "fighter": {
      "per_user": true,
      "props": {"frags": 0, "deaths": 0},
      "states": ["alive", "dead"],
      "initial_state": "alive",
      "public": ["frags", "deaths"]
    }
  },
  "clock": {"phases": [{"name": "battle", "seconds": 900,
                        "next": "over"},
                       {"name": "over", "seconds": 15,
                        "next": "battle"}]},
  "actions": {
    "hit": {
      "requires": {"state": "alive"},
      "target": {"type": "fighter", "own": false,
                 "state": "alive"},
      "phases": ["battle"],
      "cooldown_sec": 1,
      "public": true,
      "effects": [
        {"op": "state", "to": "dead", "then": "alive", "after": 3},
        {"op": "add", "on": "actor",  "props": {"frags": 1}},
        {"op": "add", "props": {"deaths": 1}},
        {"op": "notify", "event": "killed",
         "data": {"by": "$actor.owner"}}
      ]
    }
  },
  "rules": [
    {"when": "phase_enter", "phase": "over",
     "then": [{"op": "end", "result": "match_over"}]}
  ],
  "score": {"entity": "fighter", "prop": "frags"}
}
// Клиент считает попадание и сообщает серверу
function onProjectileHit(victimId) {
  zloy.rt.op('match', 'do', {action: 'hit', target: String(victimId)})
    .catch((e) => {
      // сервер не засчитал: цель мертва, кулдаун, не та фаза
      if (e.code !== 'bad_target') console.warn(e.code);
    });
}

// Смерть и респавн приходят состоянием сущности
zloy.rt.on('match', 'state', (v) => {
  const me = v.mine.find((e) => e.type === 'fighter');
  if (me.state === 'dead') showRespawnTimer(me.ready_at);
  updateScore(v.mine.concat(v.others));
});
interface WorldView {
  phase: string; ends_at: number;
  mine: Fighter[]; others: Fighter[];
}

zloy.rt.on('match', 'state', (v: WorldView) => {
  const me = v.mine[0];
  if (me.state === 'dead' && me.ready_at) {
    showRespawnIn((me.ready_at - Date.now()) / 1000);
  }
});

Итог и возвращение. Когда таймер добивает 15 минут, timer.fired → match.advance закрывает фазу боя, правило завершает партию, а связь match.ended → board.submit пишет результат в таблицу рекордов. Клиенту таблица недоступна на запись (client_submit: false) — счёт отправляет только сервер.

После этого lobby.close закрывает комнату, игроки получают событие closed и зовут hall.quick — снова в общий холл, к общему чату. Круг замкнулся.

Чего в этой схеме нет и не будет из коробки: серверной физики полёта, предсказания движения и компенсации лага. Позиции идут как есть, сглаживание — на клиенте. Для «ковров-самолётов» с шарами этого хватает; для соревновательного шутера понадобится своя симуляция.

# Итоговая таблица приходит в событии ended
func _on_event(env):
    if env.cube == "match" and env.op == "ended":
        var rows := []
        for e in env.payload.entities:
            if e.type == "fighter":
                rows.append({
                    "user": e.owner,
                    "frags": e.props.frags,
                    "deaths": e.props.deaths,
                })
        rows.sort_custom(func(a, b): return a.frags > b.frags)
        _show_scoreboard(rows)
        send_op("hall", "quick")   # обратно в холл
zloy.rt.on('match', 'ended', async (p) => {
  const table = p.entities
    .filter((e) => e.type === 'fighter')
    .sort((a, b) => b.props.frags - a.props.frags);
  showScoreboard(table);

  const top = await zloy.rt.op('board', 'top', {limit: 10});
  showLeaderboard(top.rows);

  await zloy.rt.op('hall', 'quick');   // назад в общий холл
});
interface EndedPayload {
  room: string; result: string; entities: Fighter[];
}

zloy.rt.on('match', 'ended', async (p: EndedPayload) => {
  const table = p.entities
    .filter((e) => e.type === 'fighter')
    .sort((a, b) => b.props.frags - a.props.frags);
  showScoreboard(table);
  await zloy.rt.op('hall', 'quick');
});

Рецепт: реконнект#

Мобильные браузеры рвут сокеты постоянно — это нормальный режим работы.

SDK переподключается сам с нарастающей паузой. Ваша задача — пересинхронизировать состояние после восстановления: спросить состав комнаты, историю чата, состояние мира.

Комнату игрок не теряет: пока идёт партия, слот сохраняется, и вход в ту же комнату вернёт на место. Сообщения, отправленные в момент обрыва, не теряются — SDK держит их в очереди до восстановления связи.

# Godot: WebSocketPeer сообщает о закрытии — переподключаемся сами
func _process(_dt):
    ws.poll()
    if ws.get_ready_state() == WebSocketPeer.STATE_CLOSED and not reconnecting:
        reconnecting = true
        await get_tree().create_timer(1.0).timeout
        _connect_and_resync()

func _connect_and_resync():
    ws.connect_to_url(url)
    reconnecting = false
    # после hello — обновляем состояние
    send_op("room", "list")
    send_op("chat", "history", {"channel": "room"})
    send_op("session", "view")
zloy.rt.onReconnect = async () => {
  // связь восстановлена — забираем актуальное состояние
  const [history, view] = await Promise.all([
    zloy.rt.op('chat', 'history', {channel: 'room'}),
    zloy.rt.op('session', 'view'),
  ]);
  restoreChat(history.messages);
  render(view);
};
zloy.rt.onReconnect = async (hello: HelloPayload): Promise<void> => {
  const view = await zloy.rt.op<WorldView>('session', 'view');
  render(view);
};