Откуда всё началось

Несколько лет я играл в Pokémon GO. Дело было не столько в покемонах, сколько в прогулках: выходишь вечером «на пять минут», а возвращаешься через два часа, пройдя десять километров. Во дворах собирались люди, ходили на рейды, знакомились, обсуждали, где что появилось.

Потом игра ушла из России, и всё это пропало. Местные чаты затихли, а привычка гулять осталась без повода. Я всё думал: как снова собрать людей и выходить вместе на улицу так, чтобы это было не «надо пройтись», а «пойдём, там Жар-птица».

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

Что изменилось с ИИ

Когда появились ИИ-агенты, которые не просто подсказывают строчку кода, а сами пишут, запускают, проверяют и исправляют, у меня в голове что-то щёлкнуло. Возможности стали почти безграничными. Человек без навыков программирования и дизайна может собрать своё приложение и воплотить свою мечту примерно за 5% тех сил и денег, которые ушли бы раньше.

Расклад ролей у нас простой. Я — автор идеи, геймдизайнер и «приёмщик»: говорю словами, что хочу, смотрю результат, хожу с телефоном по улице и ругаюсь, если неудобно. Claude Code пишет весь код, сам запускает тесты и браузер, рисует картинки через нейросети на моём компьютере и выкладывает игру на сервер.

Несколько правил, без которых ничего бы не вышло:

  • Документы вместо памяти. У ИИ нет памяти между сессиями, поэтому всё записываем: план по фазам, дизайн игры (GDD) и журнал решений «что решили и почему». Сейчас в журнале почти 600 строк.

  • Стек под ИИ, а не под человека. Сначала думали про Unity или Godot. Но там много работы мышкой в редакторе, а ИИ удобнее, когда всё — текстовые файлы, которые он может сам запустить и проверить. Поэтому TypeScript везде: Three.js в браузере, Fastify на сервере, общий пакет с правилами игры.

  • Фазы и приёмка. Каждая фаза — своя ветка в git. В основную ветку попадает только то, что я принял.

  • Одно слово — один ритуал. «Выкладывай» значит: запись в историю изменений → тесты → коммит → проверка, что в коммит не попали секреты → выкладка на сервер → проверка сайта.

  • Выбор — за человеком. Когда нужно выбрать картинку, ИИ собирает HTML-страницу с вариантами и галочками. Я отмечаю, пишу правки и копирую ему текст. Сам он варианты не отбирает.

Для масштаба: от первого коммита до нынешней версии прошло шесть дней и больше 140 коммитов. В игре крупные города России, 90 видов духов, идолы, торговец, недельный пропуск, рейтинги, вход через Telegram и приложение для Android.

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

Карта: «я не узнаю свой район»

Первую карту ИИ сделал «по учебнику»: плоские клетки по 8–10 метров, раскрашенные по данным OpenStreetMap. Технически всё работало, но я свой двор на ней не узнал. Я показал скриншот любимой гео-игры: наклонная камера, тонкие дороги, невысокие блоки домов.

Пришлось менять всё. Phaser (2D) уступил место Three.js с наклонной камерой. Вместо клеток — векторные тайлы: дороги стали линиями своей ширины, дома — своей формы. Затем появилась новая беда: трава «переливалась» и рябила при движении, а дома шли «лесенкой» из пикселей. Решили так: землю рисуем ровными заливками, а лёгкие пятна травы считает видеокарта прямо в шейдере. Пиксель-арт остался только у персонажей и духов.

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

Духи: от каши из пикселей к живым существам

Это была самая долгая и самая интересная часть. Сразу скажу: художника у меня нет. Всё нарисовано нейросетями на моём ноутбуке (RTX 4060, 8 ГБ) через ComfyUI, а ИИ-агент управляет ComfyUI напрямую, по его API.

Попытка 1: SDXL и скрипт «в пиксели»

Первые восемь духов нарисовала обычная SDXL, а скрипт уменьшал картинку до 40–60 пикселей, сводил цвета к палитре и обводил контуром. Для первой пробы получилось неплохо, но проблемы полезли сразу:

  • на маленьком размере детали превращаются в кашу: глаза теряются, лапы сливаются;

  • фон вырезался не всегда: у Ауки так и остался белый квадрат;

  • нейросеть то рисовала лист с десятком фигурок вместо одной, то упорно превращала духа в «человечка»;

  • иконки предметов на 32 пикселях были совсем беда: руны выходили узорными «мандалами», а «красная нить» превращалась в красные цветы. Иконки ИИ в итоге нарисовал кодом, пиксель в пиксель.

Первые восемь духов: SDXL + автоматический перевод в пиксели. У Ауки остался фон
Первые восемь духов: SDXL + автоматический перевод в пиксели. У Ауки остался фон

Попытка 2: духи, нарисованные кодом

Тогда я попробовал радикальный путь: пусть ИИ рисует духов программой. Из «заготовок» тела (зверь, птица, рыба, змей, жук, колобок, лягушка) и деталей (шерсть штрихами, перья «чешуйками», глаза с бликами) код собирал спрайт и сам его анимировал: Колобок скачет, Баюн водит хвостом, Жар-птица машет крылом. За один день в игре оказались все сто духов.

Потом ИИ перерисовал каждого духа кодом «точь-в-точь» по удачным эталонам на 48 пикселей, с эволюциями и Светлыми версиями. Стало чище, но характера всё равно не хватало: духи были похожи на клипарт.

Попытка 3: конвейер с человеком в середине

В итоге сложился конвейер, который мы записали в арт-гайд как правило:

  • 6 вариантов каждого духа в ComfyUI: SDXL + LoRA Pixel Art XL, единый стиль и описание облика. В негативный промпт добавили human, humanoid, clothes, чтобы духи не превращались в людей;

  • выбор на HTML-странице: я отмечаю вариант и пишу правки («глаза у Лисы как во втором варианте», «крыло сложить вдоль тела»);

  • правки словами через Qwen-Image-Edit 2511: она меняет готовую картинку по тексту и сохраняет персонажа;

  • подробная перерисовка через img2img без пиксельной LoRA, после неё BiRefNet вырезает духа: прозрачный фон, без тени и подставки;

  • перевод в пиксели на 192 точки со своей палитрой. Для ручной доводки ИИ сделал мне простой пиксельный редактор в браузере. Мои правки хранятся отдельным списком пикселей, поэтому пересборка их не стирает;

  • оживление: дух не режется на части, а целиком оживает на видеокарте. Он дышит, моргает, шевелит ушами и хвостом, летающие парят. Разметку (где глаза, где хвост) ИИ делает сам, а я проверяю на странице просмотра.

Одни и те же духи: первая попытка → нарисован кодом → нынешний вариант
Одни и те же духи: первая попытка → нарисован кодом → нынешний вариант
Часть нынешних духов. Все дышат и моргают прямо в игре
Часть нынешних духов. Все дышат и моргают прямо в игре

Отдельно пришлось следить за мифологией:

  • человекоподобных персонажей мы не ловим: Сирин, Алконост и Гамаюн — это NPC, а не добыча;

  • Коловрат и спорные неоязыческие символы не используем, на рунах только этнографические орнаменты вышивки и резьбы;

  • девять уже нарисованных духов я в итоге убрал из игры: они не вписались.

Путники

С героем вышла похожая история. Сначала SDXL нарисовала старца-странника. Потом ИИ нарисовал кодом шесть обликов (с лицом, поясом с кистями, лаптями). В итоге остались два героя, Друид и Ведунья, которых я принёс в виде гифок, а скрипт перевёл их в пиксели по 30 кадров.

Старец от SDXL → Друид в игре → подробная перерисовка Qwen-Image-Edit
Старец от SDXL → Друид в игре → подробная перерисовка Qwen-Image-Edit

