Привет, Хабр! Меня зовут Костя, я десять лет занимаюсь коммерческой Android-разработкой. Давно хотелось попробовать себя в вебе, а эксперименты с LLM давали некоторую уверенность в том, что по крайней мере есть шанс написать агентами поддерживаемый код и задеплоить.

Что же можно сделать? Конечно тамагочи! Текстовая браузерная игра: герой живёт в мире мрачного будущего. Непрямое управление, возможность предлагать миссии, а также тюнинг предпочтений героя. Эдакий Годвилль, но на новый лад.

Оглавление

Как это создавалось
Что может делать игрок
Мир общий для всех
Как сделан дневник событий
Как игра понимает приказы
Модель рассказывала о том, чего уже нет
Как научился не сильно бояться при деплое
Дорогая модель не значит лучшая
Стек
Кто пришёл играть
Расходы
Выводы и что дальше

Как это создавалось

Началось с обсуждения в веб-версии Claude. Описал концепцию мира и что хотелось бы видеть - текстовую браузерную игру с историей персонажа, возможностью взаимодействовать с миром, а также с другими игроками. Прорабатывал пару дней, результатом был диздок в md-файле. Его я сложил в директорию будущего проекта и зарядил Fable отрабатывать подписку в Claude Code. Начал с планирования реализации роем агентов. В результате разделили реализацию на одиннадцать вех. За ночь прошла генерация кода первой части вех, на следующий прекрасный выходной день продолжили и ночью задеплоили прототип:

Любезно составленная моделью сводка первого дня

Время

Что закрыто

02:55

Веха 0: фундамент — go mod, Postgres в докере, миграции, конфиг, healthz, AGENTS.md

03:00

Веха 1: детерминированное ядро симуляции — RNG от (seed, day, tick, purpose), 7 действий, черты характера

03:10

Веха 2: предгенерация суток, капельная выдача, гостевой вход

14:32

Веха 3: дизайн-система + рендер прозы

14:40

Веха 4: стратегия — ползунки, директивы, перегенерация

14:56

Веха 5: мир — 6 городов, 12 мобов, 15 НПЦ, предметы, память героя

15:04

Веха 6: встречи героев

15:08

Веха 7: LLM-батч — Gemini, сцены, виньетки, сводки дня

15:15

Веха 8: новости — три редакции

15:21

Веха 9: регистрация — email-PIN, Google OAuth

15:27

Веха 10: Web Push + PWA

16:42–17:13

мир переехал на реальную Землю 2200-х: Байконур, Стамбул, Норильск, Атакама, Канаверал, Детройт, Роттердам; пол героя и родовые формы во всех текстах

17:16

Веха 11: i18n

20:56–23:27

своя аналитика без PII, домен, ops-дашборды за mTLS, карьера и криминал

Сейчас разработка идёт уже третью неделю, понемногу расширяю мир, исправляю ошибки, улучшаю генерацию по фидбеку игроков. Патчноуты стали частью пайплайна: заметку о том, что изменилось, собирают агенты, а игрок видит её во вкладке аккаунта.
Весь проект сделан Claude Code, моделями Fable и Opus. Fable помогал строить архитектуру и писал ядро, Opus тянул остальное.

После того как более-менее наладился процесс выкатки фич и стало понятно, что всё работает, попросил сделать команду для Claude Code. Выделился постоянный паттерн - исследование, написание и утверждение плана. Потом создание ворктри, выполнение, тесты, мердж, деплой по необходимости. Новая команда берёт промпт, который ей передали, запускает расследование, составляет план, предлагает его проапрувить или поменять. После завершения реализации - ревью в субагенте с чистым контекстом. Когда команда отработала, она предлагает смерджить или посмотреть глазами. Для экономии токенов разные этапы выполняются разными моделями Anthropic. Отладка заняла какое-то время, итоговый файл команды получился на 250 строк. Зато сейчас обычно /task <описание что делать> покрывает 90% задач по внедрению новых фич.

Что может делать игрок

Прямого управления нет. Основных путей влияния два - характер и цели. Характер настраивается ползунками и влияет на то, чего больше или меньше хочется герою. Цель вводится обычными словами, её разбирает модель, и герой начинает предпринимать действия, которые к ней ведут. Можно ничего не задавать, тогда персонаж будет действовать по наитию.

Лента героя в браузере компьютера
Лента героя в браузере компьютера

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

Мир общий для всех

Герои могут взаимодействовать по-разному. К примеру, оказаться в одном заведении в один час, тогда есть вероятность встречи и у обоих в ленте появится сцена. Также существуют NPC. Это мэры, шефы полиции, владельцы заведений. По миру кочуют корпораты. Эти фигуры сменяются, и мир это замечает. Герой может и сам со временем купить заведение и стать его владельцем. В городе есть погода, могут происходить фестивали или другие события.

