Несколько лет я в одиночку пишу сервер для своей 2D MMO RPG. Эта часть — про то, как изменился сам процесс разработки: игровую фичу я по-прежнему придумываю сам, а реализую её уже не один.
Это не демо в духе «модель выдала сниппет». Внутри — настоящая 2D MMO RPG: авторитарный сервер реального времени, тайловые карты, клиент на Unity. ИИ не создал эту систему, а ускорил: то, что раньше занимало дни и недели, теперь укладывается в часы и дни, и в одиночку я держу темп целой команды. Расскажу по порядку, как я к этому пришёл и где у подхода честная граница.

Сначала я переписал всё на Symfony — и это было решение в пользу ИИ
Мой сервер начинался как самописный PHP. Он работал, держал бой в реальном времени, но был «мой» в худшем смысле слова: ни один справочник, ни одна статья, ни одна модель не знали, как он устроен. Любому новому участнику разработки — человеку или машине — пришлось бы изучать его с нуля.
Поэтому я переписал серверную часть на Symfony — популярный фреймворк для PHP, на котором построен проект. Уложился в два месяца; без ИИ ушло бы полгода. Переписывал ради ИИ и ради будущей команды — на знакомом многим фреймворке проще подключить разработчиков. Не ради моды.
Современная модель обучена на гигантских объёмах кода, документации и готовых продуктов на распространённых фреймворках. Symfony она «знает» — её паттерны, жизненный цикл, способ раскладывать код по местам. Когда я прошу что-то изменить в проекте на Symfony, модель опирается не на мои объяснения, а на то, что у неё и так в голове. Меньше контекста на вход, меньше разночтений, точнее результат. Самописный легаси требовал разжёвывать каждую мелочь. Знакомый фреймворк превратил ИИ из стажёра, которого надо вводить в курс, в инженера, уже работавшего с таким стеком сотни раз.
При переписывании я сохранил бандловую структуру: каждая крупная часть системы — отдельный самостоятельный модуль со своими миграциями базы, таблицами, конфигами, шаблонами и чёткой зоной ответственности. Это не косметика, а то, на чём держится масштаб (к этому вернусь ниже).
Потом ИИ получил руки — и в сервере, и в клиенте
Переписать сервер — половина дела. Игра живёт на двух берегах: сервер считает правду, клиент на Unity её показывает. Пока ИИ умеет править только сервер, я всё равно остаюсь узким горлышком на стороне клиента.
Поэтому я построил MCP-мост в обе стороны.
С одной стороны — мой собственный MCP-сервер. Это мой продукт. Через него ИИ делает почти всё, что геймдизайнер делает руками: создаёт и правит карты, расставляет на них врагов, меняет игровой баланс, читает логи сервера — и даже может сам подключиться к игре как обычный клиент и поиграть, чтобы проверить на ощущение то, что только что сделал.
С другой стороны — MCP-плагин для Unity. Через него ИИ работает уже внутри редактора: создаёт и правит объекты на сцене, пишет C#-скрипты, запускает игру, делает скриншоты и читает консоль. (Плагин планирую оформить отдельно — будет в сторе.)
Соединив оба моста, я отдал ИИ обе руки. Теперь одна и та же задача доводится до конца сквозь оба берега: правило — на сервере, его отображение и эффекты — в клиенте, а между ними не появляется шва, который раньше я сшивал вручную.
Как фича доходит до игры
Над этим мостом я собрал свой слой: набор ИИ-агентов и skill’ов, заточенных под мой проект. Один отвечает за серверные механики, другой — за клиент на Unity, третий проверяет работу, и так далее. Каждый знает свою зону и свои правила. Я не пишу промт на каждую мелочь — я описываю фичу, а слой сам раскладывает её по исполнителям.
Тестирует тоже ИИ. Не просто читает код — поднимает сервер, шлёт команды как настоящий клиент, сверяет ответы и ловит регрессии. Держится всё это на знаниях: правила проекта — как устроены механики, что можно и чего нельзя — живут в skill’ах, которые агенты подхватывают под задачу. Поэтому даже такой сложный продукт — авторитарный сервер реального времени плюс клиент на Unity — остаётся поддерживаемым силами ИИ, а не разваливается под собственным весом.
Свежий пример — инвентарь с экипировкой: ячейки под предметы, слоты под надетое, перетаскивание. Из одного описания выросла законченная цепочка через оба берега:
-
через mcp ИИ создает справочник — инвентарь и экипировка, назначается на Игрока и Врагов. Пишется простой код контроля слотов и дропа что не влезло;
-
сервер держит предметы и надетое там же, где вся правда мира, и сам решает, можно ли надеть вещь в этот слот;
-
клиент на Unity рисует окно инвентаря и куклу персонажа с перетаскиванием;
-
тест прогоняет всё это: команды уходят на сервер, ответы сверяются — фича работает, а не «выглядит работающей».
Код справочника Инвентарь
<?php# Этот код и при создании существ проверит значение компонента и далее из события что будет меняться.# Формат: {slot_idx => {prefab, count, components?} | null}. Партиальные апдейты: отсутствующий# в $value ключ слота остаётся как в $current; null = слот очищается; non-null = слот заменяется./** @var array $value входящее значение, прокинуто closure-обёрткой ComponentCollection::trigger */$component = ComponentCollection::list()['inventory'];if($component->maxCompareLevel!=2)throw new Error('Компонент inventory не должен иметь в настройках предела уровня анализа уникальных данных для Рассылки кроме как 2');$current = $object->components->get('inventory');$slotCount = count($component->default);// Один проход: строим $inventory[$i], считаем тотал по prefab+components (для drop-в-мир)// и identity-ключи $currentIds/$inventoryIds (для $slotMap ниже). json_encode используется// как идентификационный ключ — components приходит из json_decode(assoc=true), порядок// ключей стабилен между сохранениями, md5 не нужен (длина строкового ключа массива не лимитирована).$inventory = [];$oldTotals = [];$oldPrefabs = [];$oldComponents = [];$newTotals = [];$currentIds = [];$inventoryIds = [];for($i=1;$i<=$slotCount;$i++){if(!empty($value[$i])){$allowed = ['prefab'=>1, 'count'=>1, 'components'=>1];if(array_diff_key($value[$i], $allowed))throw new Error('Слот '.$i.' содержит лишние поля: '.implode(', ', array_keys(array_diff_key($value[$i], $allowed))));$required = ['prefab'=>1, 'count'=>1];$missing = array_diff_key($required, $value[$i]);if($missing)throw new Error('Слот '.$i.' отсутствуют обязательные поля: '.implode(', ', array_keys($missing)));$empty = array_keys(array_diff_key(array_intersect_key($value[$i], $required), array_filter($value[$i])));if($empty)throw new Error('Слот '.$i.' содержит пустые поля: '.implode(', ', $empty));if(!World::prefabExists('item', $value[$i]['prefab']))throw new Error('Слот '.$i.' содержит несуществующий prefab "'.$value[$i]['prefab'].'" (kind=object, доступные: '.implode(', ', array_keys(World::prefabList('item'))).')');if(!empty($value[$i]['components']) && isset($value[$i]['components']['count']))throw new Error('Слот '.$i.' содержит count в components — count является отдельным полем слота');$slot = ['prefab'=>$value[$i]['prefab'], 'count'=>$value[$i]['count']];if(!empty($value[$i]['components']))$slot['components'] = $value[$i]['components'];$inventory[$i] = $slot;}elseif(array_key_exists($i, $value))$inventory[$i] = null;elseif(!empty($current[$i])){$slot = ['prefab'=>$current[$i]['prefab'], 'count'=>$current[$i]['count']];if(!empty($current[$i]['components']))$slot['components'] = $current[$i]['components'];$inventory[$i] = $slot;}else$inventory[$i] = null;if(!empty($current[$i])){$c = $current[$i]['components'] ?? null;$key = $current[$i]['prefab'].'-'.(!empty($c) ? json_encode($c) : '');$currentIds[$i] = $key;$oldTotals[$key] = ($oldTotals[$key] ?? 0) + $current[$i]['count'];$oldPrefabs[$key] = $current[$i]['prefab'];if(!empty($c))$oldComponents[$key] = $c;}if(!empty($inventory[$i])){$c = $inventory[$i]['components'] ?? null;$key = $inventory[$i]['prefab'].'-'.(!empty($c) ? json_encode($c) : '');$inventoryIds[$i] = $key;$newTotals[$key] = ($newTotals[$key] ?? 0) + $inventory[$i]['count'];}}// Дропаем на землю положительную разницу oldTotals - newTotals (реальная потеря).// Нулевая или отрицательная разница = swap/merge внутри инвентаря или partial-pickup —// дропать нечего. Подход не зависит от порядка обхода и иммунен к багам с накоплением балансов.foreach($oldTotals as $key => $oldCount){$diff = $oldCount - ($newTotals[$key] ?? 0);if($diff <= 0)continue;$entityData = ['count' => $diff];if(isset($oldComponents[$key]))$entityData += $oldComponents[$key];World::add(['prefab' => $oldPrefabs[$key],'map' => MAP_ID,'x' => $object->position->x,'y' => $object->position->y,'z' => $object->position->z,'components' => $entityData,], 'item');}// Карта: старый_слот → новый_слот (куда переехал предмет в инвентаре).// Строим один раз — переиспользуется для каскадов в actionbars и equip (оба хранят ссылки// на инвентарные слоты по номеру). Сравнение по identity-ключу: только реальная смена// предмета в слоте триггерит поиск — изменение одного count в том же слоте не считается «переездом».$slotMap = [];foreach($currentIds as $i => $oldKey){if($oldKey === ($inventoryIds[$i] ?? null))continue;foreach($inventoryIds as $j => $newKey){if($j !== $i && $newKey === $oldKey){$slotMap[$i] = $j;break;}}}// обновить ссылки в actionbars (там хранится номер слота инвентаря)// из триггера компонента нельзя менять компоненты напрямую, поэтому вешаем событие// actionbars разрешён только player'ам — на enemy/прочих kind компонент не присвоен, get кинет ошибкуif($object->components->isset('actionbars')){$actionbars = $object->components->get('actionbars');// отправляем только изменённые ячейки actionbars$updates = [];foreach($actionbars as $barNum => $bar){if(!empty($bar) && $bar['kind'] == 'item'){$oldSlot = (int)$bar['id'];if(isset($slotMap[$oldSlot]))$updates[$barNum] = ['kind' => 'item', 'id' => $slotMap[$oldSlot]];elseif(empty($inventory[$oldSlot]))$updates[$barNum] = null;}}if($updates)$object->events->add('ui/actionbars', 'index', ['actionbars' => $updates]);}// аналогичный каскад для equip — переезд инвентарного слота меняет idx,// drop из инвентаря снимает экипировку (idx → null)// equip без default — на сущностях без экипировки компонент в values отсутствуетif($object->components->isset('equip')){$equip = $object->components->get('equip');$updates = [];foreach($equip as $slot => $idx){if(isset($slotMap[$idx]))$updates[$slot] = $slotMap[$idx];elseif(empty($inventory[$idx]))$updates[$slot] = null;}if($updates)$object->events->add('ui/equip', 'index', ['items' => $updates]);}$value = $inventory;

