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


Не так давно нашей фронтенд-команде предоставилась возможность попробовать себя в роли gamedev. Очень коротко, задача такая: сделать самую настоящую 3D-игру, да так, чтобы можно было поиграть, просто открыв браузер. Даже мобильный. Даже в WebView.



В этом посте я постараюсь рассказать о том, как мы спроектировали архитектуру игры, с какими проблемами столкнулись, используя один из самых популярных и актуальных технологических стеков — React + Redux, и какими «хорошими практиками», вероятнее всего, придётся пожертвовать, если вы для схожих задач выберете этот же стек.


Как всё начиналось


Год назад на корпоративном портале в разделе для RnD появился зазывающий пост, который начинался такими словами: «Делать стенд на CodeFest — наша добрая традиция. Сделать лучший стенд — наша в меру амбициозная цель». Далее следовал призыв погенерить идеи, ну и, конечно же, реализовать их. Итогом совместного брейншторма и последующих вечерних посиделок стала игра с гордым именем Gods in the sky.


Фото из группы CodeFest в ВК / https://vk.com/codefest. Исходник фото тут.
Фото из группы CodeFest в ВК.


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


Как всё продолжалось


Несколько лет подряд 2ГИС готовит к Новому году спецпроекты, которые либо знакомят пользователей с нашим функционалом, либо просто напоминают о 2ГИС как о тёплой и ламповой компании. Часть миссии нашей команды как раз в том, чтобы создавать такие проекты, а потому первичный бриф упал на проработку к нам в начале октября.


Если коротко: самолёты меняем на Санта-Клаусов, пули — на снежки, которые не убивают. А да, ещё в эту игру поиграет N сотен тысяч человек, а дедлайн — 1 декабря. Ну, край — 15 декабря. А, это воскресенье, так что 16-е. Если не вдаваться в детали, то задача кажется очень простой, однако очень быстро всплыли большие но:


  1. Нагрузка на сеть в исходной архитектуре позволяла с комфортом играть не более 30 игрокам одновременно. Этого хватало для CodeFest, но было недостаточно для наших реалий — десятки тысяч игроков в сутки, пусть и в разных «комнатах». Получается, надо перепиливать.
  2. «Железные» ресурсы. Примерная оценка показала, что нам нужно для наших бэкендов:
    • 90 ядер CPU;
    • 120ГБ ОЗУ;
    • 270 Мбит/c — ширина канала на отправку, 675 Мбит/c — на получение в наших ДЦ.
  3. Даже после всех оптимизаций скорость интернета у пользователей должна была быть не меньше 4,2 Мбит/c. Это не так много, но есть далеко не у всех. И цена этого — использовать UDP при помощи WebRTC, а это поддерживается далеко не всеми браузерами. Да и технология нами совсем не изучена.
  4. Делать бэкенд с ресурсами из п. 2 нужно было команде со специфическим для геймдева опытом, чтобы в реальном времени предусмотреть обсчёт действий всех игроков, проверять на читерство, банить и пр. В общем, это не мини-бэкендик на Node.js накидать за пару дней, это уметь надо.

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


А ещё мы не были уверены, что успеем всё это сделать к Новому году. Поэтому после нескольких раундов обсуждений мы пришли к тому ТЗ, результатом которого стала игра «Авиаторы». Она до сих пор открыта и играбельна — пробуйте.


Стек технологий: что и почему


Пока мы неспешно согласовывали ТЗ, искали человеческие ресурсы бэкенда, завершали активные проекты в разработке, внезапно подкрался ноябрь. К моменту, когда мы, начали писать первые строки кода на календаре было 5 ноября, а до вполне реального, никак не сдвигаемого дедлайна оставалось 40 календарных дней.


