Як реалізувати турніри в казуальній грі
Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті
Привіт, мене звати Юрій Коваленко, я Node.js-розробник у компанії OBRIO, яка входить в екосистему бізнесів Genesis. Команда розвиває чотири продуктові напрями: GameDev, SaaS, Mobile та Web. Ми — одна з перших команд у Genesis, яка послідовно розвиває геймдев-напрям. Сам Genesis — це компанія-співзасновник, яка будує та інвестує в IT-бізнеси та надає їм усю необхідну інфраструктуру для розвитку на початкових стадіях.
Наразі я відповідаю за всю екосистему сервісів для гри Factory Empire. До OBRIO був частиною команди, яка займається сервісом розсилки SMS-повідомлень у складі європейського B2B-продукту для електронного маркетингу. На початку своєї кар’єри займався автоматизацією бізнес-процесів та розробляв Web-клієнти на Angular.
Рік тому ми з нуля почали збирати GameDev-команду і розробляти казуальну гру. На початкових етапах розробки команда замислилася про те, як зацікавити гравців. У цій статті детально розповім, як реалізували механіку активності.
Матеріал буде цікавим тим, хто лише починає розробку бекенду гри та проєктує механіку, що передбачає сезонні або постійні лідерборди.
Завдання
У будь-якій грі, де існує взаємодія між гравцями, добре працюють механіки, які змушують конкурувати й доводити свою першість. Гравці мають потребу змагатися між собою, між групами (гільдіями, кланами, командами). У казуальних іграх часто трапляється реалізація такої механіки у вигляді короткотривалих турнірів.
Працює все просто:
- граєш у гру, отримуєш очки;
- піднімаєшся у турнірній таблиці, випереджаючи інших гравців;
- у процесі отримуєш проміжні нагороди за активність;
- у кінці отримуєш «соковиту» нагороду залежно від місця в таблиці.
Ця фіча впливає на основні продуктові метрики, оскільки збільшує інтерес до гри, заохочує гравців змагатися між собою, а отже, повертатися в гру і збільшувати довжину сесії.
З вищеописаної механіки можна виділити такі етапи реалізації:
- турнірна таблиця;
- турнірні групи;
- боти;
- видача нагород.
Далі зупинюся на технічних деталях реалізації.
Деталі стеку. Екосистема сервісів гри розроблена з використанням стеку таких технологій: Node.js + NestJS, TypeORM, PostgreSQL, Bull, Redis, RabbitMQ, Docker, Angular. Для реалізації цієї фічі, окрім програмного коду, знадобиться саме Redis. (Дані гравців вже зберігаються в PostgreSQL).
У ролі основного сховища інформації турнірів обрали Redis, оскільки це динамічні дані, які потрібно часто оновлювати, видаляти і створювати заново для подальших турнірів, що є не настільки ефективним в SQL базах даних. Також у зв’язці Redis + Node.js існують рішення, які розв’язують деякі проблеми, описані далі.
Реалізація
Турнірна таблиця
Турнірна таблиця — це відсортований список, який містить ідентифікатор гравця і число набраних очок. Водночас у такій таблиці відбувається постійне оновлення балів і повна вибірка. Для описаної задачі добре підходить Redis Sorted Set. Така структура даних автоматично сортується при вставці нового елемента чи оновленні наявного, і вибірка за замовчуванням є впорядкованою, що дозволяє не витрачати ресурси сервера та клієнта на сортування. Вставка та оновлення в Sorted Set відбувається однією командою ZADD (подібно операції upsert в БД).
У нашому випадку гравець отримує турнірні очки за рейд і атаку. За «ідеальний рейд» найбільше. Набравши пороговий бал, гравець приєднується до турніру. Незалежно від місця кожен гравець, набравши певну кількість балів, отримує проміжні нагороди.
Групи гравців
Аби створювати адекватну конкуренцію серед гравців, турнірна таблиця не може бути одна: якщо гравець звалиться кудись на глибоке «дно» таблиці та не вміщатиметься у вибірку, яка не може бути необмеженою зі зрозумілих причин, зникне мотивація конкурувати, а отже, зацікавленість грою знизиться. Тому ми вдалися до розділення гравців на групи. У кожної групи своя окрема турнірна таблиця. Турнір конфігурується за допомогою таких змінних: розмір таблиці, розмір квоти реальних гравців, поріг очок для участі, мінімальний рівень локації гравця. Усі ці значення можна поміняти без змін в коді чи перезапуску середовища за допомогою сервісу «адмінки». На початку турніру створюється таблиця, наповнена ботами. Вони потрібні для того, щоб зацікавити гравців.
Перші боти в списку — активні, їх порівняно мало, і їм дається певна кількість очок для емуляції активності. Кількість активних ботів дорівнює різниці розміру таблиці й розміру квоти реальних гравців. Зараз їх відсоток невеликий. Друга частина ботів — неактивні, їхня кількість дорівнює максимальному числу реальних гравців. Очки кожного такого бота відповідають порогу входу, кожен новий гравець «виштовхує» його з таблиці.
Коли таблиця повністю заповнюється і всі неактивні боти вибули, автоматично створюється нова, знову заповнена лише ботами. І так далі. Варто зазначити, що окремо створювати таблицю як Sorted Set не потрібно. Досить почати додавати в неї записи командою ZADD. Якщо ключ у Redis відсутній, він буде створений.
Зв’язок «гравець → таблиця» зберігається в Redis Hash.
Структура виглядає так: key → field → value, де field — ідентифікатор гравця, а value — ідентифікатор турнірної таблиці. Це дає змогу швидко отримати ідентифікатор таблиці, до якої належить гравець. Якщо такий зв’язок ще не створений, гравець вважається новим.
Також із Redis Hash можна отримати список значень всіх ключів командою HKEYS — це відкидає необхідність зберігати окремий список учасників.
Для кожної таблиці створюється Redis Sorted Set з її ідентифікатором у назві ключа. Тому, зберігаючи зв’язок «гравець → таблиця», можна легко отримати потрібну турнірну таблицю, щоб відправити в гру за допомогою двох операцій з Redis:
- HGET для отримання ідентифікатора таблиці за допомогою ідентифікатора гравця;
- ZREVRANGE для отримання списку очок, відсортованого за спаданням.
Список ботів таблиці зберігається в Redis List, де з кінця видаляється бот під час приєднання гравця.
Список таблиць зберігається також в Redis List. Кожна нова таблиця додається згори списку, тому новий гравець приєднується в першу за списком таблицю — вона вважається завжди поточною.
Масштабування сервісу, що містить логіку турнірів, і відсутність зручного механізму блокувань в Redis створює челендж під назвою race condition у процесі конкурентного доступу до даних турніру.
Контроль конкурентності без використання блокувань може вирішити черга задач, але черга в оперативній пам’яті на кшталт p-queue або самописний «велосипед» на rxjs не впорається при горизонтальному масштабуванні, оскільки в кожному екземплярі сервера існуватиме своя черга, яка жодним чином не знає про «сусідні».
Тут на допомогу приходить Bull. Ця бібліотека реалізує чергу задач поверх Redis, який є спільним для всіх екземплярів. Її використання вирішує одразу дві проблеми: масштабування і контроль конкурентності. А обгортка від фреймворку NestJS допоможе легко вбудувати API для роботи з чергою в код програми.
Використанням черги можна гарантувати, що до турніру приєднується лише один гравець за раз, і, коли виникне необхідність згенерувати нову таблицю, не станеться непередбачених побічних ефектів, викликаних одночасним приєднанням двох гравців в останній слот поточної таблиці.
Боти
Як було згадано вище, боти мають імітувати активність у турнірній таблиці, змагаючись із гравцями за лідерство. Ми це реалізували за допомогою простої відкладеної задачі (кронджоби). Активні боти в таблиці розбиваються на підгрупи, кожна з яких стежить за певним місцем у турнірній таблиці. Кожні кілька хвилин, якщо на цільовому місці перебуває реальний гравець, підгрупа ботів скорочує відставання. Якщо на цільовому місці бот, підгрупа ботів пропускає ітерацію.
Для персоналізації поведінки використовуються сегменти — мітки, які присвоюються гравцю за його активність протягом життя у грі. На основі сегментів будуються правила, які регулюють стрімкість наздоганяння ботів. Це полегшить можливість виграти пасивному гравцю, що дасть ресурси й бажання грати активніше і закріпитися в наступному турнірі чи пройти декілька локацій, а отже, повертатися в гру частіше.
Для вирішення колізій у правилах вказується пріоритет при створенні. Це необхідно для уникнення конфліктів, якщо гравець має декілька сегментів, від яких залежить активність ботів.
Завершення турніру і присвоєння нагород
По завершенню турніру всім гравцям, які були учасниками, видається нагорода. На сервері немає інформації про налаштування економіки гри, тому в нагороду кожному гравцю зберігається снепшот турнірної таблиці, у якій він брав участь. Далі гравець окремо заходить у гру й отримує нагороду на основі місця в цьому снепшоті, вже враховуючи налаштування економіки.
Останній крок — очистити дані проведення турніру, більше вони не знадобляться. Для цього лише потрібно передати в команду DEL список ключів незалежно від типу.
Турніри на клієнті
На реалізації логіки відображення турнірів, анімацій перельоту очок та інших подібних речах зупинятися не буду, оскільки особисто не мав з цим справу — це відповідальність нашої команди Unity. Втім, я постараюся розкрити аспекти клієнт-серверної взаємодії, що складається з двох видів активності: отримання даних турніру й оновлення кількості очок.
Передача даних турніру на клієнт
Для взаємодії клієнта з сервером використовується WebSocket для мінімізації затримки та забезпечення взаємодії в режимі реального часу.
Для отримання даних турніру ми використовуємо повідомлення TournamentSync, яке містить дані такого вигляду:
{ "event": "TournamentSync", "data": { "tournament": { "id": 1, "name": "Grand Tournament", "type": "GrandTournament", "endDate": "2020-11-13T18:00:00Z" }, "scoreThreshold": 10, "scores": [ { "player": { "id": "c2cbff05-1f02-492a-837c-69d090e4a447", "nickname": "Player1", "avatar": "https://avatar.com/a1" }, "score": "130" }, { "player": { "id": "ed59d0c8-8823-46da-8380-1fefee1f6b18", "nickname": "Player2", "avatar": "https://avatar.com/a2" }, "score": "120" } ] } }
У разі, якщо гравець не встиг набрати кількість очок, необхідну для приєднання до турніру, поле scores має значення null. Якщо в конкретний момент активного турніру немає, поле tournament також має значення null.
Дані в полі tournament необхідні, щоб:
- довантажити потрібний арт та елементи UI;
- відобразити іконку в інтерфейсі гри, щоб показати можливість взяти участь у турнірі;
- показати таймер, щоб мотивувати гравця не гаяти час і зайняти якомога вище місце.
Дані в полі scores потрібні для безпосереднього відображення лідерборда.
І відповідно поле scoreThreshold дає розуміння гравцю, яку кількість очок потрібно набрати, щоб приєднатися до турніру. Для оптимізації TournamentSync відправляється лише на початку гри, щоб дізнатися про наявність активного турніру. А також щоразу по кліку на іконку турніру в інтерфейсі гри.
Оновлення кількості очок
Для зміни кількості очок використовується повідомлення BalanceUpdate — те саме, що і для зміни будь-якої валюти в грі. Клієнт передає кількість очок, які потрібно додати гравцю, а сервер рахує суму й оновлює запис в лідерборді турніру. І вже з наступним повідомленням TournamentSync по кліку на іконку турніру гравець отримує актуальний розклад лідерборду.
Сповіщення гравця про турнір
Є необхідність сповіщати гравця про початок і кінець турніру. Для офлайн-гравців усе просто: всім відправляється пуш-сповіщення про початок турніру і лише учасникам поточного — про його кінець.
Для онлайн-гравців використовуються повідомлення по WebSocket із назвою TournamentStart і TournamentEnd. Їх необхідно відправити, оскільки онлайн-гравець вже у грі, а отже, не може дізнатися про початок турніру або наявність нагород за щойно завершений.
TournamentStart змушує гру відправити повідомлення TournamentSync, щоб отримати його дані. TournamentEnd спричиняє відправку повідомлення RewardsSync, щоб видати нагороди гравцю.
Планування розкладу турнірів
Для керування турнірами розробили графічний інтерфейс у рамках раніше згаданої «адмінки» у вигляді календаря. Заплановані турніри моніторить кронджоба і сповіщає основний ігровий сервер про необхідність запуску чи завершення.
Підведемо підсумки
У цій статті я постарався розкрити наш досвід реалізації турнірів. Цілком можливо, що будь-яка складова частина цієї фічі буде видозмінена для задоволення нових потреб продукту. Але навіть у такому вигляді турніри вже можуть приносити свій внесок у гру, мають досить гнучкі налаштування, які можна змінювати без труднощів.
Варто зазначити, що «адмінка» є не обов’язковим інструментом, фічу можна реалізувати і без неї, використавши конфігурацію через змінні середовища (хардкодити не раджу). Але наявність такого сервісу в екосистемі гри мінімізує участь розробника і спрощує керування LiveOps-ом гри спеціально виділеною людиною — LiveOps-менеджером.
Також я окремо акцентував на користі та великому спектрі можливостей, які дає Redis (насправді їх іще більше).
Ігровим проєктам, що не мають повноцінного бекенду, для реалізації турнірів можна скористатися готовими рішеннями від Google Play Services, Game Center (iOS), Facebook. Оскільки в нас він уже був і всі дані гравців зберігаються на нашому сервері, то послідовним рішенням була реалізація з нуля власними силами.
20 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів