Весь «вечно живущий» мир моей MMORPG держится на одной строке в кроне

от автора

Давай сразу честно, чтобы потом не было обидно. Я не профессиональный программист. У меня нет красивого тайтла, я не хожу на стендапы и не оптимизирую хайлоад на работе. Просто мужик, который два года по вечерам в одиночку пилит текстовую MMORPG в Telegram, потому что это интересно. Мрачный остров, по которому ходят выжившие, и один я под капотом с гаечным ключом. Не стартап и не портфолио. Хобби.

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

И сразу предупреждение. Я буду показывать свой код и свои решения. Часть из них нормальные. Другая часть это костыли, которыми я не горжусь, но которые два года держат живой мир и ни разу толком не подвели. Прятать не буду: вся соль не в том, чтобы выглядеть умным, а в том, чтобы показать, как растёшь, когда учишься на реальном проекте, а не на туториалах про todo-лист.

История идёт снаружи, а код и зануднота — под спойлерами. Зашёл просто почитать про соло-разработку? Спойлеры можно не открывать, нить не порвётся.

Почему CodeIgniter, если для игр его не берёт вообще никто

Будем честны до конца. Я не сравнивал движки и не выбирал «лучший инструмент под задачу». Я взял CodeIgniter, потому что я его знал. Точка.

Слабое оправдание, да? Но вот в чём штука. Когда ты один и пилишь хобби после работы, твой главный враг — не производительность и не модность стека. Главный враг — страх перед собственным проектом. Любая незнакомая магия фреймворка превращается в стену, об которую разбивается вечерний запал. А запал у хоббиста ресурс невозобновляемый: перегорел на ровном месте — и проект встал на год. (Собственно, однажды так и случилось, см. статью №2.)

Выбор стека: гора модных game-движков против пыльного CodeIgniter

Выбор стека: гора модных game-движков против пыльного CodeIgniter

Зато CodeIgniter скучный и предсказуемый, как лопата. Почти нет спрятанного волшебства, всё на виду, ты сам решаешь, где что происходит. Для одного человека это важнее любого бенчмарка. Когда нет команды, твой главный капитал в том, что ты держишь всё приложение в голове целиком и не боишься ни одного его угла. CI4 это даёт.

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

Главная засада: игра живёт во времени, а веб-фреймворк нет

В вебе всё просто. Пришёл запрос, ты собрал ответ, отдал, процесс умер. Между запросами сервера будто и нет.

Теперь моя игра. Крафт топора идёт двадцать минут. Поход через карту — целый час реального времени. База на клетке потихоньку ветшает, даже когда выживший спит и видит сны про консервы. В эти двадцать минут и в этот час никакого запроса нет. Игрок закрыл чат и ушёл варить уже настоящий, не игровой чай. Кто доведёт крафт до конца и положит топор в инвентарь?

У нормального игрового сервера для этого есть вечный цикл: процесс, который крутится всегда и каждый тик пересчитывает мир. У меня такого нет и никогда не было. PHP так не работает, он по своей природе про «ожил-ответил-умер».

Решение вышло простым до неприличия. Каждое действие во времени — это строка в таблице с дедлайном. Раз в минуту крон будит воркер, тот достаёт всё, у чего время вышло, и доводит до конца.

* * * * * php spark tasks:run

Да-да. Весь «вечно живущий» мир моей MMORPG держится вот на этой одной строке в кроне. Когда я это осознал, мне было и смешно, и немного стыдно. Запустил крафт — в таблицу character_tasks легла строка со статусом in_work и временем окончания. Каждую минуту воркер спрашивает базу: дай мне всё, что уже должно было завершиться.

Ожидание против реальности: игроки думают про мощный звездолёт, внутри одна cron-строка на палке

Ожидание против реальности: игроки думают про мощный звездолёт, внутри одна cron-строка на палке
Код: воркер достаёт завершённые задачи
$now = date('Y-m-d H:i:s');$activeTasks = $this->characterTaskModel    ->where('status', 'in_work')    ->where('end_time <', $now)    ->findAll();foreach ($activeTasks as $task) {    $taskDetails = $this->taskModel->find($task['task_id']);    if ($taskDetails) {        $this->handleTask($task, $taskDetails);    }}

Да, тут честный N+1: на каждую задачу я отдельно дёргаю справочник её типа. На сотнях задач за тик это шум, база не замечает. Упрусь в потолок — закэширую справочник в память, он крошечный и почти не меняется. Пока не упёрся, живёт как есть.

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