Как сделан дневник событий

Не секрет, что LLM сложно работать с большими контекстными окнами. А хотелось попытаться создать более-менее связный мир и чтобы он не развалился по мере роста. Из предыдущих экспериментов с моделями я сделал вывод, что надо стараться иметь детерминированную основу, а модель использовать для внесения данных в эту систему либо для вывода из неё по строгим правилам. Это даёт возможность меньше «дрейфовать» результату и выдавать более правдоподобные вещи. Итак, у нас детерминированный мир (города, в них районы, заведения и т. п.), происходят события, которые его меняют, а LLM могла бы «рендерить» текст и разбирать задания.

Но рендерить все записи оказалось накладно. В результате пришёл к комбинированному подходу: часть текстов «запекается», а часть рендерится LLM. Когда мы хотим создать запись для ленты, смотрим, о чём она, проверяем условия и создаём запись либо из комбинации заранее созданных значений текста, либо генерируем моделью по текущему событию и состоянию мира и получаем небольшой текст. Ночью запускается джоба, которая строит сутки наперёд, далее в ленте игроку выдаются события капельно по часам. Некоторые вещи (например, создание миссии) могут вызвать пересчёт ещё не показанного конца дня. Это позволяет более-менее контролировать расход токенов. Если потребление начнёт расти, то можно попробовать батчи (в Gemini, если не требовать мгновенного исполнения, запрос можно поставить в очередь — токены сгенерируют с 50-процентной скидкой) или меньше записей создавать моделью.

Поток данных: симуляция рождает событие, событие рендерится шаблоном или моделью, готовый текст капает в ленту
Поток данных: симуляция рождает событие, событие рендерится шаблоном или моделью, готовый текст капает в ленту

Записи из шаблонов

К примеру, есть событие (JSON упрощён):

{
  "type": "work",
  "day": "2026-07-28",
  "tick": 3,
  "params": {
    "loc": "farms",
    "district": "farms_kompot",
    "district_tier": 2,
    "prof": "farmer",
    "rank": 2,
    "npc": "farm_elder"
  },
  "deltas": { "credits": 33, "xp": 4, "skill_farming": 1 }
}

В deltas хранится, что событие изменило в состоянии героя, а params описывает контекст.

Первым делом вычисляется ключ — из типа события и, если нужно, уточняющего параметра (они разные для разных событий). Для нашего примера ключ будет: type: "work" + params.prof: "farmer"work.farmer. Рендерер берёт пул вариантов по ключу variants := pack.Templates["work.farmer"], отбрасывает варианты, чьи условия не совпали с фактом (не тот город, не тот час, не та погода, не та черта характера), и выбирает из оставшихся — детерминированно (тот же герой в тот же час всегда получит ту же фразу) и с оглядкой на неделю назад, чтобы лента не повторялась. В результате для игрока в журнале будет что-то типа:

Честный труд под стеклом: 33 кредита за смену и запах компоста, который сходит вместе с кожей.

В коде варианты текста для ключа выглядят так (упрощено — часть полей и комментариев убрана):

// Вариант текста: сама фраза + условия, при которых она годится.
type TplVariant struct {
	Text string

	// Условия (все опциональные): вариант берётся, только если совпали.
	IfLoc     string // событие случилось в этой локации
	IfWeather string // погода дня в этом городе
	IfDaypart string // часть суток: morning|day|evening|night
	// …и ещё несколько: черта характера, параметр события, окно дат
}

// Пул профессии: у каждого ключа — свой список вариантов.
var templatesFarmer = map[string][]TplVariant{
	"work.farmer": {
		// Безусловное ядро — годится всегда.
		{Text: "Честный труд под стеклом: {n, plural, one{# кредит} few{# кредита} " +
			"many{# кредитов} other{# кредита}} за смену и запах компоста, " +
			"который сходит вместе с кожей."},
		{Text: "Урожай собран, ящики подписаны, спина отправила официальную жалобу."},

		// Условные — включаются, только если совпали с фактом.
		{Text: "Гуси-мутанты признают только твёрдую руку и полный кормораздатчик. " +
			"У {name} сегодня было и то и другое.", IfLoc: "farms"},
		{Text: "Смена началась до света: купол просыпается по таймеру, " +
			"а люди — как получится.", IfDaypart: "morning"},
		{Text: "Под куполом погода — это расписание: {weather}. По нему сверяют часы, " +
			"обед и терпение.", IfWeather: "dome"},

		// …ещё три десятка вариантов
	},
}

Всего сейчас в игре около 2200 таких вариантов примерно на 235 ключей, это «запечённые» тексты, написанные во время разработки; они лежат в приложении.

Генерируемые записи

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

