Meander
SDK 1 · актуально на 2026-09-24

API плагинов Meander

Справочник для автора плагина и для нейросети, которая плагин пишет.

SDK 1
22 UI-типа
0.1 EventContract
2 файла для плагина

01Как пользоваться

Главные правила создания и установки плагина.

Что должна выдать нейросеть

Ровно два файла: plugin.json и main.lua. Имя Lua-файла может отличаться, если это отражено в entry.

Установка

Meander → Плагины → Import plugin → выбрать plugin.json или .zip.

Строгая валидация

Неизвестные поля manifest являются ошибкой установки. Ошибки валидации показываются в интерфейсе и не игнорируются молча.

Ограничения

Нет native code, файлов и сети. Используются только Lua и декларативный JSON.

Важно: UI callback должен быть глобальной функцией по имени. Например function openPage() ... end, а не локальной функцией.
Свойства UI допускают только scalar-значения: number, string, bool, а также children и row. Вложенные style-объекты не используются.
Хранилище плагина является app-wide. Каждый ключ должен начинаться с mnd.questId, иначе возможно пересечение данных между квестами.

02Структура плагина

Плагин является папкой с manifest и Lua entry-файлом.

my-plugin/
├── plugin.json
└── main.lua

plugin.json

Manifest плагина. Описывает идентификатор, версию, entry, permissions, hooks, content types и slots.

main.lua

Логика плагина. Файл, который указан в поле entry.

Плагин описывает намерение, а движок выполняет команды и отрисовывает UI. Host написан на Rust и существует один на процесс. Установка плагина является свойством приложения, а не отдельной игровой сессии.
Наружу передаётся JSON-список команд из фиксированного набора: toast, render, close, goto_node, play_sound, stop_audio, diff переменных и snapshot storage.

03plugin.json

Все разрешённые поля manifest.

ПолеТипОбяз.Описание
idstringдаМинимум 2 сегмента lowercase Latin через точку. Например my.stuff.counter.
namestringдаЧеловекочитаемое имя.
versionstringдаЛюбая непустая строка.
sdkstringдаТочно "1".
entrystringдаОтносительный путь к .lua. Нельзя использовать .., абсолютный путь или \.
descriptionstringнетОписание.
authorstringнетАвтор.
iconstringнетИмя Material icon.
tagsstring[]нетТеги.
permissionsstring[]нетПодмножество ui quest navigation audio storage events.
hooksstring[]нетИмена событий из раздела событий или custom.*. Дубликаты и неизвестные значения являются ошибкой.
contentTypesobject[]нетТипы контента плагина.
slotsobject[]нетUI slots.

slots[]

ПолеОписание
idИдентификатор slot.
areahud, overlay, drawer или menu.
priorityЧисло. Меньшее значение означает выше/левее внутри области.
hud — top-right
overlay — center
drawer — bottom
menu — left edge
{
  "id": "mnd.examples.statushud",
  "name": "Панель статуса",
  "version": "1.0.0",
  "sdk": "1",
  "entry": "main.lua",
  "description": "HUD со шкалами HP и энергии.",
  "author": "Meander",
  "icon": "heart",
  "tags": ["example", "hud", "ui"],
  "permissions": ["ui", "quest"],
  "hooks": ["quest_start", "node_enter", "item_press"],
  "slots": [
    { "id": "status", "area": "hud", "priority": 10 },
    { "id": "details", "area": "overlay", "priority": 20 }
  ]
}

04Permissions

Разрешение проверяется при каждом соответствующем вызове mnd.*.

ui

mnd.ui.toast, mnd.ui.render, mnd.ui.close.

quest

mnd.vars.*.

navigation

mnd.game.go(node).

audio

mnd.audio.play, mnd.audio.stop.

storage

mnd.storage.*.

events

mnd.emit(name, payload).

Без нужного permission Lua получает ошибку. Она попадает в dispatch errors и debug panel. Filesystem, network и shell отсутствуют и физически недоступны.

05Lua API

Полный набор API из SDK 1.