Надёжно? Ровно до тех пор, пока не вспомнишь школьную истину про гонки. Если один тик не уложился в минуту, следующий крон стартанёт поверх него, найдёт те же задачи в статусе in_work и выдаст награду второй раз. Дюп ресурсов на ровном месте, любимая дыра всех песочниц. А мне совсем не хотелось, чтобы выжившие дюпали патроны, пока я сплю.

И вот тут важная честность, без которой коммент-секция меня бы съела. Настоящую корректность даёт только атомарный «захват» задачи. Я помечаю её завершённой ещё ДО запуска обработчика, одним UPDATE с условием «только если она всё ещё in_work». Кто-то уже забрал — база вернёт ноль изменённых строк, и я просто прохожу мимо. Этого одного достаточно, даже если воркеров вдруг запустится два.

Файловый замок поверх — не вторая линия обороны, а дешёвая оптимизация. Он не даёт соседнему тику на том же хосте даже начать лишнюю выборку. От двух хостов flock бы не спас (он живёт на локальной ФС одного сервера) — спас бы как раз атомарный захват. Я добавил замок раньше, по молодости, и оставил: пусть будет, есть не просит.

Код: атомарный захват + flock-оптимизация
// Дешёвая оптимизация: не пускаем соседний тик на ТОМ ЖЕ хосте в лишнюю выборку.// LOCK_NB — не ждём, а тихо выходим, если прошлый тик ещё работает.$lockHandle = @fopen(WRITEPATH . 'worker.lock', 'c');if (!flock($lockHandle, LOCK_EX | LOCK_NB)) {    log_message('info', '[Worker] Предыдущий запуск ещё работает — пропускаем тик.');    return;}// А вот это — НАСТОЯЩАЯ защита. Атомарный захват задачи ПЕРЕД обработкой.$claimed = $db->table('character_tasks')    ->where('id', $task['id'])    ->where('status', 'in_work')   // ключевое условие    ->update(['status' => 'completed', 'updated_at' => date('Y-m-d H:i:s')]);if (!$claimed || $db->affectedRows() === 0) {    // Кто-то уже забрал — пропускаем.    return;}

У этого размена есть и обратная сторона, честно скажу. Если воркер свалится ровно между захватом и выдачей награды, задача останется completed, а топор в инвентарь так и не ляжет: крафт тихо потеряется. Я выбрал этот риск сознательно. Потерянный раз в сто лет крафт чинится одним сообщением в поддержку, а вот тихий дюп патронов отравил бы экономику сразу всем. На практике обработчик до выдачи награды не падает, так что пока это чистая теория.

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

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

По-хорошему это костыль. Я сделал игровой цикл из крона и одной таблицы, потому что настоящего цикла у фреймворка нет. Но костыль оказался живучим, крутит мир уже два года. Знаешь, что я понял? Иногда «костыль, который работает и который ты понимаешь» лучше, чем «правильная архитектура, в которой ты тонешь». Особенно когда вся команда — это ты, и спросить не у кого.

Что происходит, когда выживший жмёт кнопку (и как я наступил на грабли два дня назад)

Вся игра — это текст и кнопки в чате. Каждое нажатие прилетает боту как callback_data, короткая строка вроде march или npcAct_attack_5. Дальше её надо превратить в конкретный кусок кода.

Маршрутизатор у меня самодельный и довольно тупой. Берёт первый кусок строки до первого подчёркивания и ищет по нему обработчик: сначала точное совпадение, потом по префиксу.

