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

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

Сначала я переписал всё на Symfony — и это было решение в пользу ИИ

Мой сервер начинался как самописный PHP. Он работал, держал бой в реальном времени, но был «мой» в худшем смысле слова: ни один справочник, ни одна статья, ни одна модель не знали, как он устроен. Любому новому участнику разработки — человеку или машине — пришлось бы изучать его с нуля.

Поэтому я переписал серверную часть на Symfony — популярный фреймворк для PHP, на котором построен проект. Уложился в два месяца; без ИИ ушло бы полгода. Переписывал ради ИИ и ради будущей команды — на знакомом многим фреймворке проще подключить разработчиков. Не ради моды.

Современная модель обучена на гигантских объёмах кода, документации и готовых продуктов на распространённых фреймворках. Symfony она «знает» — её паттерны, жизненный цикл, способ раскладывать код по местам. Когда я прошу что-то изменить в проекте на Symfony, модель опирается не на мои объяснения, а на то, что у неё и так в голове. Меньше контекста на вход, меньше разночтений, точнее результат. Самописный легаси требовал разжёвывать каждую мелочь. Знакомый фреймворк превратил ИИ из стажёра, которого надо вводить в курс, в инженера, уже работавшего с таким стеком сотни раз.

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

Новый контур: существо и его параметры правятся в админке поверх той же игровой карты, что видит игрок
Новый контур: существо и его параметры правятся в админке поверх той же игровой карты, что видит игрок

Потом ИИ получил руки — и в сервере, и в клиенте

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

Поэтому я построил MCP-мост в обе стороны.

С одной стороны — мой собственный MCP-сервер. Это мой продукт. Через него ИИ делает почти всё, что геймдизайнер делает руками: создаёт и правит карты, расставляет на них врагов, меняет игровой баланс, читает логи сервера — и даже может сам подключиться к игре как обычный клиент и поиграть, чтобы проверить на ощущение то, что только что сделал.

С другой стороны — MCP-плагин для Unity. Через него ИИ работает уже внутри редактора: создаёт и правит объекты на сцене, пишет C#-скрипты, запускает игру, делает скриншоты и читает консоль. (Плагин планирую оформить отдельно — будет в сторе.)

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

Как фича доходит до игры

Над этим мостом я собрал свой слой: набор ИИ-агентов и skill’ов, заточенных под мой проект. Один отвечает за серверные механики, другой — за клиент на Unity, третий проверяет работу, и так далее. Каждый знает свою зону и свои правила. Я не пишу промт на каждую мелочь — я описываю фичу, а слой сам раскладывает её по исполнителям.

Слой агентов: у каждого своя зона ответственности — сервер, клиент, проверка.
Слой агентов: у каждого своя зона ответственности — сервер, клиент, проверка.
Skill’ы — правила и знания проекта, которые ИИ подхватывает под конкретную задачу
Skill’ы — правила и знания проекта, которые ИИ подхватывает под конкретную задачу

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

Тот самый инвентарь — собран на сервере и в Unity из одного описания
Тот самый инвентарь — собран на сервере и в Unity из одного описания

Где ИИ заканчивается и начинаюсь я

Здесь обычно появляется возражение: «ИИ генерит мусор». Так и есть — если отдать ему пустой холст. Мусор получается там, где нет архитектора, который заранее закрыл целые классы ошибок.

Я не прошу ИИ придумать ни игру, ни архитектуру. Фичи придумываю я — какой будет инвентарь, как работает экипировка, что делает существо в бою. Архитектуру и инварианты задаю тоже я. ИИ исполняет внутри этих рамок. Два примера, на которых это видно.

Правда живёт только на сервере. Клиент ничего не решает — он показывает то, что посчитал сервер. Это инвариант, который ИИ не вправе нарушить. Поэтому, что бы он ни написал в клиенте, у него физически нет способа породить рассинхрон или чит: клиент не источник истины, а витрина. Тот самый инвентарь это и подтверждает — клиент рисует предметы, но разрешает или запрещает надеть вещь только сервер. Целый класс багов закрыт не проверками, а архитектурой.

Сеть и симуляция — разные бандлы. Сетевой WebSocket-слой только принимает и рассылает пакеты, про игру он не знает ничего. Отдельно — бандл симуляции мира: игровая логика, авторитарный расчёт состояния по тикам. Эти два куска независимы, их можно разнести по разным серверам. Каждая карта — свой набор процессов; карты живут на разных машинах, соседние обмениваются изменениями на каждом тике и складываются в единый бесшовный мир. Нужно больше игроков или мира — добавляешь серверы, а не переписываешь движок.

При чём здесь ИИ? При том, что эта развязка — моё решение, а не его. ИИ отлично пишет новую механику, но он не предложит развести сеть и симуляцию ради масштаба — он не мыслит категориями «как это переживёт нагрузку на тысячах игроков». Зато благодаря чёткой границе между бандлами и mcp он может спокойно править симуляцию, не задевая сеть и не ломая масштабирование. Хорошая архитектура работает в обе стороны: это и рельсы, по которым ИИ едет быстро, и потолок, который он сам себе не поставит.

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

Что дальше

Инвентарь, существо, поведение в бою — это я уже отдаю описанием. Следующая граница — анимации.

Сейчас, чтобы новое существо ожило, его анимации всё ещё нужно вручную заводить в клиент. Я хочу довести до анимаций ту же систему патчей, которой уже доставляю в клиент карты: загрузил анимацию собранную в Spriter в админку — патч уехал в клиент, готовый плагин её подхватил, и всё это без программирования под каждое существо. Это и проверю в следующей статье — и честно покажу, справится ли ИИ.

Анимации — тема следующей части: завести их в клиент из админки, без кодинга под каждое существо
Анимации — тема следующей части: завести их в клиент из админки, без кодинга под каждое существо

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

  1. Введение

  2. Масштабируемость и асинхронность

  3. WebSocket

  4. Redis

  5. LUA и JavaScript

  6. Выбор технологий, протокола и ECS

  7. Игровые локации (тайловые карты)

  8. Клиентская часть на Unity

  9. Игровые серверные механики

  10. Открытый бесшовный мир в 2D игре

  11. FPS, Ping, интерполяция и экстраполяция

  12. Очереди и параллельное программирование на CPU

  13. Event-driven, JSON-RPC и почему не SOA

  14. Сетевая карта и задержка кадра (Latency frame) по RFC 2544

  15. Авторитарный сервер на процессах

  16. MVP готов

  17. Внедряю ИИ: механики из одного описания

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