Язык для интерактивных историй — это весело до тех пор, пока сюжет не превращается в механическую цепочку заранее известных развилок. Чтобы истории жили дольше одного прохождения, им нужна случайность. В этой статье я расскажу, как можно встроить элемент «судьбы» в сам DSL: добавить рандом, вероятности, броски кубиков и даже скрытые триггеры. Всё это — на Python, с реальными примерами кода, а не только с теорией.

Почему предопределённые истории быстро наскучивают
Когда пишешь первый прототип языка для текстовых историй, он кажется волшебным: простые сцены, ветки, условия — и у тебя уже своя мини-игра. Но стоит пройти её второй раз, и магия пропадает: всё те же сцены, всё те же выборы, игрок уже «знает» что будет дальше.
Нам нужен элемент неожиданности, как в настольных RPG — там всегда есть шанс провала или успеха, даже если у персонажа высокий навык. Без этого ощущение «судьбы» исчезает, а история превращается в сухую диаграмму переходов.
DSL без случайности — это пьеса. DSL со случайностью — это уже жизнь.
Синтаксис судьбы: придумываем новые конструкции
В исходном языке у меня были только scene
, text
, choice
и set
. Этого достаточно, чтобы описывать предопределённые развилки, но никак не случайность. Пришлось добавить новые ключевые слова:
random
— бросок случайного числа;chance
— проверка вероятности (например, «30% что персонаж оступится»);dice
— синтаксический сахар для привычных кубиков (1d6, 2d10 и т.д.);trigger
— скрытые условия, которые срабатывают неожиданно.
Пример на DSL:
scene bridge:
text "Ты идёшь по старому мосту."
chance 0.3 -> fall
choice "Продолжить путь" -> forest
scene fall:
text "Доска ломается, и ты падаешь вниз."
set health -= dice(1d6)
choice "Выбраться из ямы" -> forest
На первый взгляд это добавляет всего пару строчек. Но теперь игрок может пройти сцену пять раз и каждый раз получить разный результат.
Реализация случайности на Python
Теперь самое интересное — как это реализовать в интерпретаторе. Самый простой вариант — использовать встроенный модуль random
.
import random
import re
class StoryRunner:
def __init__(self, scenes):
self.scenes = scenes
self.state = {"health": 10}
def roll_dice(self, notation: str) -> int:
"""Бросок кубиков в формате 2d6 или 1d20"""
match = re.match(r"(\d+)d(\d+)", notation)
if not match:
raise ValueError("Неверный формат кубика")
count, sides = map(int, match.groups())
return sum(random.randint(1, sides) for _ in range(count))
def run_chance(self, probability: float) -> bool:
return random.random() < probability
def execute(self, command):
if command.startswith("chance"):
_, prob, target = command.split()
if self.run_chance(float(prob)):
return target
elif command.startswith("dice"):
_, expr = command.split()
return self.roll_dice(expr)
return None
Да, это упрощённый фрагмент, но уже с ним можно сделать «живую» историю.
Как это меняет восприятие игрока
Вместо детерминированного сценария теперь у нас есть «кубик судьбы». Игрок больше не уверен, что его выбор приведёт к конкретному результату. Даже при одинаковом маршруте он может упасть с моста, а может и пройти без происшествий.
И что важно: разработчик (или писатель) сам решает, где добавить случайность. Хочешь классическую RPG-механику — бросай кубики на каждый чих. Хочешь драматичное повествование — оставь ключевые сцены детерминированными, а случайность добавь в мелочи.
Именно так появляется эффект «реиграбельности»: игрок захочет попробовать снова, потому что результат не предсказуем.
Расширение возможностей: скрытые триггеры и невидимая рука
Простой рандом — это хорошо, но можно пойти дальше. Я добавил поддержку скрытых триггеров: они не отображаются в тексте, но могут активироваться в зависимости от случайности.
scene cave:
text "Ты входишь в пещеру."
trigger chance 0.2 -> ambush
choice "Осмотреться" -> inside
scene ambush:
text "Из тени выскакивает гоблин!"
set health -= 2
choice "Драться" -> fight
Игрок думает, что идёт «по сюжету», но внезапно может случиться засада. Это делает историю живой — не только выборы влияют, но и сама «судьба».
Оптимизация и отладка
Случайность — штука коварная. Чтобы проверить корректность игры, я сделал «режим симуляции»: интерпретатор прогоняет историю тысячи раз и считает частоту событий.
def simulate(story, start, runs=1000):
results = {"fall": 0, "safe": 0}
for _ in range(runs):
outcome = story.play_once(start)
results[outcome] += 1
return results
С помощью этого режима можно отлаживать баланс. Например, если шанс упасть с моста по коду 30%, но симуляция показывает 50% — значит где-то ошибка в логике.

Итоги
Добавление случайности в DSL превращает его из «интерактивной книги» в настоящий нарративный движок. Сценарист может играть с судьбой: иногда подсовывать игроку случайные события, иногда оставлять выбор детерминированным. Это делает истории более живыми и реиграбельными.
Мне нравится думать, что DSL без случайности — это шахматы, а DSL со случайностью — это настольная RPG: всё вроде под контролем, но кубик всегда может напомнить, что жизнь не предсказуема.
azTotMD
Делаем с товарищем подобную интерактивную историю в виде ТГ бота. Пока там один тренировочный сценарий. Я написал ещё один, но мы его пока ещё не выкатили. Наверное, как доработаю, напишу здесь статью про этот проект.
Если кому интересно:
https://t.me/ConanGameBot