Код: резолв колбэка в обработчик
public function resolve(string $action): ?string{    // Точное совпадение первого сегмента.    if (isset($this->exactRoutes[$action])) {        return $this->exactRoutes[$action];    }    // Иначе пробуем по префиксу.    foreach ($this->prefixRoutes as $prefix => $handler) {        if (str_starts_with($action, $prefix)) {            return $handler;        }    }    return null;}

$action тут — уже только первый сегмент, всё, что до первого _. Хвост вроде attack_5 обработчик разбирает сам.

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

Чувствуешь подвох? Резолвер сравнивает первый сегмент npcAct с префиксом npcAct_. Префикс длиннее сегмента, str_starts_with честно возвращает false. Совпадения нет никогда. Все кнопки встречи оказались мёртвыми. Жмёшь — и тишина. Выживший стоит перед бандитом и не может даже замахнуться.

Зелёные тесты против реальности: всё проходит, но ни одна кнопка не нажимается

Зелёные тесты против реальности: всё проходит, но ни одна кнопка не нажимается

Самое обидное — это пережило весь мой прогон тестов. Юнит-тесты зелёные, статический анализ доволен, я сам потыкал ботом и не заметил. Потому что код-обработчик при прямом вызове делал всё правильно. Не работала только регистрация маршрута, а её ни один автотест не дёргал так, как живой палец в Telegram.

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

Как я выкинул из проекта Python (и не пожалел)

Вот тут особенно видно, как меняешься за два года. В первой статье, ещё в 2024-м, я гордо хвастался пайплайном генерации карты: Python, NumPy, SciPy, scikit-image, потом ручная доводка в фотошопе. Выглядело солидно и по-взрослому.

Сейчас Python из проекта ушёл совсем. Ни строчки. И мне за это не стыдно, наоборот.

Карта — это просто миллион строк в MySQL, по строке на каждую клетку острова. Остров сто на сто километров, клетка — сто на сто метров, вот и набегает ровно миллион. У клетки есть координаты и один из девяти биомов: леса, горы, джунгли, вулканические территории и так далее. Сгенерил один раз — дальше она просто лежит данными.

Почему миллион строк, а не генерация рельефа на лету по seed? Потому что карту мало сгенерить, её надо постоянно спрашивать про каждую клетку отдельно: кто эту клетку видел, что на ней добыто, чей лагерь тут стоит. Это уже не функция от seed, это состояние. А раз состояние всё равно живёт в базе — пусть и сама клетка лежит рядом, забирается одним джойном. Туман войны поверх — ещё одна таблица: выживший видит только клетку под ногами и соседние, каждый шаг открывает новый кусок, и обратно он уже не зарастает. Таких записей «кто что видел» накопилось 426 тысяч, и это тоже просто строки.

А научный Python решал задачу, которой больше нет. Один раз сгенерировать красивый рельеф он умеет хорошо, тут вопросов нет. Но держать ради разовой генерации целую вторую экосистему в проекте, который во всём остальном чистый PHP — это я два года назад умничал. Сегодняшний я посмотрел и сказал: меньше движущихся частей, крепче сон. И выпилил. Научиться выбрасывать собственные «умные» решения, когда они больше не нужны — это, наверное, и был главный скилл тех месяцев.

Ни одного игрового числа в коде (да, я переусложнил своё хобби)

В прошлой статье я обмолвился про систему баланса и обещал раскрыть. Раскрываю, и заодно признаюсь, что тут я знатно переинженерил.

У меня есть правило, которым я правда горжусь: ни одно игровое число не живёт в коде хардкодом. Цена ремонта, шанс редкого дропа, время крафта, урон, налоги. Всё это лежит строками в отдельной таблице настроек. На сегодня их 419. Поменять любую можно прямо на проде, без релиза и деплоя, и почти сразу увидеть эффект на живых выживших.

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

Ключ: defense.repair.cost_fraction = 0.50 Зачем столько: баланс между «чинить базу не больно» и «ремонт ощутимо ест ресурсы». На что влияет: доля ресурсов, которую забирает один ремонт постройки. Если выше (0.70): ремонт почти бесплатный, у эндгеймеров пропадает сток ресурсов, лезет инфляция. Если ниже (0.30): чинить дорого, новички бросают разрушенные базы и уходят.

Так «магическое число» из тёмной материи превращается в подписанную ручку на пульте.

Пульт управления АЭС с сотнями подписанных тумблеров — баланс текстовой игры

Пульт управления АЭС с сотнями подписанных тумблеров — баланс текстовой игры
Код: как игровая логика читает настройку

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

// Доля ресурсов, которую съедает ремонт. Живёт в админке, не в коде.$costFraction = $this->gsFloat('defense.repair.cost_fraction', 0.50);$qty = (int) ceil($baseQty * $costFraction * $missingFraction);

Под капотом чтение кэшируется на минуту. Поэтому правка ручки в админке доезжает до игроков с задержкой до этой самой минуты, пока живёт старый кэш. А если таблица настроек вдруг целиком недоступна, читалка возвращает дефолт из второго аргумента, прямо как в строке выше. Игра не должна падать из-за того, что я полез крутить баланс с дивана.

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

Тысяча тестов для текстовой игрули — это диагноз?

Кодовая база разрослась до примерно ста пятидесяти тысяч строк и за тысячу файлов. Я в ней один. И от превращения в болото её держит только одно. Строгость, которую я сам на себя надел.

Статический анализ стоит на девятом, максимальном уровне. Тестов уже перевалило за тысячу двести. Для текстовой игрули в Telegram это звучит как диагноз. Но именно это позволяет мне катить изменения почти каждый день и не разваливать живой мир под игроками. Когда правишь проект на полтораста тысяч строк по памяти, без сетки внизу ты рано или поздно ломаешь что-то старое новой фичей и узнаёшь об этом из гневного сообщения игрока, а не из теста.

Тесты ловят не всё, врать не буду. Как реально отрисуется сообщение в Telegram, с какой картинкой и не разъедется ли вёрстка кнопок — это всё равно проверяешь руками на живом боте. Та история с мёртвыми кнопками встречи как раз отсюда. Но фундамент строгость держит крепко, и это здорово выручает, когда чинить некому, кроме тебя.

Два года назад у меня не было ни одного теста, а на phpstan я смотрел, как на красную кнопку — не трогать. Сегодня не понимаю, как жил без них. Это не я стал умнее, это проект заставил меня повзрослеть.

Какой стек я взял бы сегодня

А теперь честная часть, ради которой многие и долистали досюда.

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

Крон вместо игрового цикла. Файловый замок вместо нормальной очереди. Плюс самодельный роутер колбэков, который я держу в голове руками. Это всё лечение симптомов. Язык, где фоновые процессы и реалтайм идут из коробки, а не надстраиваются над вебом, убрал бы половину этих костылей просто фактом своего существования.

Я бы смотрел в сторону Elixir или Go. Крон с таблицей задач там превратился бы в лёгкий процесс-таймер на каждое действие, который сам просыпается в нужную секунду. Файловый замок вообще отпал бы: планировщик живёт внутри рантайма, его не надо дёргать кроном снаружи. А с самодельным роутером — честно, не уверен: кривой паттерн с лишним подчёркиванием не спас бы и нормальный pattern matching, баг-то был в моих данных, а не в языке. Но в целом «вечно живущий мир» там не костыль, а то, для чего язык придуман.

При этом я не строю иллюзий. У того же Elixir своя цена: экосистема под Telegram-ботов и готовых кусков несравнимо беднее PHP, и ровно ту скорость старта, которую мне дал знакомый CI, я бы там потерял на первых же вечерах. А скорость старта для хоббиста — это буквально вопрос жизни проекта.

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

Так что да. Не тот стек. Но тот, на котором проект дожил до этой статьи. Для хобби это и есть правильный выбор.

Чему меня на самом деле научили эти два года

Неважно в итоге, тот стек или не тот. Потому что главное эти два года дали мне вообще не про язык.

Я начинал, умея, по сути, херачить процедурный PHP и бояться собственного кода. За два года, не выходя из одного хобби-проекта, я на практике прошёл через всё. Разломал монолитный switch на плагины. Выкинул лишний язык, прикрутил статанализ и тесты. Научился документировать решения и оставлять себе зарубки на граблях. И понял на собственной шкуре, что такое гонки, идемпотентность и атомарный захват — не из статьи, а потому что у меня реально дюпали ресурсы.

И знаешь, к какому выводу я пришёл? Для того, кто растёт не на работе, а сам по себе и в кайф, нет школы лучше, чем один реальный живой проект, который тебе не всё равно. Не двадцатый туториал. Не курс. Не todo-лист, который ты бросишь через неделю. А штука, в которую ходят живые люди и которая сломается по-настоящему, если ты налажаешь. Вот это учит так, как не научит ничто.

Wild World для меня — это не «игра, которую я сделал». Это тренажёр, на котором я два года учусь быть инженером, замаскированный под мрачный остров с выжившими. И самое смешное, что он ещё и работает, и в него играют.

Думал, что делаю игру — оказалось, делал из себя разработчика

Думал, что делаю игру — оказалось, делал из себя разработчика

Зайти и посмотреть

Лучший способ понять, ради чего вся эта возня под капотом, — провести в самом мире один вечер. Разведать пару клеток сквозь туман, скрафтить первую кирку, поставить лагерь до того, как тебя застанет голодная ночь. А заметишь баг — знай, что по ту сторону сидит такой же живой мужик с кружкой и чинит это руками.

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

👉 Запустить Wild World в Telegram — бесплатно и прямо в чате, скачивать нечего.

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

В следующий раз спущусь ещё глубже в один конкретный угол: скорее всего разберу, как из текста и кнопок собирается бой, или как устроены фракции и общий финал сервера. Но это уже в другой вечер. До встречи на острове.

ссылка на оригинал статьи https://habr.com/ru/articles/1045308/