В команде ровно три фронтенд-разработчика. Искать наиболее подходящий нам фреймворк, систему организации и манипуляции стейтом приложения под задачи геймдева было некогда, а погружаться всей команде в этот стек — тем более. Поэтому именно с точки зрения фронтенда мы решили взять постоянно используемые нами React и Redux. Для манипуляции c 3D-пространством, по совету тех, кто делал исходный Gods in the sky, выбрали three.js.


Если коротко, то во всех случаях выбрали хорошо знакомые наборы инструментов.


Как организовали инфраструктуру


Инфраструктуру всего проекта можно представить простой схемой:



То есть пользователь приходит на лендинг или на игру, ему отвечает написанный фронтендерскими руками бэкенд на Node.js. Его задачи максимально просты:


  • обработать юзерагент пользователя и решить, добавить ли ему полифиллов в ответ или сразу отправить на страницу-заглушку для старых браузеров (в нашем понимании, это был в том числе IE11);
  • принять клиентские логи и записать свои access-логи;
  • сформировать ту самую html-ку которая будет отдана браузеру.

Никакого SSR нет. В платформе под управлением kubernetes это выглядит как два абсолютно разных приложения, но с точки зрения кода всё одно. Поэтому просто запускается один и тот же сервер, но с разными аргументами. Так сделано для того, чтобы отказ, например, лендинга, не приводил к отказу самой игры (и наоборот). А ещё так удобнее мониторить приложения и анализировать логи. Раздача всей статики осуществляется с отдельного сервиса под управлением Nginx.


Про организацию стейта


В этом разделе наконец посмотрим на код. Весь проект на TS, поэтому воспользуемся интерфейсом AppState, чтобы представить структуру:


interface AppState {
    gameState: GameState;
    gameObjects: GameObjects;
    data: Data;
    requests: RequestsState;
}

Ветка requests — это самый обычный объект, каждый ключ которого — строка. Он однозначно идентифицирует запросы, значение — его состояние. Неинтересно.


Ветка data — это данные, которые в основном запрашиваются один раз на старте игры, например, список регионов, что доступны для «полетать». Тоже неинтересно.


В GameState есть два интересующих нас поля:


  1. stage: 'factoids' | 'citySelect' | 'flyShow' | 'game' | 'results'; — стадия игры. В нашем случае стадии такие:


    • показ короткой справки;
    • выбор города;
    • облёт сцены;
    • фаза игры;
    • экран результатов.

  2. elapsedTime: number; — счётчик количества миллисекунд, проведённых в фазе игры, чистое игровое время без учёта пауз.



Таких этапов, как factoids и flyShow, изначально не было ни в ТЗ, ни в задумках — мы просто опирались на elapsedTime. Если он меньше либо равен 0, то показывали этап выбора города. Если больше либо равен максимальному времени игры, то это этап результатов. Потом мы поняли, что нужен облёт игровой сцены, чтобы дать понять игроку, где и что раскидано на карте. А ещё некоторое время спустя поняли, что надо дать нашим игрокам краткую инструкцию. И начались костыли с флагами… В какой-то момент работать с этим стало нереально, поэтому был срочный рефакторинг на выраженные стадии.


И, наконец, самая главная ветка:


interface GameObjects {
    user: UserInGame;
    gifts: { [id: string]: GiftInGame; };
    boosts: { [id: string]: Boost; };
    bombs: { [id: string]: Bomb; };
}

В этой ветке хранится информация о всех объектах, которые есть на сцене: сам игрок, подарки, бусты и бомбы. Там лежит буквально вся необходимая информация — от геокоординат каждого объекта до углового ускорения.


GameObjects — самая частоизменяемая ветка в игре. Меняется она столько раз, сколько FPS держит устройство. У одного из моих коллег на ноутбуке с очень мощным GPU мы наблюдали 100 обновлений в секунду. На Redmi Note 7 нам удалось удерживать 40 FPS стабильно (что более чем достаточно для этой игры). На моём стареньком и очень тормозном MI 5S выдержали 30 FPS, но с просадками до 20 при анимациях, что тоже не так уж плохо.


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