mnd.log.info(text)
Запись в debug panel.
permission не требуется
mnd.log.warn(text)
Предупреждение в debug panel.
permission не требуется
mnd.log.error(text)
Ошибка в debug panel.
permission не требуется
mnd.vars.get(name)
Получить quest variable.
value или nil
mnd.vars.set(name, value)
Установить quest variable.
mnd.vars.has(name)
Проверить наличие quest variable.
bool
mnd.vars.keys()
Получить имена quest variables.
string[]
mnd.ui.toast(text, durationMs?)
Показать toast.
mnd.ui.render(target, spec)
Отрисовать UI.
mnd.ui.close(target)
Закрыть UI target.
mnd.game.go(nodeId)
Перейти на node.
mnd.audio.play(asset, volume?)
Воспроизвести audio asset.
mnd.audio.stop(asset?)
Остановить asset. Без asset — остановить всё.
mnd.storage.get(key)
Получить plugin storage value.
value или nil
mnd.storage.set(key, value)
Записать plugin storage value.
mnd.storage.delete(key)
Удалить storage key.
mnd.storage.keys()
Получить storage keys.
string[]
mnd.on(event, function(payload) end)
Подписка на событие внутри плагина.
mnd.emit(name, payload)
Создать событие.
mnd.cancel()
Отменить cancellable event.
работает только в node_enter
Sandbox удаляет io, os, package, require, debug, load, loadstring, print, dofile. Доступны string, table, math.

06Контекст

Контекст перезаписывается перед каждым hook.

ПолеЗначение
mnd.pluginIdID плагина.
mnd.pluginNameИмя плагина.
mnd.contractVersion"0.1".
mnd.eventТекущее имя события. Вне hook — ''.
mnd.questIdID квеста.
mnd.nodeIdID текущей ноды. До первой ноды — пустая строка.
mnd.nowEpoch milliseconds на момент вызова.
mnd.payloadТаблица payload события. Вне hook — nil.
mnd.vars.* относится к состоянию квеста, а mnd.storage.* — к storage плагина. Оба сохраняются между переходами нод.

07События и hooks

EventContract v0.1.

quest_start

{ quest_id } — перед render первой ноды, один раз.

quest_end

{ quest_id } — после закрытия сессии, один раз.

quest_save

{ slot_id, autosave } — после сохранения slot.

quest_load

{ slot_id, from_save } — перед render восстановленной ноды.

node_enter

{ node_id } — перед default initialization ноды. Единственное событие, где работает mnd.cancel().

node_exit

{ node_id } — после выхода из ноды.

item_reveal

{ node_id, item_id, item_json } — после reveal item. Поля plugin content находятся в item_json.pluginData.

item_press

{ node_id, item_id, trigger } — перед обработкой press.

transition

{ from, to } — после разрешения transition.

audio

{ kind, asset, volume, ... } — mirror audio command.

custom.<name>

Plugin-defined custom event, который создаётся только через mnd.emit.

Способы обработки

mnd.on

Подписка внутри плагина через mnd.on(event, function(payload) end).

Global handlers

on_quest_start, on_node_enter, on_item_reveal и другие.

Просто перечислить hook в manifest недостаточно для логики: hook нужен, чтобы core вызывал плагин для соответствующего события. UI callback onPress и другие callback resolve-ятся по имени глобальной функции и получают один аргумент.

08Render targets

mnd.ui.render(target, spec)

slot:<id>

Slot, объявленный в manifest. Отсутствующий slot является ошибкой.

item:<id>

Контент конкретного item с type: 'plugin'. Живёт ровно одну ноду и удаляется при переходе.

screen

Полноэкранный modal поверх slots. Сохраняется между нодами до закрытия. В приложении может быть только один screen; последний plugin выигрывает.

Slot areas hud, overlay, drawer, menu монтируются на игровом экране. Пустая область ничего не рисует.

09UI

Все 22 типа виджетов, доступные в SDK 1.

column
row
stack
box
card
list
grid
scroll
text
heading
caption
badge
icon
image
input
button
chip
switch
slider
progress
divider
spacer

Контейнеры

column, row, stack, box, card, list, grid, scroll могут иметь children.

list / grid

Дополнительно поддерживают row как шаблон одного элемента. Данные находятся в items.

10UI properties

Свойства распределены по поддерживаемым типам.