Прямо сейчас учим путников поворачиваться во все восемь сторон. Qwen-Image-Edit с LoRA «multiple angles» поворачивает подробного Друида, а дальше из этих кадров соберём ходьбу. Пока нейросеть то и дело дорисовывает вокруг героя лес и каменные плиты, так что работа ещё идёт.

Друид с восьми сторон (черновик): фон нейросеть добавляет сама, его ещё придётся убрать
Друид с восьми сторон (черновик): фон нейросеть добавляет сама, его ещё придётся убрать

Бросок камня: шесть переделок за пять дней

В Pokémon GO ловят покемонов шаром. У нас духов удерживают обережным камнем: Куриный бог, Громовая стрела, Яхонт и Алатырь-камень. Духа можно задобрить требой: молоком, хлебом-солью, мёдом, лентой.

Сначала я хотел, чтобы обычных духов ловили черчением узоров. Но первая же прогулка показала: на ходу сосредоточиться на черчении трудно, и ловля простых духов начинает раздражать. Черчение оставили для легендарных духов и боссов, а обычных стали ловить броском. Дальше началась эпопея.

  • «Рывок в конце». На телефоне палец при отпускании дёргается вбок, а сила считалась по последним ~90 мс. Случайный рывок уводил камень мимо. Теперь короткие отрезки «не вверх» в последний момент отбрасываются, а направление не может уйти от общего взмаха больше чем на 20°.

  • Подкрут «как в Pokémon GO». Раскручиваешь палец вокруг камня, и бросок уходит дугой. Сделали дугу, потом настоящую боковую силу от вращения (эффект Магнуса), потом ограничение угла. На улице это не работало: игроки не понимали, куда полетит камень. Один коммит пришлось откатить целиком, а потом подкрут убрали совсем.

  • «Управляемая баллистика». Я показал ChatGPT, что не нравится, и принёс в проект его разбор в PDF. Идея была такая: жест прямо задаёт бросок без «настоящей физики». Куда махнул, туда и летит, а сила зависит от длины и скорости взмаха. Стало лучше, но всё равно получался «Pokémon GO, только хуже».

  • Рогатка. Это финальное решение. Камень лежит в развилке. Берёшь его, оттягиваешь вниз, к себе, и отпускаешь. Сила зависит от натяжения, направление обратно натяжению: тянешь вправо — камень летит влево. Такую механику понимает любой, кто в детстве держал рогатку, и она перестала быть копией чужой игры.

Слева рогатка натянута. Справа режим отладки: будущая траектория.
Слева рогатка натянута. Справа режим отладки: будущая траектория.

У рогатки тоже были свои грабли:

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

  • Непонятно, куда целиться. Сначала зоной попадания был прямоугольник вокруг спрайта. Теперь это белый круг-оберег вокруг духа. Мимо круга — «Молоко!».

  • Промах «таял» в воздухе. Это выглядело дёшево. Теперь камень долетает по той же дуге до земли перед духом или за его спиной, катится и только потом исчезает.

  • Слишком легко. Поэтому я добавил уворот духа в момент поимки: время от времени он перебегает к краю экрана и стоит там от 7 до 30 секунд. Камень летит не мгновенно, поэтому целиться нужно туда, где дух будет.

  • Сила под экран. Натяжение подобрано так, что 45% силы приносит камень к ногам духа, а 75% — к макушке. Маленькие духи стоят ближе, большие дальше, и кольцо вокруг дальнего меньше.

Чтобы не гадать, ИИ сделал режим отладки (только у меня на компьютере): на экране видна будущая траектория и нужная сила, а каждый бросок пишется в журнал. По журналу видно, какие броски «по ощущениям попал» на самом деле были недолётами.

Отдельно про права. Механики авторским правом не защищены, но у Nintendo и The Pokémon Company есть патенты, связанные с поимкой (вспомним иск к Palworld в 2024 году). Я сознательно не копирую Pokémon GO один в один. Рогатка — хороший пример того, как ограничение подтолкнуло к своему решению.

Честная игра: сервер решает всё