Код справочника Экипировка
<?php# Этот код и при создании существ проверит значение компонента и далее из события что будет меняться.# Формат: {slot_slug => inventory_idx}. Партиальные апдейты: ключ со значением null = unequip slot.$component = ComponentCollection::list()['equip'];if($component->maxCompareLevel != 2)throw new Error('Компонент equip не должен иметь maxCompareLevel отличный от 2');$current = $object->components->get('equip') ?: [];$inventory = $object->components->get('inventory');$result = $current;foreach($value as $slot => $idx){// явный null — снимаем slot с тела. Кладём null (не unset), чтобы delta через// Data::compare_recursive затёрла старый idx в private_world WebSocket-сервера.// unset убирает ключ только из памяти sandbox: при max_compare_level=2 отсутствующий// в setChanges ключ читается как no-op и старый idx остаётся в кеше до следующей// перезаписи именно этого слота. При player_add equip приезжает со stale idx,// ссылающимся на уже пустой inventory_idx — валидатор ниже бросает ошибку.if($idx === null){$result[$slot] = null;continue;}// slot должен быть в игровом справочнике (World::equipmentSlotList — game.equipment_slot)if(!World::equipmentSlotExists($slot))throw new Error('Slot экипировки "'.$slot.'" не разрешён в этой игре (доступные: '.implode(', ', array_keys(World::equipmentSlotList())).')');// item должен быть в инвентаре по этому номеруif(empty($inventory[$idx]))throw new Error('Слот инвентаря '.$idx.' пуст — нечего экипировать');$prefab = $inventory[$idx]['prefab'];// prefab.equipable_slot должен содержать целевой slot (из payload sandbox)$prefabData = World::prefabGet('item', $prefab);if(empty($prefabData['equipableSlot'][$slot]))throw new Error('Prefab "'.$prefab.'" нельзя надеть в slot "'.$slot.'" (допустимые: '.implode(', ', array_keys($prefabData['equipableSlot'] ?? [])).')');// TODO: вернуть дедуп (один item в одном slot). Сейчас отключён, чтобы каскад из inventory.php// корректно переносил все equip-ссылки на один inventory_idx (иначе все, кроме последней,// затирались тут же и оставались висеть на старом idx — phantom-equip при возврате item).$result[$slot] = $idx;}// Пустой массив = full-clear (отдельный сигнал от no-op). Клиент через// EmptyArrayAsDictionaryConverter маппит JSON `[]` → пустой Dictionary, чем// различает 3 состояния: ключ `equip` отсутствует (no-op), Count==0 (clear-all),// Count>0 (per-key delta). Per-key delta непустого dict формирует// AbstractSocket::updateRecursive по max_compare_level=2.$value = $result;