{
  "type": "vignette",
  "day": "2026-07-28",
  "tick": 11,
  "params": {},
  "deltas": {},
  "llm_text": { "ru": "Шипение автоматических щёток, полирующих дорожку Медеу, едва
    перекрывает гул вентиляторов. Орен Кургузый перехватывает моток меди поудобнее,
    стараясь не задеть зазубренной битой проходящего мимо робота-дворника.
    В пятнадцать ноль-ноль искусственные лампы жарят по-прежнему беспощадно…" }
}

А ещё некоторые события можно обогатить. Когда мы озвучиваем событие, берём шаблонную фразу, предоставляем дополнительный контекст и получаем текст. Если вдруг модель откажет, то можно показать шаблонный текст. Это сделано, чтобы избежать дрейфа: модель пишет по образцу шаблона, выдерживает стиль и меньше выдумывает.

К примеру, случилась драка — событие выглядит так:

{
  "type": "fight", "tick": 9,
  "params": { "loc": "farms", "mob": "rust_rat", "outcome": "win" },
  "deltas": { "credits": 14, "xp": 18 }
}

Ключ здесь fight. + params.outcomefight.win, и по нему лежит такой же пул вариантов, как у смены фермера. А LLM из того же события делает что-то типа:

Скрежет когтей по металлу опоры раздался раньше, чем Тесс Стружка успела затянуть лямку разгрузки. Грызун с облезлым загривком выскочил из тени агропульта, но быстро осел под тяжёлым ботинком, оставив на подошве бурую маслянистую полосу. Забрав из приоткрытого брюха пластиковую горсть в 14 кредитов, Тесс Стружка вытерла носок сапога о жёсткий стебель технической кукурузы.

Как игра понимает приказы

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

Вкладка «Стратегия» на телефоне: приказ игрока, поручение мира, ползунки темперамента и проценты приоритетов
Вкладка «Стратегия» на телефоне: приказ игрока, поручение мира, ползунки темперамента и проценты приоритетов

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

Настоящие приказы с прода и во что они превратились

Что написал игрок

Во что разобралось

«купить свою квартиру в Стамбуле»

{"type": "buy_home", "location": "bazaar"}

«едь в пустошь атакама»

{"type": "goto", "location": "glassfield"}

«Стать главным кодером»

{"type": "rank", "profession": "coder", "rank": 4}

«цифровой след блокады, поиск лазейки и причин»

{"type": "inquiry", "topic": "blockade", "amount": 3}

«организовать хакерскую группу»

{"type": "form_crew", "track": "gang"}

«Разведать знакомства в детройте и узнать что можно торговать отсюда. Привезти в стамбул»

{"type": "courier", "location": "detroit", "drop": "bazaar", "track": "contraband", "amount": 600}

Модель рассказывала о том, чего уже нет

Были и проблемы с генерацией. Иногда в текст попадали несуществующие детали, противоречащие окружению, иногда странно трактовались приказы. Постепенно переделываю по отзывам и сам смотрю ленты.

К примеру, была жалоба от игрока. В его ленте герой «нащупывал среди двадцати девяти кредитов острый край детали из Атакамы», но нюанс в том, что деталь продали три дня назад, в кассе лежало пятьсот тридцать шесть кредитов, а рана, о которой шла речь дальше, давно зажила. Модель ничего не выдумывала - в промпт ехал свободный пересказ последних дней героя, и она честно читала его как «прямо сейчас».

Попросил проверить Claude — оказалось, деньги упоминала почти половина таких пересказов, а каждый четвёртый текст за неделю утверждал что-то про «в кармане» или «при себе». Исправил не запретами в промпте, а тем, что модель вообще перестаёт получать пересказ. Вместо него в промпт едет досье — факты, посчитанные из событий дня на час записи: деньги вилкой («денег на пару дней»), цел или ранен, что при себе, чем занят.

Как научился не сильно бояться при деплое

Чтобы не нарваться на неприятности при деплое, добавлен preflight. Он снимает дамп прод-базы, поднимает локально Postgres, применяет миграции. После этого прогоняется ночная джоба, поднимается сервер, по нему проходит смоук-тест (путь от лендинга до нового героя, отображение всех вкладок) и проверяются логи сервера. Со смоуком, кстати, был забавный момент: фильтр ботов посчитал голый HTTP-клиент на Go краулером, и онбординг отвечал редиректом. Пришлось тесту начать представляться браузером. Пока работает - за две недели и три десятка выкаток сервер ни разу не упал))

Дорогая модель не значит лучшая

Сначала хотел генерировать прозу моделью получше, выбрал Gemini Pro. На прозе он был неплох, но той же моделью разбирались и приказы игроков — и вот там начались сюрпризы: навороченная думающая модель для некоторых кейсов оказалась хуже версии попроще. К примеру, после разбора приказа модель обязана отдать JSON. Из двадцати одного настоящего приказа Pro четыре раза вернул оборванный JSON. В итоге приходилось делать ретраи и опять платить за дорогие токены — а токенов у думающей модели уходит в десятки раз больше, чем она пишет.

Чтобы проверить, попросил сделать бенчмарк восьми моделей, включая китайские: гонял на настоящих промптах, сравнивал цену, поломки и просто читал глазами.

Результаты прогона: 21 приказ × 8 моделей

Модель

Сломалось из 21

Цена прогона

Gemini 2.5 Flash Lite

6

$0.003

Gemini 3.1 Pro

4

$0.226

Gemini 3 Flash (рабочая)

2

$0.026

GLM-5

1

$0.024

DeepSeek V4 Flash

0

$0.003

DeepSeek V4 Pro

0

$0.015

Qwen 3.6 Flash

0

$0.008

Gemini 3.1 Flash Lite

0

$0.012

Как видно, дешёвые DeepSeek и Qwen не сломались ни разу, а прогон у них стоил от трети цента до полутора — против двадцати трёх у Pro. Досталось и моей рабочей flash: два обрыва. А самая дешёвая модель в таблице сломалась чаще всех, так что линейной зависимости тут просто нет. Оговорюсь: выборка маленькая, и мерил я не качество моделей вообще, а их поведение на моём конкретном промпте — но мне и нужно было только это. Решил пока использовать flash. Сутки генерации на Pro обходились примерно в два доллара, а сейчас сорок центов. Так что самая дорогая модель не всегда лучшая, надо бенчмаркать на своих данных и случаях.

Стек

В вебе я не силён, выбрал Go, посчитал, что удобно будет деплоить один бинарник и кода написано достаточно, чтобы модель достаточно хорошо на нем писала. Навороченный фронтенд сначала решил не тащить, ограничиться server-side rendering.

Попросил Claude написать эти буквы

- Приложение: Go без фреймворков (stdlib HTTP), Postgres 17 (pgx напрямую, goose-миграции), один бинарник с embed (шаблоны, статика, миграции), серверный рендер + ванильный JS, ICU-шаблоны, ru/en, PWA + Web Push.
- Инфра: VPS Hetzner, Debian, systemd, Caddy, Docker только под Postgres. Деплой — make deploy: тесты → репетиция деплоя на копии прод-БД (дамп → одноразовый Postgres → миграции → ночная джоба → смоук по HTTP как браузер, включая вкладки реальных игроков) → rsync → рестарт → healthcheck с откатом.
- AI в игре: Gemini flash (основная) + flash-lite (резерв); проза — ночным батчем, разбор приказов — по требованию; ретраи, фолбэк на шаблоны.
- AI в разработке: Claude Code — Fable (архитектура, ядро) и Opus; AGENTS.md — накопленная память проекта: инварианты, ловушки, карта пакетов и правила «куда что класть»; /task — конвейер задачи одной командой.

Сколько всего получилось кода

Что

Сколько

Go, без тестов

~84 000 строк, из них ~17 500 — контент: тексты событий, имена, лор

тесты

~32 000 строк, ~940 тестовых функций

файлов Go / пакетов

597 / 37

миграций базы

61

шаблоны и статика

77 файлов, ~2000 строк ванильного JS

коммитов

649

Кто пришёл играть

После того как сервер начал отдавать игру, раздал ссылку друзьям и попросил привлечь знакомых. Смотрел, какие миссии люди пытаются создавать, как крутят героев. Есть несколько очень вовлечённых игроков, которые дают регулярный фидбек, спасибо им большое! Всего люди завели 45 героев, сейчас, спустя две недели, осталось около десятка активных.

Расходы

Сервер — $6.49 в месяц на Hetzner, генерация около сорока центов в день. При тесте выяснилось, что у некоторых провайдеров в РФ Hetzner работает плохо и страницы грузятся долго. Что с этим делать, пока ещё не решил: увеличивать расходы ещё и на аренду сервера в РФ и городить роут через GeoIP не хочется. Также это ещё один узел, который надо обслуживать и за работой которого надо следить.

Выводы и что дальше

Что для себя вынес за эти две недели? Детерминированная основа плюс модель поверх показали себя в моём случае неплохо. Дорогая модель — не значит лучшая. Поддерживаемый код писать получается, главное, чтобы тестами был покрыт функционал, существовал md-файл со списком проблем, чтобы их не воспроизводила модель. Ревью в субагенте с чистым контекстом тоже себя оправдывает.

Но за моделями нужен глаз да глаз, могут что-то отломать, согласиться с плохой идеей. Ну и идеи генерировать пока не способны, надо им дать хоть людского промпта вначале)

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

Комментарии (1)


  1. maslovvasili
    31.07.2026 08:19

    По сути получается симуляция с генератором текста, где основная борьба идёт не с игровыми механиками, а с тем, чтобы нейросеть не начинала придумывать лишнее.