Первые тестеры играли, пока сервер просто хранил сохранение целиком. Так быстрее начать, но «подкрутить» себе прогресс было несложно. Поэтому следующим шагом ИИ переделал всё по принципу «сервер главный»:

  • клиент присылает только действие («бросил камень», «поклонился идолу») и своё место, а сервер проверяет и считает награду;

  • вся случайность (где появится дух, его статы, исход броска) считается от ключей, подписанных секретом сервера (HMAC). Заранее её не вычислить, перезаходом не перебросить;

  • духи, идолы и торговец Офеня не хранятся в базе: это детерминированные функции от координат и времени. Поэтому у всех игроков рядом один и тот же дух, а статы у каждого свои;

  • правила и числа баланса лежат в общем пакете, его используют и клиент, и сервер. Расхождений нет.

GPS, Telegram и прочие сюрпризы реального мира

  • Казань в Челябинске. Игрок из Челябинска получил «Вы перемещаетесь слишком быстро, подождите 255 минут». Телефон на минуту определил место по вышкам и выдал Казань в 780 км. Теперь вместе с местом уходит точность, и грубые точки (хуже 150 м) не считаются перемещением.

  • Не бан, а «духи попрятались». Едешь быстрее ~32 км/ч — духи прячутся и возвращаются через 45 секунд после того, как пошёл пешком. Без наказаний: GPS иногда прыгает у всех.

  • Свёрнутая игра. Поиграл дома, свернул, прошёл километр, открыл — «слишком быстро». Проснувшаяся игра отправляла старое место с новым временем. Теперь старые точки отбрасываются.

  • Telegram из России. Вход сделан через Telegram, но сервер в РФ не может достучаться до api.telegram.org. Помог посредник на Cloudflare Workers. Вебхуки до сервера тоже не доходили, поэтому сервер сам раз в ~25 секунд спрашивает бота о новых сообщениях.

  • Приложение. APK — та же веб-игра в обёртке Capacitor, его собирает GitHub Actions. Сначала забыли про постоянную подпись: каждая сборка подписывалась новым временным ключом, и обновиться поверх было нельзя.

Что ИИ не сделает за вас

Звучит так, будто всё делается само. Это не так. ИИ пишет код быстрее, чем я мог бы мечтать, но:

  • решать приходится вам. Сто раз в день: какой вариант духа, сколько опыта за поимку, бан или «попрятались»;

  • вкус и ощущения не проверить тестами. Что бросок «бесит», я узнал только на улице, с телефоном в руке;

  • нужна дисциплина. Документы, журнал решений, ветки, приёмка. Без них через три дня никто не помнит, почему сделано именно так;

  • безопасность — ваша ответственность. Перед каждым коммитом скрипт проверяет, что туда не попали адреса, ключи и пароли. Об этом лучше подумать в первый день, а не после утечки.

Попробуйте сами

Если интересно посмотреть вживую, игра здесь: mifozov.com. Mifozov уже работает в крупных городах России. Игра бесплатная и без рекламы. Вход — через Telegram.

А если у вас тоже есть мечта, которую вы откладывали, потому что «не умею программировать», — кажется, сейчас лучшее время её достать.

Как это устроено внутри

Для тех, кому интересно, что под капотом. Весь код ниже — настоящий, из проекта, только без лишних деталей.

Структура проекта

Монорепозиторий на npm workspaces, везде TypeScript в строгом режиме:

  • apps/client — игра: Three.js для карты и встречи с духом, интерфейс на обычном HTML/CSS, сборка Vite;

  • apps/server — Fastify и PostgreSQL: вход через Telegram, сохранения, все игровые решения;

  • packages/shared — общий пакет: правила, формулы, таблица баланса, список духов. Его импортируют и клиент, и сервер, поэтому они физически не могут «посчитать по-разному»;

  • tools/geo — сборка карты: выгрузка OpenStreetMap (Geofabrik) → свои векторные тайлы → Three.js;

  • tools/art — скрипты на Python, которые управляют ComfyUI.