Под капотом — последняя на сегодня модель Claude, Opus от Anthropic. Я отношусь к ней как к инструменту и соавтору: она исполняет, я задаю рамки.
Где ИИ заканчивается и начинаюсь я
Здесь обычно появляется возражение: «ИИ генерит мусор». Так и есть — если отдать ему пустой холст. Мусор получается там, где нет архитектора, который заранее закрыл целые классы ошибок.
Я не прошу ИИ придумать ни игру, ни архитектуру. Фичи придумываю я — какой будет инвентарь, как работает экипировка, что делает существо в бою. Архитектуру и инварианты задаю тоже я. ИИ исполняет внутри этих рамок. Два примера, на которых это видно.
Правда живёт только на сервере. Клиент ничего не решает — он показывает то, что посчитал сервер. Это инвариант, который ИИ не вправе нарушить. Поэтому, что бы он ни написал в клиенте, у него физически нет способа породить рассинхрон или чит: клиент не источник истины, а витрина. Тот самый инвентарь это и подтверждает — клиент рисует предметы, но разрешает или запрещает надеть вещь только сервер. Целый класс багов закрыт не проверками, а архитектурой.
Сеть и симуляция — разные бандлы. Сетевой WebSocket-слой только принимает и рассылает пакеты, про игру он не знает ничего. Отдельно — бандл симуляции мира: игровая логика, авторитарный расчёт состояния по тикам. Эти два куска независимы, их можно разнести по разным серверам. Каждая карта — свой набор процессов; карты живут на разных машинах, соседние обмениваются изменениями на каждом тике и складываются в единый бесшовный мир. Нужно больше игроков или мира — добавляешь серверы, а не переписываешь движок.
При чём здесь ИИ? При том, что эта развязка — моё решение, а не его. ИИ отлично пишет новую механику, но он не предложит развести сеть и симуляцию ради масштаба — он не мыслит категориями «как это переживёт нагрузку на тысячах игроков». Зато благодаря чёткой границе между бандлами и mcp он может спокойно править симуляцию, не задевая сеть и не ломая масштабирование. Хорошая архитектура работает в обе стороны: это и рельсы, по которым ИИ едет быстро, и потолок, который он сам себе не поставит.
Вот честная граница: ИИ кратно ускоряет путь от моей идеи до работающей фичи на обоих берегах. Но скорость безопасна ровно настолько, насколько прочны рамки, в которые он поставлен. Рамки — и сама игра — по-прежнему на мне.
Что дальше
Инвентарь, существо, поведение в бою — это я уже отдаю описанием. Следующая граница — анимации.
Сейчас, чтобы новое существо ожило, его анимации всё ещё нужно вручную заводить в клиент. Я хочу довести до анимаций ту же систему патчей, которой уже доставляю в клиент карты: загрузил анимацию собранную в Spriter в админку — патч уехал в клиент, готовый плагин её подхватил, и всё это без программирования под каждое существо. Это и проверю в следующей статье — и честно покажу, справится ли ИИ.
Одно предложение — и механика работает на сервере и в клиенте. Какую вы бы поставили в очередь первой? Напишите в комментариях — из этих заявок соберу темы следующих частей
-
Внедряю ИИ: механики из одного описания
ссылка на оригинал статьи https://habr.com/ru/articles/1044640/