Несколько лет я в одиночку пишу сервер для своей 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 в админку — патч уехал в клиент, готовый плагин её подхватил, и всё это без программирования под каждое существо. Это и проверю в следующей статье — и честно покажу, справится ли ИИ.

Одно предложение — и механика работает на сервере и в клиенте. Какую вы бы поставили в очередь первой? Напишите в комментариях — из этих заявок соберу темы следующих частей
Внедряю ИИ: механики из одного описания