Как мы загнали себя в угол


В наших проектах мы стараемся следовать стайл-гайду Redux, за исключением одного правила — мы не пишем редьюсеры как конечные автоматы. За три года работы с Redux я не помню ни одной проблемы, которой бы помог именно такой подход. Так зачем писать больше кода? Ни в коей мере не призываю вас игнорировать этот совет Redux, просто проясняю наши привычки.


Из раздела выше следует, что у нас четыре типа объектов на сцене. Под каждый тип объекта мы завели классы, управляющие сменой состояния объектов этого типа по ходу игрового времени. Между собой мы называли эти классы «бихевиорами» (от англ. behavior — поведение). Уж не знаю, как такое принято называть в игровой индустрии.


Под каждый объект создавался свой экземпляр нужного класса. Общая черта каждого класса — публичный метод tick, который принимал одним из аргументов тот самый сдвиг игрового времени в миллисекундах. Все бихевиоры помещались в одну коллекцию, которую после каждого сдвига игрового времени обходил итератор. Он вызывал публичный метод tick с каждым элементом. По результатам своих действий каждый бихевиор:


  • не делает ничего, если состояние объекта, который он обсчитывает, не поменялось. Например, если бомба далеко, её позиция не поменялась, то и делать ничего не нужно.
  • формирует экшен (обычный Redux-экшен), которым обновляется состояние объекта.
    Например, если игрок налетел на бомбу и она «взрывается» прямо сейчас.
  • формирует экшен, которым объект удаляется из gameObjects, а сам бихевиор убирается из коллекции. Например, когда бомба перестала «взрываться», то есть закончила своё действие по отношению к игроку.

Получалось примерно по 25 экшенов обновления на каждый тик (сдвиг игрового времени).


Милый северный зверёк подкрался к нам незаметно. Сначала были написаны все бихевиоры (и не одномоментно), потом к этому прицепили React, а потом уже только вышли на этап рендеринга сцены. В какой-то момент команда заметила что вентиляторы их макбуков собираются развить первую космическую, а у меня на моем линуксе «чё-то все дёргается» (встроенная видеокарта, как оказалось, такие вещи не любит).


Ещё один участник команды в тот день. Фото взято с pixabay.com
Ещё один участник команды в тот день. Фото взято с pixabay.com


Сделав глубокий вдох, запустили FPS-метр из девтулзов Хрома и увидели совсем не радостное число 13. Уже было ясно, что нас ждёт, но я все-таки подцепил свой MI 5S, запустил тот же FPS-метр — там вообще было 5-6, что не лезло ни в какие ворота. Зверёк растянул ухмылку пошире и стал немного потолще.


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


В итоге каждую секунду при FPS=30 получалось, что:


  • Redux был должен FPS * ~[число экшенов] ~= 750 раз породить новый экземпляр стейта, не мутируя старый, ибо так делать не стоит.
  • React’у нужно обработать FPS * ~2 * ~[число отреагировавших на смену стейта компонетов] изменений состояний компонента.
  • three.js приятно порадовал, производительность библиотеки была на высоте, и с WebGL общение было быстрым. Тут особо не докопаться, оптимизировать нужно в другом коде.
  • Наши бихевиоры все вместе (т.е. порядка 150 штук) отрабатывали за 4-6 мс, т.е. никакие оптимизации не могли дать радикального эффекта.
  • CSS-анимации сильно влияют на производительность страницы.
  • Мобильный контрол управления создавался при каждом тапе по экрану заново, что тоже в моменте давало просадку FPS на 2-4 кадра (даже на ПК в режиме эмуляции, «настоящей» мобилке и так было плохо).
  • Часть функциональных компонентов мы в пылу разработки позабыли обернуть в React.memo, из-за чего они ререндерились даже тогда, когда можно было этого не делать.