Приложение для Android — та же веб-игра в обёртке Capacitor. Его собирает GitHub Actions, сервер работает в Docker. Все числа баланса (шансы, опыт, цены) живут в одном файле balance.ts, а не разбросаны по коду. Это правило записано в инструкции для ИИ, и он его соблюдает.

Духи без базы данных

Где и когда появится дух, нигде не хранится. Город поделён на ячейки около 40 м, время — на окна по 15 минут. Появится ли дух в ячейке, считается из хеша «ячейка + окно», подписанного секретом сервера:

export function spawnInCell(cx: number, cy: number, window: number, keyOf: KeyOf): SpawnPoint | null {
  const cfg = BALANCE.spawn;
  const rnd = rngFrom(keyOf(`spawn:${cx}:${cy}:${window}`)); // keyOf = HMAC-SHA256 с секретом сервера
  if (rnd() >= cfg.chance) return null;                      // в этой ячейке сейчас никого
  const step = cfg.cellMeters / M_PER_DEG;
  const lonStep = step / Math.cos((cy + 0.5) * step * (Math.PI / 180));
  // не у самого края ячейки — чтобы духи соседних ячеек не слипались
  const lat = (cy + 0.15 + 0.7 * rnd()) * step;
  const lon = (cx + 0.15 + 0.7 * rnd()) * lonStep;
  return { id: `${cx}:${cy}:${window}`, lat, lon, roll: rnd(), until: (window + 1) * cfg.windowMin * 60_000 };
}

Что это даёт:

  • у всех игроков рядом один и тот же дух в одном и том же месте, а в базе для этого нет ни одной строки;

  • без секрета будущие спавны не вычислить, даже если прочитать код клиента;

  • уровень, статы и «Светлость» духа считаются так же, но из ключа «игрок + спавн». Поэтому дух общий, а характеристики у каждого игрока свои, и перезаход их не перебросит;

  • исход броска — тоже из ключа «игрок + спавн + номер броска». Обновить страницу, чтобы «перебросить», бесполезно.

Так же устроены деревья на карте (хеш координат) и маршрут торговца Офени (хеш от района и часа).

Рогатка: физика без физического движка

Сцена встречи — простая перспектива: x — вбок, y — высота, z — глубина (0 — рука игрока, 1 — дух). Натяжение считается от точки, где палец взял камень, и ограничивается пределом:

export function pullStone(s: Sling, grab: Pt, finger: Pt): { stone: Pt; strength: number } {
  const dx = finger.x - grab.x;
  const dy = finger.y - grab.y;
  const len = Math.hypot(dx, dy);
  const k = len > s.maxPull ? s.maxPull / len : 1; // дальше предела камень не идёт
  return {
    stone: { x: s.anchor.x + dx * k, y: s.anchor.y + dy * k },
    strength: Math.min(len, s.maxPull) / s.maxPull,
  };
}

Траектория — парабола по глубине: y(z) = y0 + R·z − G·z²/p², где p — сила натяжения. Тяжесть G подбирается под конкретный экран и расстояние до духа так, чтобы сила hitFrom приносила камень к ногам духа, а hitTo — к макушке. Поэтому на любом телефоне одинаковое натяжение даёт одинаковый результат.

Самая коварная ошибка была в проверке попадания. Сначала проверяли по кадрам: в каком месте камень на этом кадре. Быстрый камень на слабом телефоне за один кадр перелетал духа целиком и «проскакивал» сквозь него. Теперь исход считается заранее, в момент выстрела, решением квадратного уравнения:

