Я делаю браузерную текстовую ролевую игру, где ведущим работает языковая модель. Игрок пишет любое действие своими словами, мастер отвечает рассказом, а игра ведёт механику: здоровье, деньги, характеристики, инвентарь, броски d20. Кажется, что всё просто: отправь модели историю и лист персонажа, получи ответ. На практике самые интересные задачи оказались не в промптах вида «напиши смешно», а на стыке вероятностной модели и детерминированной игровой механики. Ниже — четыре таких места и то, как я их решил.
Стек: Node.js 24, Express, встроенный node:sqlite, модели через OpenRouter и общий для сервера и браузера модуль движка.
1. Механику ведёт код, а не модель
Первая версия была наивной: модели дают весь лист персонажа и просят «ответь и обнови состояние». Это быстро ломается. Модель забывает предметы, раздаёт мечи +100, путает, сколько у героя денег, и через десяток ходов лист персонажа живёт своей жизнью.
Поэтому модель отвечает строгим JSON, в котором кроме рассказа есть только изменения:
{
"check": { "stat": "ХАР", "dc": 12, "why": "уговорить дракона", "kind": "other", "ok": true },
"narration": "Дракон открывает один глаз…",
"hp": -3,
"add": [{ "name": "Чужой сапог", "icon": "👢", "slot": "feet", "def": 1 }],
"fact": "Гоша назвал дракона папой"
}
Плюс поле scene с ключами из каталога пиксельных картинок, по которым клиент собирает иллюстрацию к ходу. Применяет изменения движок, и он же следит за границами: броня режет урон не больше чем вдвое, характеристики не уходят в бесконечность, у предметов из редактора есть потолок бонуса. Модель придумывает, что произошло, а код решает, сколько это стоит. Например, урон с учётом брони:
let dhp = num(r.hp);
if (dhp < 0 && !r.pierce) { // броня снижает урон, но не больше чем вдвое
const def = Math.max(0, gear(ch).def);
const dmg = -dhp, after = Math.max(Math.ceil(dmg / 2), dmg - def);
dhp = -after;
}
Поле fact уходит в «хронику» — короткий список событий, который подмешивается в промпт следующих ходов. Так мастер помнит, что двадцать ходов назад герой назвал дракона папой, а вся история целиком в контекст не тащится.
2. Один запрос на ход вместо двух
Изначально ход был двухшаговым. Сначала модель решала, нужна ли проверка и какая сложность, потом игрок бросал кубик, потом второй запрос — рассказ с учётом броска. Двойная цена и двойная задержка, а на быстрых дешёвых моделях задержка ощущается сильнее всего.
Решение — бросать кубик заранее. Сервер кидает d20 до запроса и кладёт результат в промпт:
КУБИК: выпало 14, с бонусом Харизмы итог 16.
Системный промпт объясняет, как с этим работать: если проверка нужна, первым полем ответа идёт check (характеристика, сложность по шкале 8 / 12 / 15 / 18+, успех или провал), а рассказ пишется уже с учётом исхода. Если проверка не нужна, check нет и кубик не упоминается.
Сервер хранит такой ответ «запечатанным». Игроку показывается только «нужна проверка Харизмы, сложность 12» и анимация кубика, а поле ok вырезается:
const publicCheck = (c) => { const { ok, ...rest } = c; return rest; };
Игрок нажимает «Бросить», и рассказ открывается без нового запроса к модели.
Остаётся арифметика. Модели иногда пишут «успех» при итоге ниже сложности. Перебрасывать нельзя — рассказ уже написан под исход. Поэтому сервер подвигает сложность так, чтобы честный результат совпал с текстом:
function sealedRoll(ch, check, die, used) {
let roll = resolveCheck(ch, check, die, used);
if (typeof check.ok === 'boolean' && !roll.crit && roll.ok !== check.ok && Number.isFinite(roll.total)) {
roll = resolveCheck(ch, { ...check, dc: check.ok ? roll.total : roll.total + 1 }, die, used);
}
return roll;
}
Критические 1 и 20 не трогаем — это святое. И ещё одна мелочь: потоковую выдачу пришлось научить придерживать текст, пока не ясно, будет ли проверка, иначе игрок успевает прочитать исход до броска.
3. Баг, который удваивал здоровье
Тестировщики пожаловались: стоит мастеру прибавить герою максимум здоровья, как дальше он начинает удваиваться. Причина нашлась в одной фразе промпта: «hp, money, stats — изменения, а не итоговые значения». Про max_hp в ней не было ни слова. Модель видела «Здоровье 10/20», решала, что герой стал крепче, и писала max_hp: 20 — итоговое число. А движок честно прибавлял: 20, 40, 80…
Лечение получилось двухслойным. В промпте max_hp добавлен в список изменений с правилом «меняй редко и не больше чем на четверть; лечение, отдых и еда — это hp». А в движке стоит страховка на случай, если модель всё равно пришлёт итог:
function maxHpDelta(cur, v) {
if (!v) return 0;
const lim = Math.max(2, Math.round(cur / 4));
return clamp(v > lim ? v - cur : v, -lim, lim);
}
Число больше допустимого шага считается итоговым значением и превращается в разницу, а сам шаг ограничен ±25%. Из той же истории выросло ещё одно правило: когда максимум растёт, текущее здоровье растёт на ту же величину. Иначе «благословение: +5 к здоровью» выглядит для игрока так, будто ничего не изменилось.
Вывод банальный, но полезный: у каждого числового поля в JSON‑ответе модели должен быть явный тип — дельта или абсолютное значение — и проверка на стороне кода, а не только в промпте.
4. Стили мастера: где в промпте живёт характер
Последнее нововведение — «характер» ведущего: Классика, Угар, Хам, Эпос и Документалка. Первая версия разочаровала: стили звучали почти одинаково. Блок стиля стоял в середине длинного системного промпта, после него шли правила формата, механики и примеры, и модель про стиль просто забывала.
Помогли три вещи:
-
Перенести блок стиля в самый конец системного промпта и прямо написать, что стиль важнее привычной манеры и тона мира, а правила игры, проверки и формат JSON не меняются.
-
Продублировать его одной строкой прямо перед действием игрока: «(Стиль ведущего: Хам.)».
-
Дать каждому стилю свой объём ответа. Хаму нужно место, чтобы и отыграть сцену, и поиздеваться над игроком, а Документалке — чтобы закадровый голос успел выдержать паузу.
Строка‑напоминание оказалась самой дешёвой и самой действенной правкой: десяток токенов, а разница — как между двумя разными играми.
Итого
-
Модель придумывает, код считает. Всё, что влияет на баланс, проходит через движок с жёсткими границами.
-
Случайность — до запроса, а не после. Это экономит запрос и делает ответ цельным.
-
У каждого числа в ответе модели есть явный тип и страховка в коде.
-
Важные инструкции — в конец промпта и короткой строкой рядом с вводом пользователя.
Проект называется «Абсурдия», посмотреть, как это работает вживую, можно здесь: https://absurdia.online/?src=habr. Буду рад вопросам про промпты и архитектуру в комментариях.
Автор: Absurdia