Про React чуть детальнее:


  • * ~2 вышло, потому что все почти все компоненты подписаны на обновления стейта через connect. Сам connect — это вообще-то HOC, тоже компонент, притом чистый.
  • Такое количество смен состояний стейта приводило к тому, что React делал два–четыре прохода рендера (render pass), что ещё больше загружало и CPU, и GPU.

Садим песца на диету


Опустим очевидное: компоненты оборачиваем в React.memo там, где забыли, мобильный контрол переделываем «правильно», чтобы он просто скрывался стилями, а не пропадал из DOM-а, анимации делаем максимально простые.


Остаются неочевидные проблемы, которые лежит в плоскости React + Redux, и основные усилия по оптимизации нужно инвестировать именно туда.


Про Redux


У нашего способа работы с Redux очевидная проблема — слишком частое пересоздание стейта без особого смысла. Нам по факту нужно ~1 раз поменять стейт на каждый тик, для нашей игры это будет самое то. ~1, потому что некоторые ветки проще всё-таки поменять разными экшенами — это вопрос удобства. Так у нас появляется экшен-креатор, суть которого заключалась в том, чтобы ветка GameObjects обновлялась нужным образом, реагируя только на него:


export function setNextGameObjects(payload: GameObjects) {
    return {
        type: 'SET_NEXT_GAME_OBJECTS' as const,
        payload,
    };
}

После этого разделяем редьюсер ветки GameObjects на две части:


  1. Первая обрабатывает только те экшены, которые летят редко или очень редко. Здесь же экшен SET_NEXT_GAME_OBJECTS.
  2. Все те экшены, которые наши бихевиоры выбрасывали, часто переезжают в отдельный редьюсер этой же ветки GameObjects. Его мы к стору Redux не подключаем, а просто используем как функцию где-то вовне. Результатом действия нового редьюсера является пэйлоад для экшена SET_NEXT_GAME_OBJECTS.

Выглядит использование нового фейкового редьюсера примерно так:


let state = store.getState().gameObjects;
for (const action of this.collectedActions) {
    state = gameObjectsFakeStateReducer(state, action);
}
store.dispatch(setNextGameObjects(state));

collectedActions — экшены, которые насоздавали бихевиоры. Организация способа помещения в одну коллекцию — задача примитивная, вариантов масса.


Итог этих действий — вместо ~25 экшенов на каждый тик сразу выигрываем в 10 раз: у нас теперь ~2 экшена в тике. Но особого профита это не даёт, так как gameObjectsFakeStateReducer как функция продолжает работать медленно. Ей все ещё нужно 25 раз создать абсолютно новый объект стейта. Выхода в условии ограниченности временных ресурсов мы не нашли иного, кроме как отказаться от иммутабельности стейта.


Насколько это помогло? А вот вам очень синтетический, но все же perf-тест. Оттуда видны 3 вывода:


  1. Если переехать на компиляцию с таргетом ES2018, то в Хроме получим прирост скорости работы редьюсеров в ~50 раз относительно компиляции с таргетом < ES2018 (правда, не в Firefox — тот прироста не даёт). Это связано с тем, как работает оператор spread.
  2. Переход на мутабельный стейт с использованием Object.assign даёт прирост производительности операций над ним примерно в 300 раз.
  3. Переход на мутабельный стейт даёт прирост производительности операций над ним примерно в 500 раз, если просто «в лоб» выставлять значения нужных полей.

Первый путь, конечно, неплох, но позволить мы его себе не можем — далеко не все клиенты смогут в ES2018, рисковать не стоит. Плюс всё ещё останутся проблемы у Firefox.


Путь №3 самый быстрый, но код будет… неприятный. И расширять набор полей будет ну совсем уж неудобно.


В итоге останавливаемся на мутирующем Object.assign. gameObjectsFakeStateReducer становится gameObjectsFastStateReducer, а под капотом происходит как-то так:


