Мультиплеер, чат, сохранения, экономика и игровая логика — без своего сервера. Кубики собирают на холсте в студии, игра подключается к ним одним WebSocket. Серверный код писать не нужно и нельзя — вы описываете нужное настройками и связями.
Кубики не подключают библиотекой и не разворачивают на сервере — их ставят на холст.
Откройте проект в приложении Zloy Studio и нажмите значок ⬡ «Zloy Backend» в верхней панели — он есть и на экране проекта, и в редакторе кода. Откроется холст: узлы — это кубики, стрелки — связи между ними, справа — инспектор выбранного узла.
Всё, что вы соберёте на холсте, платформа хранит в одном файле —
zloy_server.json в корне проекта. Он лежит в том же git-репозитории,
что и игра, и каждое «Сохранить» — это коммит. Поэтому у сборки бекенда
три равноправных входа, и все три правят один и тот же файл:
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
Блок ui в файле — только координаты узлов на холсте,
рантайм его не читает. Правка руками ничего не сломает: узел без координат
редактор просто разложит сам.
Файла нет? Тогда включены все кубики с настройками по умолчанию,
а имя инстанса совпадает с именем кубика. Игра может подключиться и
позвать chat.send, не открывая редактор ни разу — настройки
нужны, когда умолчания перестали устраивать.
Тип берут из каталога, инстанс называют — под этим именем игра к нему и обращается.
Слева вверху холста — чип «+ Кубик». В списке живой каталог
сервера (тот же, что отдаёт GET /rt/cubes), поэтому новые кубики
платформы появляются в меню без обновления приложения. Выбрали тип — на
холсте появился узел.
Тип и инстанс — разные вещи. room — тип, «комната
вообще». hall и lobby — два инстанса этого типа с
разными настройками: холл на 64 места без старта и боевая комната на
восемь. Ставьте столько инстансов, сколько нужно игре: у них раздельные
данные, события и связи.
Имя инстанса — это адрес. Первый узел называется как тип
(room), второй — room2; переименуйте карандашом
в инспекторе, пока имя не разошлось с ролью. Именно это имя игра указывает
первым аргументом при вызове, и оно же стоит слева от точки в связях —
при переименовании редактор чинит связи сам.
Настройки инспектор строит из описания кубика, поэтому поле знает свой вид: переключатель для флага, число с границами, закрытый список значений, отдельные редакторы для валют банка и мира партии. Границы — не совет: значение вне них сервер приведёт к допустимому.
{
"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});Связь — «событие одного кубика запускает операцию другого». Рисуется за три клика.
На узле-источнике нажмите ⇢ («Связать с другим кубиком»). В шапке появится «Выберите узел-приёмник…» — ткните во второй узел. Откроется диалог из трёх полей:
onJoin,
onStart, fired, ended…announce, after, submit,
payout. Клиентских здесь нет намеренно — иначе игрок
начислял бы себе награды сам.{поле} подставляет значение из события.Если соединять нечего (у источника нет событий или у приёмника нет серверных операций), редактор скажет об этом сразу, а не нарисует мёртвую стрелку.
Что подставляется. У room.onJoin в событии есть
{room}, {user_id}, {name}; у
onLeave добавляется {reason}. Поле
room чату передавать обязательно — иначе он не поймёт, в какую
комнату писать. Полный состав события — в справочнике каждого кубика.
{
"channel": "room",
"room": "{room}",
"text": "{name} вошёл в комнату"
}flowchart LR
E["Событие<br>lobby.onJoin<br>room, user_id, name"]:::accent
W["Поля связи<br>channel: room<br>room: {room}<br>text: {name} вошёл"]:::data
O["Серверная операция<br>chat.announce"]:::accent
M["Событие chat.message<br>всем в комнате"]:::cube
E --> W --> O --> M
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Игре для такого объявления не нужно ни строчки кода: она только слушает
чат и рисует ленту. Системные сообщения приходят с kind: "SYSTEM"
и from: null.
func _on_event(env: Dictionary) -> void:
if env.cube == "chat" and env.op == "message":
var m: Dictionary = env.payload
if m.kind == "SYSTEM":
chat_log.add_system(m.text)
else:
chat_log.add_line(m.from.name, m.text)zloy.rt.on('chat', 'message', (m) => {
if (m.kind === 'SYSTEM') addSystemLine(m.text);
else addPlayerLine(m.from.name, m.text);
});
// Лента после входа второго игрока:
// SYSTEM Алиса вошла в комнату
// SYSTEM Все в сборе — начинаем!
// PLAYER Алиса: погналиinterface ChatMessage {
channel: string;
room?: string;
from: {user_id: number; name: string} | null; // null у системных
kind: 'PLAYER' | 'SYSTEM';
text: string;
ts: number;
}
zloy.rt.on('chat', 'message', (m: ChatMessage) => render(m));sequenceDiagram autonumber participant P as Игрок participant R as Кубик lobby participant L as Связи zloy_server.json participant C as Кубик chat participant A as Все в комнате P->>R: op lobby.quick R-->>P: ok room, slot R->>L: событие onJoin room, user_id, name L->>C: announce channel, room, text C->>A: событие chat.message kind SYSTEM
Цепочки. Операция, вызванная связью, сама может породить событие — и запустить следующую связь. Так собирается вся механика матча: комната стартовала → началась партия и пошёл таймер → таймер выстрелил → партия закрыла фазу → партия закончилась → результат уехал в рекорды. Глубина цепочки ограничена восемью шагами: кольцо на холсте нарисовать легко, крутиться вечно оно не должно.
Порядок. Каждый кубик обрабатывает свою очередь по одному сообщению, поэтому внутри одного приёмника порядок сохраняется. Между разными приёмниками порядок не гарантирован — не стройте логику на том, что «чат успеет раньше банка».
Одно событие — сколько угодно связей. Тот же onStart
может одновременно писать в чат, запускать партию и заводить таймер: это
три отдельные стрелки, а не одна с тремя действиями.
Справа — набор связей, который и превращает набор кубиков в игру.
Обратите внимание на timer.fired: событие таймера ничем не
отличается от события комнаты, и связывается точно так же.
[
{"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"}},
{"from": "match.ended", "to": "lobby.close",
"with": {"room": "{room}"}},
{"from": "lobby.onEmpty", "to": "timer.cancel",
"with": {"tag": "match_end"}},
{"from": "lobby.onEmpty", "to": "bank.refund",
"with": {"room": "{room}"}}
]У 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Черновик применяется сам за пятнадцать секунд, прод — только через публикацию.
Кнопка «Сохранить» оживает, как только вы что-то изменили; рядом
горит «есть несохранённые изменения», а попытка выйти с холста переспросит.
Сохранение пишет 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
Восемь кликов от пустого холста до работающего матча на пятнадцать минут.
Соберём типовой каркас сетевой игры. Ничего, кроме описанного выше, здесь не используется.
lobby. Свойства:
capacity 8, min_start 8,
auto_start включить.chat.timer),
+ Кубик → Партия (match — это тип
session), + Кубик → Рекорды (board,
свойство client_submit выключить: результат пишет
сервер, не игрок).onJoin, операция
announce, поля — как справа.onStart →
announce, «Матч начался».onStart →
start, поля пустые.onStart →
after, {"sec": 900, "tag": "match_end",
"room": "{room}"}. И ⇢ с timer на match:
fired → advance.result →
операция submit, поля {"board": "kills"}.
Событие result приходит по одному на игрока — с его
счётом и местом; чтобы движок знал, что считать счётом, в редакторе
мира на вкладке «Конец» укажите свойство-счёт.
Сохранить.Пятнадцать минут отсчитывает сервер, а не устройство игрока: закрытая вкладка, спящий телефон и подкрученные часы на матч не влияют.
{
"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. Коды конкретных
операций описаны ниже у каждого кубика.
Лимиты соединения, которые стоит держать в голове:
Остальные лимиты — свойства кубиков (вместимость комнаты, длина сообщения, размер сохранения): их видно и меняется во вкладке «Бекенд» студии.
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 в корне проекта описывает бекенд игры:
какие кубики включены, с какими настройками и как связаны. Его собирает
визуальный редактор во вкладке «Бекенд» студии — или ИИ-агент по
вашей просьбе. Писать руками не обязательно.
Связь — это «событие одного кубика запускает операцию другого».
Роутинг исполняет наш код, вы описываете только граф. Значения в
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});Разбор на самом частом примере — как события лобби превращаются в сообщения чата.
Комната умеет сообщать о происходящем событиями: onJoin,
onLeave, onStart, onFull,
onEmpty. Чат умеет принимать серверную операцию
announce — системное сообщение в канал. Связь соединяет
одно с другим, и игре для этого не нужно ни строчки кода: сервер сам
напишет «Игрок вошёл», когда это случится.
Поля события подставляются в with фигурными скобками:
у onJoin есть {room}, {user_id},
{name}; у onLeave ещё и {reason}
(QUIT, TIMEOUT, KICK). Обязательно
передавайте room — иначе чат не поймёт, в какую комнату
писать.
Справа — четыре типовые связи. Их же собирает мышью редактор «Бекенд»: кнопка ⇢ на узле комнаты, выбор события и операции.
{
"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} вошёл в комнату"}},
{"from": "lobby.onLeave", "to": "chat.announce",
"with": {"channel": "room", "room": "{room}",
"text": "{name} вышел ({reason})"}},
{"from": "lobby.onFull", "to": "chat.announce",
"with": {"channel": "room", "room": "{room}",
"text": "Все в сборе — начинаем!"}},
{"from": "lobby.onStart", "to": "chat.announce",
"with": {"channel": "room", "room": "{room}",
"text": "Матч начался. Удачи!"}}
]
}// Игра ничего не делает для объявлений — просто слушает чат:
zloy.rt.on('chat', 'message', (m) => {
// системные приходят с kind: 'SYSTEM' и from: null
appendMessage(m.kind === 'SYSTEM' ? {...m, system: true} : m);
});
// Вход в комнату — обычная операция; объявление напишет связь
await zloy.rt.op('lobby', 'quick');
// Ожидаемая лента:
// SYSTEM Алиса вошла в комнату
// SYSTEM Все в сборе — начинаем!
// PLAYER Алиса: погналиinterface ChatMessage {
channel: string;
from: {user_id: number; name: string} | null; // null у системных
kind: 'PLAYER' | 'SYSTEM';
text: string;
ts: number;
}
zloy.rt.on('chat', 'message', (m: ChatMessage) => {
if (m.kind === 'SYSTEM') showSystemLine(m.text);
else showPlayerLine(m.from!.name, m.text);
});Куда ещё ведут события комнаты. Связь не обязана заканчиваться
чатом: onStart обычно запускает партию и таймер матча,
onEmpty — гасит таймеры и возвращает ставки, а
onLeave может начислить сопернику победу.
Правило одно: справа от стрелки стоит серверная операция (в справочнике помечена «только связи»), а слева — событие. Клиенту эти операции недоступны — иначе игрок начислял бы себе награды сам.
{
"links": [
{"from": "lobby.onStart", "to": "session.start"},
{"from": "lobby.onStart", "to": "timer.after",
"with": {"sec": 900, "tag": "match_end",
"data": {"room": "{room}"}}},
{"from": "timer.fired", "to": "session.advance"},
{"from": "lobby.onEmpty", "to": "timer.cancel",
"with": {"tag": "match_end"}},
{"from": "lobby.onEmpty", "to": "bank.refund",
"with": {"room": "{room}"}}
]
}// То же самое, но глазами игры: она видит только результаты.
zloy.rt.on('session', 'phase', (p) => setPhase(p.phase, p.ends_at));
zloy.rt.on('session', 'ended', (p) => showResults(p.result));
zloy.rt.on('bank', 'changed', (p) => updateBalance(p.balance));interface PhasePayload {
room: string; phase: string; round: number; ends_at: number;
}
zloy.rt.on('session', 'phase', (p: PhasePayload) => {
setPhase(p.phase, p.ends_at); // ends_at — серверное время окончания
});Черновик и опубликованная игра работают с разными данными.
Превью из студии подключается к dev-окружению: там свои комнаты, свои сохранения и «песочные» деньги банка. Опубликованная игра работает с prod. Данные не пересекаются — тестируйте спокойно.
Настройки бекенда в dev применяются на лету (правка
zloy_server.json подхватывается за ~15 секунд), а в prod уезжают
вместе с публикацией версии и проходят ту же модерацию, что игра.
bank#Внутриигровая экономика: счета в валютах игры (монеты, опыт, уровни), покупки, начисления связями, переводы, СТАВКИ (котёл матча: скинулись — победители забрали). Строгий ledger, идемпотентность. Приём реальных денег — свойство игры, не кубика.
Задаются в студии (вкладка «Бекенд») или в zloy_server.json. Значения вне границ сервер приводит к допустимым — лимит можно ослабить, но не пробить.
| Свойство | Тип | По умолчанию | Что делает |
|---|---|---|---|
currencies | json | [{"code":"gold","initial":0,"name":"Золото"}] | валюты игры: [{code, name, initial}] — код, название, стартовый баланс нового игрока |
allow_transfer | bool | false | переводы между игроками |
max_amount | int | 1000000 1…1e+09 | максимум одной операции |
daily_debit_limit | int | 0 0…1e+09 | лимит списаний игрока в сутки (0 = без лимита) |
allow_bets | bool | false | ставки: игроки скидываются в котёл матча |
max_bet | int | 10000 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');# последние операции игрока
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});# поставить в котёл текущей комнаты
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'});# сколько в котле комнаты и кто внёс
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": {"…": "…"}}
]
}// Вызывается связью в 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, свой ранг. Периоды (навсегда/день/неделя/месяц) и защита от абсурдных значений — свойствами, без кода.
Задаются в студии (вкладка «Бекенд») или в zloy_server.json. Значения вне границ сервер приводит к допустимым — лимит можно ослабить, но не пробить.
| Свойство | Тип | По умолчанию | Что делает |
|---|---|---|---|
period | string | "all" all, day, week, month | период таблицы |
order | string | "desc" desc, asc | лучший результат: desc (больше — лучше) | asc (меньше — лучше) |
keep_best | bool | true | хранить только лучший результат игрока (иначе последний) |
max_score | int | 1000000000 1…1e+09 | потолок результата (защита от абсурда) |
client_submit | bool | true | разрешить отправку результата с клиента (иначе только связи — честнее) |
top_limit | int | 50 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});# лучшие результаты
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});# свой результат и место
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'});// Вызывается связью в 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 (для связей).
Задаются в студии (вкладка «Бекенд») или в zloy_server.json. Значения вне границ сервер приводит к допустимым — лимит можно ослабить, но не пробить.
| Свойство | Тип | По умолчанию | Что делает |
|---|---|---|---|
history | int | 50 0…200 | хвост истории на канал |
rate_per_10s | int | 10 1…60 | сообщений игрока за 10 с |
max_len | int | 300 1…500 | длина сообщения |
global_enabled | bool | true | глобальный канал включён |
# отправить сообщение
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: 'Всем привет!'});# хвост истории канала
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'});// Вызывается связью в 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) включено.
Задаются в студии (вкладка «Бекенд») или в zloy_server.json. Значения вне границ сервер приводит к допустимым — лимит можно ослабить, но не пробить.
| Свойство | Тип | По умолчанию | Что делает |
|---|---|---|---|
capacity | int | 8 2…64 | мест в комнате |
min_start | int | 2 1…64 | минимум игроков для старта |
auto_start | bool | false | старт автоматически, когда все готовы |
allow_late_join | bool | false | можно входить после старта |
max_rooms | int | 100 1…1000 | комнат на приложение |
persist | bool | true | переживать перезапуск бекенда: комнаты восстанавливаются из хранилища (иначе живут только пока идёт игра) |
keep_hours | int | 24 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');# создать комнату
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});# войти по 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');# готовность
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});# данные своего слота (команда/роль/аватар...)
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');# выгнать игрока (хост)
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});// Вызывается связью в 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": {"…": "…"}}
]
}// Вызывается связью в 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». Роли, фазы и предметы описываются данными, движок не знает ни одной конкретной игры.
Задаются в студии (вкладка «Бекенд») или в zloy_server.json. Значения вне границ сервер приводит к допустимым — лимит можно ослабить, но не пробить.
| Свойство | Тип | По умолчанию | Что делает |
|---|---|---|---|
rules | json | — | описание мира: {entities, clock, actions, rules, end, tables}. Пусто — берётся preset, затем world.json проекта |
min_players | int | 0 0…64 | минимум игроков для старта партии (0 — мир без партий) |
seed | int | 0 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": {"…": "…"}}
]
}// Вызывается связью в 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": {"…": "…"}}
]
}// Вызывается связью в 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 для счётчиков.
Задаются в студии (вкладка «Бекенд») или в zloy_server.json. Значения вне границ сервер приводит к допустимым — лимит можно ослабить, но не пробить.
| Свойство | Тип | По умолчанию | Что делает |
|---|---|---|---|
max_value_bytes | int | 16384 1…65536 | размер документа |
max_keys_player | int | 64 1…1024 | ключей на игрока |
max_keys_world | int | 256 1…4096 | ключей мира |
world_writable | bool | true | клиенты могут писать в world |
searchable | bool | true | разрешить поиск по сохранённым документам (op query): фильтры по полям, сортировка, страницы |
search_limit | int | 50 1…500 | максимум документов в ответе поиска |
# прочитать ключ
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'});# записать документ
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}});# атомарно прибавить к числу
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'});# ключи по префиксу
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'});# удалить ключ
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.
| Событие | Что значит | Данные |
|---|---|---|
changed | world-ключ изменился (всем в приложении) | {"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 раз в секунду.
Задаются в студии (вкладка «Бекенд») или в zloy_server.json. Значения вне границ сервер приводит к допустимым — лимит можно ослабить, но не пробить.
| Свойство | Тип | По умолчанию | Что делает |
|---|---|---|---|
tick_hz | int | 10 1…30 | снимков в секунду |
max_state_bytes | int | 512 32…4096 | размер состояния игрока |
drop_after_sec | int | 30 5…300 | забыть игрока без обновлений через N с |
send_empty | bool | false | слать снимок, даже если ничего не менялось |
# своё состояние (заменяет предыдущее)
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}});# обновить часть полей состояния
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 для следующих связей. Авторитарные дедлайны фаз без кода.
Задаются в студии (вкладка «Бекенд») или в zloy_server.json. Значения вне границ сервер приводит к допустимым — лимит можно ослабить, но не пробить.
| Свойство | Тип | По умолчанию | Что делает |
|---|---|---|---|
max_active | int | 100 1…1000 | активных таймеров на приложение |
min_sec | int | 1 1…3600 | минимальная задержка, сек |
// Вызывается связью в 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": {"…": "…"}}
]
}// Вызывается связью в 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": {"…": "…"}}
]
}// Вызывается связью в 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": {"…": "…"}}
]
}# когда сработает тег (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 игрокам | 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);
};