Я делаю браузерную текстовую ролевую игру, где ведущим работает языковая модель. Игрок пишет любое действие своими словами, мастер отвечает рассказом, а игра ведёт механику: здоровье, деньги, характеристики, инвентарь, броски d20. Кажется, что всё просто: отправь модели историю и лист персонажа, получи ответ. На практике самые интересные задачи оказались не в промптах вида «напиши смешно», а на стыке вероятностной модели и детерминированной игровой механики. Ниже — четыре таких места и то, как я их решил.

Стек

Node.js 24, Express, встроенный node:sqlite, модели через OpenRouter и общий для сервера и браузера модуль движка.

Первая версия

Первая версия была наивной: модели дают весь лист персонажа и просят «ответь и обнови состояние». Это быстро ломается. Модель забывает предметы, раздаёт мечи +100, путает, сколько у героя денег, и через десяток ходов лист персонажа живёт своей жизнью.

Поэтому модель отвечает строгим JSON, в котором кроме рассказа есть только изменения:

Плюс поле scene с ключами из каталога пиксельных картинок, по которым клиент собирает иллюстрацию к ходу. Применяет изменения движок, и он же следит за границами: броня режет урон не больше чем вдвое, характеристики не уходят в бесконечность, у предметов из редактора есть потолок бонуса. Модель придумывает, что произошло, а код решает, сколько это стоит. Например, урон с учётом брони:

Поле fact уходит в «хронику» — короткий список событий, который подмешивается в промпт следующих ходов. Так мастер помнит, что двадцать ходов назад герой назвал дракона папой, а вся история целиком в контекст не тащится.

Изначальная логика хода

Изначально ход был двухшаговым. Сначала модель решала, нужна ли проверка и какая сложность, потом игрок бросал кубик, потом второй запрос — рассказ с учётом броска. Двойная цена и двойная задержка, а на быстрых дешёвых моделях задержка ощущается сильнее всего.

Решение — бросать кубик заранее. Сервер кидает d20 до запроса и кладёт результат в промпт:

Системный промпт объясняет, как с этим работать: если проверка нужна, первым полем ответа идёт check (характеристика, сложность по шкале 8 / 12 / 15 / 18+, успех или провал), а рассказ пишется уже с учётом исхода. Если проверка не нужна, check нет и кубик не упоминается.

Сервер хранит такой ответ «запечатанным». Игроку показывается только «нужна проверка Харизмы, сложность 12» и анимация кубика, а поле ok вырезается:

Игрок нажимает «Бросить», и рассказ открывается без нового запроса к модели.

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

Критические 1 и 20 не трогаем — это святое. И ещё одна мелочь: потоковую выдачу пришлось научить придерживать текст, пока не ясно, будет ли проверка, иначе игрок успевает прочитать исход до броска.

Проблема с удвоением здоровья

Тестировщики пожаловались: стоит мастеру прибавить герою максимум здоровья, как дальше он начинает удваиваться. Причина нашлась в одной фразе промпта: «hp, money, stats — изменения, а не итоговые значения». Про max_hp в ней не было ни слова. Модель видела «Здоровье 10/20», решала, что герой стал крепче, и писала max_hp: 20 — итоговое число. А движок честно прибавлял: 20, 40, 80…

Лечение получилось двухслойным. В промпте max_hp добавлен в список изменений с правилом «меняй редко и не больше чем на четверть; лечение, отдых и еда — это hp». А в движке стоит страховка на случай, если модель всё равно пришлёт итог:

Число больше допустимого шага считается итоговым значением и превращается в разницу, а сам шаг ограничен ±25%. Из той же истории выросло ещё одно правило: когда максимум растёт, текущее здоровье растёт на ту же величину. Иначе «благословение: +5 к здоровью» выглядит для игрока так, будто ничего не изменилось.

Вывод банальный, но полезный: у каждого числового поля в JSON‑ответе модели должен быть явный тип — дельта или абсолютное значение — и проверка на стороне кода, а не только в промпте.

Стили мастера

Последнее нововведение — «характер» ведущего: Классика, Угар, Хам, Эпос и Документалка. Первая версия разочаровала: стили звучали почти одинаково. Блок стиля стоял в середине длинного системного промпта, после него шли правила формата, механики и примеры, и модель про стиль просто забывала.

Перенести блок стиля в самый конец системного промпта и прямо написать, что стиль важнее привычной манеры и тона мира, а правила игры, проверки и формат JSON не меняются.

Продублировать его одной строкой прямо перед действием игрока: «(Стиль ведущего: Хам.)».

Дать каждому стилю свой объём ответа. Хаму нужно место, чтобы и отыграть сцену, и поиздеваться над игроком, а Документалке — чтобы закадровый голос успел выдержать паузу.

Строка‑напоминание оказалась самой дешёвой и самой действенной правкой: десяток токенов, а разница — как между двумя разными играми.

Модель придумывает, код считает. Всё, что влияет на баланс, проходит через движок с жёсткими границами.

Случайность — до запроса, а не после. Это экономит запрос и делает ответ цельным.

У каждого числа в ответе модели есть явный тип и страховка в коде.

Важные инструкции — в конец промпта и короткой строкой рядом с вводом пользователя.

Проект называется «Абсурдия», посмотреть, как это работает вживую, можно здесь: https://absurdia.online/?src=habr. Буду рад вопросам про промпты и архитектуру в комментариях.