switch (action.type) {
    case 'PARTIAL_GIFT_STATE_PATCH':
        if (!state.gifts[action.payload.id]) {
            return state;
        }
        Object.assign(state.gifts[action.payload.id], action.payload);
        break;
    // case, default ...
}

Плохо? Конечно, кто б спорил. Но работает, и переделка заняла пару часов. Итого переделка одной ветки стейта на антипаттерн даёт нам обработку 25 экшенов за 1 тик за 2-4мс (причем при четырёхкратном замедлении CPU). Пруф из профайлера:


image


Скринов с профайлингом «старой» версии не осталось, поэтому придётся поверить, что совокупная оптимизация Redux дала прирост к производительности всего проекта раз в 10 на этапе формирования нового стейта после каждого тика. Конечно же, из-за появившихся мутаций стейта, пришлось подшаманить некоторые компоненты, которые через connect были подписаны на ту или иную смену ветки стейта gameObjects. Но это мелочи, хоть и не очень приятные.


Про React


Вообще про оптимизации React написано уже очень много статей. Основной их посыл — свести к минимуму изменения DOM. По факту это означает, что нам следует стремиться к минимизации согласований, а делать это можно разными способами — reselect, React.memo/React.PureComponent, вручную написать shouldComponentUpdate и прочее. От минимизации количества согласований и оттолкнёмся.


Мы уже поработали с количеством экшенов, которые мы бросаем на каждый тик, и их теперь в обычных случаях ~2, в исключительных — до 5. Но это не давало гарантии, что React обработает близкие во времени изменения за один шаг свёртки, т.е. выполнит согласование и отобразит изменения виртуального DOM в реальный.


Однако этот процесс можно взять под контроль при помощи функции batch из пакета React + Redux. Собственно, суть этой функции — гарантировать обработку ряда синхронных изменений стейта за одну фазу согласования. Круто же, разве нет?


Но этого мало, у нас все ещё остаётся ряд компонентов, подписанных через connect на обновление данных из стейта. И они могут меняться очень часто, хотя делать этого не надо.


Тут нужен пример. Например, есть компонент <ScoreBoard />, который


  1. показывает оставшееся время;
  2. показывает очки, набранные игроком.

Это был классический React.PureComponent, который обёрнут в connect и в mapStateToProps. Он забирает из стейта поля elapsedTime и score. Score меняется нечасто, а elapsedTime — после каждого тика. Преобразование elapsedTime в минуты-секунды происходит в методе render. Это значит, что сам компонент ререндерится по частоте FPS, то есть в каждом кадре у этого компонента выполняется shouldComponentUpdate. У HOC’a connect тоже выполняется — shouldComponentUpdate в каждом кадре.


Вот варианты, как можно «оптимизировать» этот компонент и добиться наибольшей эффективности, как в этом приложении:


  1. Написать свою функцию shouldComponentUpdate, в которой elapsedTime будет сравниваться как секунды (с последующим приведением к привычному формату времени). Это вариант, но он не избавит от жизненного цикла HOC’a connect.
  2. В mapStateToProps сразу вернуть секунды. За счёт того, что у нас чистый компонент, фактический ререндер будет происходить один раз в секунду. Но это всё ещё не избавляет нас от ЖЦ connect’a.
  3. Перейти на хуки и использовать useSelector. Вариант тоже хорош, т.к. селектора будет всего два. Это, вероятно, будет быстрее, но не факт.
  4. Посмотреть на родительский компонент — он тоже обёрнут в connect, и он — самый верхнеуровневый компонент приложения. То есть наверняка он и останется таким, его не оптимизируешь. А значит он может передать нашему ScoreBoard готовое значение в секундах.

Сказано — сделано! У ScoreBoard’a больше нет connect, зато есть две пропсы, прилетающие сверху от родительского компонента. Родительскому компоненту нужно не забыть возвращать из mapStateToProps сразу секунды, чтобы он сам по себе не стал слишком часто обновляться.


