Архітектура ігрового сервера: з чого складається світ
Вебсервер відповідає на запит і забуває про клієнта. Ігровий сервер тримає тисячі гравців у спільному світі, де кожен бачить дії інших із затримкою в десятки мілісекунд, і не має права «забути» нікого. Це інший клас задач, і майже все, що працює у вебі, тут доводиться переосмислювати.
Цей курс — на нашому власному матеріалі. Ми тримаємо живий світ Ейрін і розбираємо системи, які в ньому справді працюють.
Чим ігровий сервер відрізняється
| Вебсервер | Ігровий сервер | |
|---|---|---|
| З'єднання | коротке, стан у БД | постійне, стан у пам'яті |
| Модель | запит → відповідь | двонаправлений потік подій |
| Стан | у базі | у пам'яті, база — резервна копія |
| Затримка | 200 мс прийнятно | 100 мс уже відчутно |
| Масштабування | додати інстанс | світ не ділиться просто так |
Головне: стан живе в пам'яті. Читати позицію персонажа з бази на кожен крок неможливо — це тисячі запитів на секунду з нульовою користю. База потрібна, щоб пережити перезапуск, а не щоб працювати.
Звідси випливає решта курсу: як тримати стан консистентним у пам'яті, як його вчасно скидати на диск і що робити, коли процес усе-таки впаде.
З чого складається система
┌──────────────┐
гравець ──▶│ Login-сервер │──┐
└──────────────┘ │
▼
┌──────────┐
│ MySQL │
└──────────┘
▲
┌──────────────┐ │
гравець ──▶│ Game-сервер │──┘
└──────────────┘
Login-сервер перевіряє логін і пароль, віддає список світів і видає одноразовий ключ сесії. Живе окремо, бо це єдина точка, де ходить пароль, і бо перезапуск ігрового світу не має вибивати людей з автентифікації.
Game-сервер тримає світ: персонажів, NPC, предмети, бій. Тут постійні з'єднання й ігровий цикл.
База зберігає те, що має пережити перезапуск: акаунти, персонажів, інвентар, стан кланів.
Часто додається кеш (Redis) для міжсерверних речей: онлайн-статус, черга, повідомлення між світами.
Потоки: що де виконується
Найважливіше архітектурне рішення — розділення потоків.
[Netty I/O потоки] читають байти з сокетів, декодують пакети
│
▼ черга
[Ігровий потік] виконує логіку світу: рух, бій, тік
│
▼ черга
[Пул БД] асинхронні збереження, батчинг
Правило, порушення якого відчують усі: в ігровому потоці не можна блокувати. Жодного запиту в базу, жодного Thread.sleep, жодного мережевого виклику. Запит до MySQL на 50 мс — це 50 мс, протягом яких увесь світ стоїть.
Другий бік цього правила: ігрова логіка виконується в одному потоці (або по одному на регіон), тому здебільшого не потрібні блокування. Це навмисний обмін: відмовляємось від паралелізму логіки заради простоти й передбачуваності.
Тік
Світ живе дискретними кроками:
while (running) {
long start = System.nanoTime();
processIncomingPackets(); // те, що надіслали гравці
updateMovement(); // перерахувати позиції
updateCombat(); // удари, кулдауни, регенерація
updateAI(); // рішення NPC
broadcastChanges(); // розіслати зміни
long elapsed = (System.nanoTime() - start) / 1_000_000;
long sleep = TICK_MS - elapsed;
if (sleep > 0) Thread.sleep(sleep);
else logger.warn("Тік не вклався: {} мс", elapsed);
}
Типовий тік для MMORPG — 100–200 мс. Для шутера — 15–30 мс, але там і світ менший.
else у цьому коді важливіший за саму паузу: якщо тіки перестають вкладатись, світ починає «гальмувати» для всіх одночасно. Це перший сигнал, який треба ловити в моніторингу.
Стек
Що використовуємо в курсі:
- Java 21 — віртуальні потоки, records, pattern matching
- Netty 4 — асинхронний мережевий шар
- MySQL + HikariCP — сховище й пул з'єднань
- Caffeine — кеш у пам'яті
- Micrometer + Prometheus — метрики
- JMH, async-profiler — вимірювання
Чому Java, а не C++: збирач сміття — керована проблема, а швидкість розробки й екосистема виграють. Більшість відомих емуляторів MMORPG написані саме на Java.
Що ламається першим
Порядок, у якому проєкти зазвичай упираються в стелю:
- Блокуючий виклик в ігровому потоці — світ завмирає на час запиту
- Broadcast усім замість тих, хто поруч — трафік росте квадратично
- Збереження на кожну дію — база лягає під навантаженням
- Довгі GC-паузи — світ замирає на секунду, гравці телепортуються
- Довіра до клієнта — читери роблять що завгодно
Кожному з цих пунктів присвячено окремий модуль.
Структура проєкту
mmorpg-server/
├── common/ спільні моделі й утиліти
├── login-server/
│ ├── net/ пакети автентифікації
│ └── auth/
├── game-server/
│ ├── net/ кодеки, обробники пакетів
│ ├── model/ персонажі, NPC, предмети
│ ├── world/ регіони, видимість, спавн
│ ├── combat/ формули, навички
│ ├── ai/
│ └── db/ DAO, черга збереження
└── data/ конфіги світу: спавни, дроп, статистика
Розділення model і db принципове: ігрова модель не має знати про SQL. Інакше кожна зміна схеми потягне за собою правки бойової логіки.
Практика
- Створи багатомодульний Gradle-проєкт за структурою вище.
- Напиши каркас ігрового циклу з фіксованим тіком і логуванням перевищень.
- Додай штучну затримку 300 мс усередині циклу й подивись у логах, як поїде тік.
- Порахуй на папері: скільки пакетів на секунду згенерує 500 гравців, якщо кожен рухається двічі на секунду, а про рух знають усі 500.
Останнє завдання дасть число, яке пояснює наступні модулі краще за будь-який текст.
Підсумок
- Стан живе в пам'яті, база — засіб пережити перезапуск
- Login і game розділені навмисно
- В ігровому потоці не можна блокувати — жодного I/O
- Один потік логіки прибирає більшість гонок
- Тік, що не вкладається, — головна метрика здоров'я світу
- Модель не знає про SQL
Далі — мережевий шар.