export function shotOutcome(v: View, s: Shot, stoneR = 0, spiritX = 0): Outcome {
  // Касание земли: y(z) = −stoneR, больший корень (камень летит вверх, потом падает).
  const zGround = (s.R + Math.sqrt(s.R * s.R + 4 * s.a * (s.y0 + stoneR))) / (2 * s.a);
  const ground = zGround / s.vz;
  if (zGround < 1) return { kind: 'short', t: ground, ground }; // упал раньше духа — недолёт
  const t = 1 / s.vz;                                           // момент, когда камень на глубине духа
  const p = shotAt(s, t);
  const c = hitCircle(v);
  const dx = p.x - spiritX;                                     // дух мог отбежать, пока летел камень
  if (Math.hypot(dx, p.y - c.y) <= c.r) return { kind: 'hit', t, ground };
  return { kind: p.y > c.y && Math.abs(dx) <= c.r ? 'over' : 'wide', t, ground };
}

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

С уворачиванием та же идея. Путь духа (к левому краю, к правому, в середину) — случайный, но генерируется один раз на встречу. Поэтому в момент выстрела можно спросить, где дух окажется через 600 мс, когда долетит камень.

Сервер решает всё

Клиент не меняет сохранение сам. Он отправляет действие и своё место, сервер проверяет и возвращает новое сохранение:

POST /api/act
{ "action": { "type": "throw", "spawnId": "…", "stone": "stone_kurinyy", "quality": "nice" },
  "pos": { "lat": 55.75, "lon": 37.61, "acc": 12 } }

Сервер проверяет, начата ли встреча, есть ли такой камень, не «едет» ли игрок (скорость считается по соседним точкам), достаточно ли точный GPS, и только потом бросает кубик. Действия одного игрока выполняются строго по очереди, поэтому два телефона одного игрока не перезапишут сохранение друг друга.

Как ИИ рисует через ComfyUI

ComfyUI умеет принимать задания по HTTP: граф узлов в виде JSON. ИИ пишет на Python скрипт, который собирает такой граф, отправляет его, ждёт картинку и сохраняет её. Например, правка картинки словами через Qwen-Image-Edit 2511 — это десяток узлов:

wf = {
  '1':  {'class_type': 'UnetLoaderGGUF', 'inputs': {'unet_name': 'qwen-image-edit-2511-Q4_K_M.gguf'}},
  '2':  {'class_type': 'LoraLoaderModelOnly',  # ускорение: 4 шага вместо 40
         'inputs': {'model': ['1', 0], 'lora_name': 'Qwen-Image-Edit-2511-Lightning-4steps-V1.0-bf16.safetensors', 'strength_model': 1.0}},
  '5':  {'class_type': 'LoadImage', 'inputs': {'image': image}},
  '7':  {'class_type': 'TextEncodeQwenImageEditPlus',
         'inputs': {'clip': ['3', 0], 'prompt': prompt, 'vae': ['4', 0], 'image1': ['6', 0]}},
  '13': {'class_type': 'KSampler', 'inputs': {'model': ['12', 0], 'positive': ['9', 0], 'negative': ['10', 0],
         'latent_image': ['11', 0], 'seed': seed, 'steps': 4, 'cfg': 1.0, 'sampler_name': 'euler', 'scheduler': 'simple'}},
  # … загрузка текстовой модели и VAE, масштаб, декодирование, сохранение
}

Зерно (seed) считается из имени духа и номера варианта. Поэтому любой вариант можно пересоздать один в один, а в RTX 4060 с 8 ГБ модель влезает в квантованном виде GGUF (Q4). Одна картинка — две-три минуты.

Отдельная история — как показать человеку десятки вариантов. ИИ после каждой партии собирает HTML-страницу: картинки, галочки «беру», поле для правок и кнопку «скопировать ответ». Я отмечаю и вставляю ему текст обратно в чат. Это оказалось удобнее любых «умных» интерфейсов.

Проверки перед каждой выкладкой

  • около 180 автотестов (Vitest): правила поимки, спавны, рогатка, вход, метрика. Пишет их тот же ИИ, вместе с кодом;

  • проверка типов TypeScript во всём монорепозитории;

  • перед коммитом скрипт ищет в изменениях запрещённые строки — адреса, ключи, пароли. Сам список запрещённых строк лежит вне репозитория, иначе он сам стал бы утечкой;

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