Ну и так по аналогии со многими компонентами, использующими connect. После этих манипуляций осталось всего два компонента, которым действительно нужно получать актуальное состояние как можно чаще — компонент-обёртка над нашей картой и компонент-обертка над canvas-элементом, в который three.js рендерит все игровые объекты (бомбы, самолёт, и т.д). Если посмотреть на логику методов render у обоих компонентов, то там будет:


return <div id="map" className={s.map} ref={mapRef} />;

и


return <canvas id="scene" className={s.scene} ref={sceneRef} />;

Что бы ни происходило со стейтом приложения, на DOM эти компоненты влияют ровно один раз за свой жизненный цикл — когда происходит маунт. А это означает, что весь процесс согласований React им не нужен. Весь ререндер сводится к тому, чтобы отработал метод componentDidUpdate, в котором будем выполнять некие действия. Строго говоря, код componentDidUpdate компонента карты выглядел примерно так:


public componentDidUpdate() {
    const { geoPos, orientedRotationQuanternion } = this.props;
    const { map } = this.state;
    map.setQuat(orientedRotationQuanternion);
    map.setCenter([geoPos[0], geoPos[1]], { animate: false });
    map.setZoom(getMapZoomFromHeight(geoPos[2]), { animate: false });
}

Дальше задача сводится к тому, чтобы componentDidUpdate у этих компонентов более никогда не вызывался. Шаги для этого такие:


  1. избавляемся от connect. Выпиливаем с корнями, вместе с mapStateToProps;
  2. пишем у компонента public shouldComponentUpdate() { return false; } — компонент из «чистого» превращается в «обычный»;
  3. достаём стор Redux в компоненте (вот как это можно сделать) или просто useStore в случае хуков;
  4. подписываемся на изменений состояния приложения (store.subscribe(() => { /* … */}));
  5. делаем требуемые манипуляции в колбэке на изменение состояния приложения;
  6. готово!

Получается примерно так:


store.subscribe(() => {
    const state = store.getState();
    const { map } = this.state;
    const { geoPos, orientedRotationQuanternion } = state.gameObjects.user;

    map.setQuat(orientedRotationQuanternion);
    map.setCenter([geoPos[0], geoPos[1]], { animate: false });
    map.setZoom(getMapZoomFromHeight(geoPos[2]), { animate: false });
}); 

Теперь компонент никогда не ререндерится, не участвует в согласовании и продолжает делать то, что от него требуется.


Я затрудняюсь сказать, является ли такой подход каким-то ярким антипаттерном, но для обычных приложений никогда не стал бы так делать.



Результат работы над реактом. Тонкие оранжевые полоски — React Tree Reconcilation + Commit



React Tree Reconcilation + Commit под микроскопом


Заключение


Связка React + Redux подходит для разработки игр, если вы:


  • работаете в одиночку или в команде и действительно понимаете, как работают эти технологии, жизненный цикл компонентов помните на зубок, а слово «согласование» в контексте React не вызывают у вас необходимости срочно читать, что это такое;
  • имеете маломальский опыт в оптимизации приложений на этом стеке;
  • готовы не просто отойти от «лучших практик», но и внедрять откровенные антипаттерны с пониманием, к чему это приведет;
  • имеете опыт в мобильной разработке, если игра адаптирована для маленьких экранов.

Подумайте ещё раз, вдруг в вашей ситуации есть варианты лучше. Если нет, берите именно этот стек. Если хотя бы один пункт не выполняется, то я бы не стал брать эти технологии. Что брать на замену? Предлагайте варианты в комментариях.