Общие и контейнеры
text
Для text, heading, caption, badge, chip label.
label
Для button, chip, switch, slider, badge. Button также понимает text.
size
Для text, icon, badge, spacer. pt/px.
color
Имя цвета или #RRGGBB / #AARRGGBB.
background
Имя цвета или #RRGGBB / #AARRGGBB.
border
Имя цвета или #RRGGBB / #AARRGGBB.
radius
Доступно везде, кроме card, box, stack.
padding
Доступно везде, кроме card, box, stack.
opacity
Доступно везде, кроме card, box, stack.
width / height
Leaf widgets. card, box, stack игнорируют width.
Text
weight
100…900 или 'bold'.
bold
Для text.
maxLines
Для text.
Layout
align
Containers и children stack: start, end, center, stretch, top, bottom, topStart, bottomEnd.
justify
column/row: center, end, space.
gap
column, row, list, grid, card. Значение в px.
fill
column: true означает занять всю высоту.
expand
Любой node внутри column/row: true → Expanded.
list / grid
items
Literal array или '{name}'.
columns
grid: от 1 до 6.
ratio
grid: aspect ratio ячейки.
cellWidth
Минимальная ширина ячейки в px для adaptive columns.
icon / image
icon
Для icon, button, chip, image. Имя из конечного списка.
name
Для icon, button, chip, image.
src / path / asset
Для image.
fit
image: cover, contain, fill, tile.
input
placeholder
Placeholder.
password
Password mode.
multiline
Многострочный ввод.
maxLength
Максимальная длина.
Значения
value
progress / slider / input.
max
progress / slider.
min
slider.
checked
switch.
button / divider / stack
variant
button: outline, text или default filled.
thickness
divider, px.
x / y
stack child, absolute position в px.
args
Carrier callback arguments.
onPress / onTap / onChange / onSubmit
Имя функции, а не код.
Иконки
icon names
heart
star
key
lock
gear
bag
book
coin
clock
check
cross
eye
map
person
sound
bulb
fire
drop
flag
note

11Переменные в UI

String properties поддерживают подстановку переменных.

Используется синтаксис {name}, такой же как в quest text. Поддерживаются вложенные поля и индексы, например {inv.slots[2].name}.
{
  type = 'progress',
  value = '{hp}',
  max = '{hpMax}'
}

Unknown variable

Неизвестная переменная остаётся в UI буквально как {hp}.

Boolean

Boolean выводится как да / нет.

args

Подстановка переменных работает также в args.

icon

Подстановка переменных работает также в icon.

В row template item fields имеют приоритет над quest variables. Например item {name:"ключ",count:2} позволяет использовать {name} × {count}.
Для scalar lists доступны value и index.

12Input

Особенности value binding и callback.

{
  type = 'input',
  placeholder = 'код',
  value = '{typed}',
  maxLength = 4,
  password = true,
  args = itemId,
  onChange = 'typing',
  onSubmit = 'submit'
}
Callback получает { value = 'typed', args = '<from spec>' }. Если args был object, его keys merge-ятся с value.
value является one-way binding: пока input не в focus, spec управляет значением; во время набора client не трогает значение и cursor. Не следует делать render на каждый onChange, потому что focus теряется.

13Adaptive grid

Renderer адаптирует сетку через cellWidth.

Plugin не видит ширину viewport и не имеет media queries. Renderer адаптирует grid через cellWidth.
{
  type = 'grid',
  items = '{bag_items}',
  columns = 5,
  cellWidth = 104,
  gap = 8,
  ratio = 0.72,
  row = {
    type = 'button',
    label = '{name}',
    icon = '{icon}',
    args = '{key}',
    onPress = 'examine'
  }
}

Формула

actual columns = floor((width + gap) / (cellWidth + gap)), максимум columns, минимум 1.

Без cellWidth

Используется ровно columns, поэтому на mobile cells могут сжиматься.

Если button не помещается, renderer убирает icon и оставляет label с ellipsis.

14Content types

Новые типы node content, которых нет встроенными в engine.

Примеры назначения: combination lock, diary, radio. Поля являются form/data, а не code.
{
  "contentTypes": [
    {
      "id": "combinationLock",
      "name": "Комбинационный замок",
      "description": "Айтем с клавиатурой: код открывает ноду.",
      "icon": "lock",
      "fields": [
        { "key": "code", "label": "Код", "type": "text", "default": "1337" },
        { "key": "targetNode", "label": "Куда вести", "type": "text" }
      ]
    }
  ]
}

text

Текстовое поле.

number

Поддерживает min/max и hint.

bool

Boolean.

select

Имеет options.

color

Цвет.

asset

Asset.