Настало время полезных советов:


  1. На мой взгляд, разработка игры является тем случаем, когда архитектуру нужно развивать не планомерно, но на старте работ надо прикинуть, что и как будет. В этот момент сразу стоит выделить одну или несколько веток в стейте, которые будут часто меняться.
  2. Стремитесь, чтобы React’у не приходилось по 30 раз в секунду выполнять согласование для множества компонентов. В этом вам поможет первый пункт: не стоит завязывать компоненты на частоизменяемые значения стейта.
  3. Явно выделите стадии вашего приложения, потом это поможет отсутствием костылей.
  4. Изолируйте игровое время от физического. Дайте возможность подписываться кому угодно на изменение этого времени. В игре банально могут быть паузы и игровое время в этот момент останавливается, а физическое продолжает идти.
  5. Вся логика в игре должна основываться на игровом времени.
  6. Чаще запускайте вкладку Performance в вашем Хроме — так вы обнаружите проблемы раньше. Не забывайте устанавливать тротлинг CPU — так вы будете ближе к пользователям, далеко не у всех Intel i9.
  7. Найдите среди ваших тестовых девайсов какой-нибудь среднестатистический Андроид: в диапазоне цен 12–20К и с возрастом два–три года. Скорее всего, это ваш пользователь.
  8. Если найдете ПК со слабой видеокартой, будет очень хорошо. Если бы у нас у всех были маки, подозреваю, что мы бы сильно позже увидели проблем с производительностью. На таких устройствах очень удобно профилировать, и проблемы производительности на них заметнее.
  9. Вам точно будет легче, если вы на самом старте договоритесь с заказчиком о том, что не поддерживаете старые браузеры. Люди с IE11 вряд ли ваш пользователь, а проблем это доставит. Мы вообще отказались работать с Хромом меньше 70-го и Edge меньше 18-го (по мажорным версиям).
  10. Технология WebGL нова и далеко не всеми браузерами поддерживается на одном уровне качества. Где-то работает быстро, как в Хроме, а где-то, например в Firefox на linux, есть очевидные проблемы производительности. Будьте к этому готовы. Какую политику выбрать — решать вам. Можете сразу отправлять в бан-лист, например. Мы сделали специальную плашку, которая предупреждала игроков о том, что «могут быть проблемы с производительностью на вашем устройстве», и просьбой попробовать другую конфигурацию.

Проблемы, которые вас точно ждут:


  1. Firefox. Он просто медленнее Хрома. В частности, в моментах работы с WebGL, но не только — посмотрите пример с оператором spread. FPS в Firefox стабильно раза в полтора ниже, чем у Chrome. Благо, это не самый популярный браузер, тем более на мобилках.
  2. IPhone. Разработчики Safari ведут себя на уровне IE6 в 2К20 и наглейшим образом отказываются чинить доисторические баги своей платформы. В айфонах банально не может нормально работать событие ресайза, что очень сильно стреляет, когда пользователь переворачивает экран. Про выезжающую снизу плашку, которую нужно отдельно обрабатывать, я вообще молчу. Ну и их политики безопасности. Нам так и не хватило времени победить звук в айфонах — нельзя взять и просто так проиграть какой-либо звук.
  3. Читеры. Они точно будут. Причём некоторые из них могут быть очень наглыми. У нас был случай, когда забаненный игрок писал в техподдержку о том, что все призы достанутся сотрудникам компании (хотя, конечно, правилами это явно запрещено). Предусмотрите античит.
  4. Недовольные результатами. Некоторые могут видеть читеров вообще в любом, кто набрал больше, и могут завалить жалобами и упрёками. Хотя нередко результат, который они посчитали читерским, и близко не был к тому, который мы сами «набивали» без всяких читов ещё во время тестов.
  5. Если аудитория сильно отличается по возрасту и игровым навыкам (а это наш случай), вам придётся уместить в трёх–пяти картинках всё знание о том, что и и как надо делать в игре. Самостоятельно эта часть аудитории разбираться не будут.

Проблемы и трудности были, но в итоге получилось одно приложение, которое работает на всех устройствах. Не нужны отдельные приложения под iOS, Android и Web и три команды разработки под каждую платформу.