Полный ID content type: <pluginId>:<id>.
Content type появляется в add content на node и может быть размещён в row/column/stack.
Незаполненные поля используют default. Без default: text = пустая строка, number = min/0, bool = false, select = первый option.
Плагин определяет item через pluginTypeId. Заполненные поля находятся в payload.item_json.pluginData во время item_reveal.

15Лимиты

Ограничения одного plugin pass и UI specification.

commands per call64
log records per call100
UI nodes400
spec depth24
properties per node22
children / array length64
string length2000
payload64 KiB
storage keys256
storage value size16 KiB
Lua memory per plugin16 MiB
Lua instructions per call5,000,000
mnd.emit nesting4
При превышении лимита возникает ошибка для плагина в этом pass. Quest продолжается, game loop не падает. Instruction budget защищает от бесконечных циклов, замораживающих игру.

16Минимальный рабочий плагин

Полный пример из SDK.

plugin.json

{
  "id": "my.counter",
  "name": "Счётчик",
  "version": "1.0.0",
  "sdk": "1",
  "entry": "main.lua",
  "description": "Считает открытия сцены и показывает счётчик в углу.",
  "author": "Ваше имя",
  "permissions": ["ui", "quest", "storage"],
  "hooks": ["quest_start", "node_enter"],
  "slots": [
    { "id": "counter", "area": "hud", "priority": 30 }
  ]
}

main.lua

local function storageKey()
  return tostring(mnd.questId) .. '.counter'
endlocal function current()
local value = mnd.vars.get('visits')
if type(value) == 'number' then return value end
if type(value) == 'string' then return tonumber(value) or 0 end
return 0
end

local function render()
mnd.ui.render('slot:counter', {
type = 'card',
align = 'stretch',
background = '#1B1B22',
border = '#3A3A46',
radius = 16,
padding = 12,
gap = 6,
children = {
{
type = 'badge',
label = 'Сцена',
background = '#26262F',
color = 'gray',
radius = 8
},
{
type = 'heading',
text = 'Открытий: {visits}',
color = 'white'
},
{
type = 'button',
label = 'Забыть',
variant = 'outline',
color = 'white',
border = '#3A3A46',
onPress = 'reset'
}
}
})
end

function on_quest_start()
if not mnd.vars.has('visits') then
mnd.vars.set('visits', mnd.storage.get(storageKey()) or 0)
end
render()
end

function on_node_enter()
mnd.vars.set('visits', current() + 1)
mnd.storage.set(storageKey(), current())
render()
end

function reset()
mnd.vars.set('visits', 0)
mnd.storage.set(storageKey(), 0)
mnd.ui.toast('Счётчик сброшен', 1500)
render()
end

17Частые ошибки

Ошибки, которые отдельно описаны в SDK.

1

Локальный callbackonPress='openPage' с local function openPage() не работает. Handler должен быть глобальным.

2

Неверное поле manifestНапример authorName вместо разрешённого поля приводит к ошибке установки.

3

Вложенный stylestyle={color='red'} validator отклоняет. Используй плоское color.

4

children не тамchildren нельзя использовать у text/button.

5

list/grid без argsКнопки без args='{key}' вызывают одну и ту же функцию с одинаковым literal.

6

Storage без prefixКлюч без prefix из mnd.questId может вызвать cross-quest leakage.

7

Render на onChangeПовторный render surface на каждый onChange приводит к потере focus input.

8

Переменная может отсутствоватьПеред первым использованием проверяй mnd.vars.has или инициализируй через mnd.vars.set.

9

mnd.cancelРаботает только внутри node_enter.

10

while для animationНе используй Lua loop для анимации. Instruction budget ограничен; анимацию должен выполнять renderer/engine.

18Чеклист публикации

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

✓

PermissionsНет лишних permissions.

✓

ManifestID имеет два сегмента, sdk = "1", entry указывает на существующий Lua-файл.

✓

HooksТолько события из SDK, без дубликатов.

✓

UI specsНет property objects, callback являются identifiers.

✓

HandlersОбработчики являются глобальными.

✓

StorageВсе storage keys начинаются с mnd.questId.

✓

ПоведениеПлагин должен заметно менять поведение квеста, а не только украшать UI.

Оригинальные примеры: plugins/examples — status-hud, achievements, combination-lock, travel-journal, shop, inventory.