JS-битва: как я написал свой eval()

    Вы можете помнить Александра Коротаева по браузерной версии «Героев Меча и Магии»: расшифровка его доклада о ней собрала на Хабре громадное количество просмотров. А теперь он сделал игру, ориентированную на программистов: играть в неё надо JS-кодом.

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



    В итоге Александр снова сделал доклад на HolyJS, а мы (организаторы конференции) снова подготовили для Хабра текстовую версию.


    Меня зовут Александр Коротаев, я работаю в Tinkoff.ru, занимаюсь фронтендом. Помимо этого, я в составе сообщества Spb-frontend, помогаю организовывать митапы. Делаю подкаст Drinkcast, мы приглашаем интересных людей и обсуждаем различные темы.

    В чём суть игрушки? Сначала там надо выбирать юнит из предложенных, это такая RPG-система: у каждого юнита есть свои сильные и слабые стороны. Вы видите, каких юнитов выбрал противник, и выбираете в отместку ему. Затем нужно написать на JavaScript скрипт поведения своей армии — проще говоря, сценарий «что каждый юнит должен делать на поле битвы».

    Это делаетеся в режиме дебага: фактически вы дебажите код, а затем оба противника пушат свой код, и начинается битва между двумя сторонами.

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

    В действии всё это выглядит так:


    А как выглядела работа над ней? Много труда ушло на документацию. Когда игрок садится за ноут, он видит документацию, в которой всё довольно подробно описано.

    У меня ушло много времени на её вёрстку, доработку и опрос среди людей, понятна ли она. В итоге получилось понятно для сишников, джавистов и прочих разработчиков, которые вообще ничего не знают про JS. Этой игрушкой можно даже пропагандировать JavaScript: «Это не страшно, смотрите, как на нём можно писать, даже что-то фановое получается».



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

    Из технологий я использовал самый популярный игровой движок из мира JS — Phaser. Самый большой и популярный часто используемый Ace Editor. Это редактор в вебе, очень похож на Sublime или VSCode, его можно встроить в веб-страницу. Еще я использовал RxJS, чтобы работать с асинхронными взаимодействиями от разных пользователей и Preact, чтобы рендерить html. Из нативных технологий особенно часто работал с workers и websocket.





    Игры для программистов


    Что вообще такое игры для программистов? На мой взгляд, это игры, где надо кодить, потом получить какой-нибудь веселый результат, который можно с кем-то сравнить, это сражение. Из таких доступных онлайн-игр я знаю «Elevator Saga» — вы пишете скрипты для лифтов по определенным параметрам. «Screeps» — про биологию, молекулы, пишете скрипты для них.

    Есть еще игрушки, которые иногда бывают на конференциях. Самая популярная из них «Code in the Dark», у нас тоже сегодня она была представлена. Кстати, «Code in the dark» в чём-то вдохновила меня на это всё.



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

    Геймификация. Мы проводили это не только среди практикующих программистов, но и среди учащихся. Мы проводили такие матчи в институтах в «День карьеры». Нам нужно было как-то посмотреть, какие там ребята, годятся нам или нет. Мы использовали геймификацию, чтобы завлечь людей в процесс, посмотреть, как они действуют, что они делают. Они играли и были отвлечены, но это давало нам информацию. Некоторые пушили код, даже ни разу не запустив его, и было сразу видно, что в разработку им пока рановато.



    Как это выглядело в первой версии. Это был главный экран и два ноутбука для игроков. Все это связывалось с сервером, сервер хранил State и шарил его между всеми подключенными клиентами. Каждый экран был подключенным клиентом. Ноутбуки игроков были интерактивными экранами, с которых можно было этот state изменять. Экраны жестко привязаны к одному серверу.

    История о нехватке времени


    Первая история, с которой я столкнулся в этой разработке, это история о том, что у меня было очень мало времени. Буквально за пять минут была придумала идея, за пять секунд было придумано название, когда потребовалось создать репозиторий на GitHub. Я мог потратить на это всё всего лишь четыре часа вечером, отнимая их даже у жены. В итоге у меня оставалось всего три недели до конференции, чтобы реализовать это хоть как-то. А начиналось все так, что просто нужно было выдумать идею в рамках брейншторма, за пять минут родилась идея: «Давайте писать какой-нибудь искусственный интеллект для RPG на JS». Это круто, весело, я смогу это реализовать.



    В первой реализации на экране был редактор кода и экран битвы, на котором была сама битва. Были использованы Phaser, Ace Editor и чистый Node.js как сервер без всяких фреймворков. О чем я потом пожалел, правда, но тогда от сервера ничего особого не требовалось.



    Я успел реализовать тогда Renderer, который рисовал саму битву. Самой сложной частью оказалась sandbox для JS кода, то есть песочница, в которой все изолированно должно было выполняться для каждого игрока. Также был State sharing, который приходил с сервера. Игроки как-то меняли state, закидывали на сервер, сервер рассылал остальным по веб-сокетам. Сервер был источником истины, и все подключенные клиенты доверяли тому, что приходило от сервера.

    Песочница


    Что же такого сложного в реализации песочницы? Дело в том, что песочница — это целый мир для кода, в котором код должен существовать. То есть вы создаете ему примерно такой же мир, как и вокруг, но только состоящий из каких-то условностей, из API, с которым можно взаимодействовать. Как это реализовать на JS? Кажется, что JS к этому не способен, он настолько дыряв и свободен, что просто не получится сделать так, чтобы полностью заключить код пользователя в какую-то коробочку, не используя отдельную виртуалку с отдельной ОС.



    Что должна делать песочница?

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

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



    Что я хотел сказать про асинхронность? Дело в том, что в JS она базово реализована с помощью promises. Тут все для всех понятно, promises отличная штука, отлично работают, почти всегда были у нас. Уже много лет все знают, как с ними работать, но эта игрушка была не только для джаваскриптеров. Представляете, если я начал объяснять джавистам, как писать код битвы с помощью promises? Как делать then-then-then, почему иногда не надо… Что делать с условиями или циклами?



    Можно, конечно, пойти лучшим путем и взять синтаксис async/await. [Слайд 8:57] Но представляете тоже, как программистам не из мира джаваскрипта объяснить, что почти перед каждой строчкой нужно ставить await? В итоге лучший путь работы с асинхронностью — это вообще ее не использовать.



    Делать максимально синхронный код и максимально простой API, похожий на практически любой язык программирования. Мы делаем игрушку не только для людей, пишущих на JS, мы хотим сделать ее доступной для всех, кто умеет кодить.



    Нам это все надо как-то запустить. Пользователь пишет код, и нам нужно его выполнить, подвигать юнитов на карте. Первым делом приходит в голову, что нам нужен eval() плюс нерекомендуемый оператор with, который не рекомендован к использованию на MDN. Это будет работать, но тут есть свои проблемы.



    Например, у нас есть код, который полностью рушит всю нашу идею, который вообще не дает нам дальше ничего выполнять. Это код, который блокирует выполнение. Нужно как-то сделать так, чтобы пользователь не смог заблокировать приложение. Например, бесконечный цикл может все сломать. Если alert() и prompt() ещё можно переопределить, то бесконечный цикл мы не можем переопределить вообще.

    eval() is evil


    Так мы доходим до того, что eval() — это зло. Не зря его называют злом, потому что это коварная функция, которая фактически вбирает в себя все самое свободное и открытое, что есть в JS, и оставляет нас полностью беззащитными. Одной простой функцией мы делаем огромную дыру в своем приложении.



    Но что, если я скажу вам, [голосом Стива Джобса] что мы переизобрели eval()?

    Мы сделали eval() на других технологиях, он работает почти так же, как тот же eval(), который у нас уже есть. Фактически у меня в коде есть функция eval(), но реализованная с помощью Workers, оператора with и Proxy.



    Почему workers? Дело в том, что они создают отдельный поток исполнения, то есть JS у нас однопоточный, но благодаря workers мы можем обзавестись еще одним потоком. Это дает нам много преимуществ. Например, в рамках тех же бесконечных циклов мы можем оборвать поток, созданный через worker, из главного потока, пожалуй, это главное, почему я использовал workers. Если worker успел отработать быстрее, чем за одну секунду, мы считаем его успешным, получаем его результат. Если нет, то мы его просто обрываем. Фактически пользовательский код по какой-то причине не сработал, произошли какие-то непонятные ошибки или он затормозился из-за бесконечного цикла. Многие сегодня пытались писать while(true), я предупреждал, что это не будет работать.



    Чтобы написать наш worker, нам всего лишь нужно в конструктор worker скормить скрипт, который будет загружен по http. Внутри скрипта мы должны сделать обработчик сообщения из главного потока. При помощи функции postMessage() внутри worker мы можем направлять сообщения в главный поток. Таким образом мы делаем общение между двумя потоками. Довольно удобный простой API, но в нем чего-то не хватает, а именно пользовательского кода, который мы должны исполнять в этом worker. Не будем же мы каждый раз генерировать какой-то файл скрипта на сервере и скармливать его воркеру.



    Я нашел способ при помощи URL.createObjectURL(). Мы делаем некий блок и скармливаем его в src worker'а. Таким образом, он выгружает наш код прямо из строки. Кстати, этот путь работает с любыми объектами в DOM, которые имеют src — image так работает, например, и даже в iframe можно загрузить html'ку, просто сгенерировав её из строки. Довольно круто и гибко, я считаю. Мы также можем управлять worker, просто передавая ему наш специально сгенерированный объект из URL. Мы также можем его терминировать и это уже работает как нам надо, и мы создали первую песочницу.



    У нас дальше идут асинхронные взаимодействия, потому что любая работа с workers — это асинхронность. Какое-то сообщение мы отослали, и не можем синхронно дождаться следующего сообщения, worker нам всего лишь возвращает instance, и мы можем подписаться на сообщения. Мы ловим сообщение при помощи RxJS, мы создаем два потока: один для успешного сообщения из worker, второй для завершения по timeout. Два потока, которыми мы потом управлением при помощи их merge.



    В RxJS есть операторы, которые позволяют нам работать с потоками. Фактически это как lodash для синхронных операций. Мы можем указать какую-то функцию и не думать, как она внутри реализована, она снимает с нас головную боль. Мы должны начать мыслить потоками, оператор merge мержит наши потоки, реагирует на любое сообщение. Он отреагирует и на timeout, и на message. Нам нужно только самое первое сообщение, соответственно, после первого сообщения мы терминируем worker. В случае ошибки выводим эту ошибку, в случае успеха мы делаем resolve.



    Тут все довольно просто. Наш код становится декларативным, сложность асинхронности куда-то уходит. Главное — выучить эти операторы.



    Примерно так мы работаем с Unit API. Я хотел, чтобы Unit API был устроен настолько просто, насколько это возможно. Говоря про JS, многие думают, что это сложно, надо куда-то лезть, что-то изучать. А мне хотелось сделать максимально просто: все в глобальной области, есть только область видимости Unit API, больше ничего. Все для управления юнитами, даже автокомплит.



    [Слайд 15:20] Напрашивается решение, что все это можно засунуть в тот самый запрещённый оператор with. Давайте разбираться, почему же его запрещают.

    Дело в том, что у with есть свои проблемы. Например, with, к сожалению, дырявый за пределами того скоупа, который мы в него прокинули, потому что он пытается смотреть глубже Unit API и заглядывает в глобальный скоуп.


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



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

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



    Дело в том что Proxy смотрит за всеми нашими обращениями к объекту, которые проксируются через новый API, и мы можем управлять тем, как будут вести себя запросы новых данных в этом объекте.



    Фактически with работает довольно просто. Когда мы скармливаем ему какую-то переменную, он под капотом проверяет, есть ли эта переменная в объекте (то есть он выполняет оператор in), и если есть, то выполняет её в объекте, а если нет, то выполняет в верхнем скоупе, в нашем случае в глобальном. Тут довольно просто. Главное, чем нам поможет Proxy — мы можем переопределить это поведение.



    В Proxy есть такая вещь как хуки. Замечательная штука, которая позволяет нам проксировать любые запросы к объекту. Мы можем изменить поведение запроса атрибута, изменить поведение задания атрибута, а главное — можем изменить поведение этого оператора in. Там есть хук has, которому мы можем вернуть только true. Таким образом, мы возьмем и полностью обманем наш оператор with, делая наш API уже куда сохраннее, чем было до этого.



    Если мы попробуем запустить eval(), он сначала спросит, есть ли этот eval() в unitApi, ему ответят «есть», и получим «undefined is not a function». Кажется, это первый случай, когда я радуюсь этой ошибке! Эта ошибка — именно то, что мы должны были получить. Мы взяли и сказали пользователю: «Извини, забудь про всё, что ты знал об объекте window, этого больше нет». Мы уже ушли от части проблем, но это еще не всё.



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



    Нам важно не это, нам важно то, что пользователь может выполнить valueOf() и получить весь наш sandbox. Прямо взять и забрать, посмотреть, что там в нём есть. Этого тоже не хотелось, поэтому в спецификации завели интересную штуку: Symbol.unscopables. То есть в новой спецификации по символам завели Symbol.unscopables специально для оператора with, который запрещён. Потому что они верят, что его кто-то ещё использует. Например, я!



    Таким образом, мы сделаем еще один перехватчик, где мы специально проверяем, а находится ли этот символ в списке всех атрибутов unscopables. Если нет, то возвращаем его, а вот если да — тогда извините, не возвращаем. Мы его тоже не используем. И, таким образом, с with мы не можем получить даже прототип нашего сэндбокса.



    У нас осталось еще окружение Worker. Это что-то такое, что висит в глобальной области и всё равно доступно. Дело в том, что если просто переопределить this, оно будет доступно в прототипе. Через прототип в JS можно вытащить практически всё. Удивительно, но все эти методы все равно доступны через прототип.



    Пришлось просто взять и вычистить весь this. Мы проходимся по всем ключам и все это чистим.



    А дальше мы оставляем маленькую пасхалку для пользователя, который все-таки попробуем вызвать this. Мы берем обычную функцию, главное, что не стрелочную, у которой есть скоуп, и меняем ее скоуп на наш объект, в котором оставляем маленькую пасхалку для особо любопытного пользователя, который захочет вывести в консоли какой-нибудь this или self. Я считаю, что пасхалки — это замечательно, и нужно их оставлять в коде.



    Далее оказывается, что остались только с нашим Unit API. Мы полностью все заблокировали — по сути, оставили whitelist. Нам нужно добавить те API, которые полезны и нужны. Например, API Math, который имеет полезную функцию random, которую многие используют во время написания кода для юнитов.

    Также нам нужна console и многие другие утилитарные функции, которые не несут никакой разрушительной функции. Мы создаем while list для наших API. Это хорошо, потому что если бы мы создали blacklist, мы бы зависели от любого обновления браузера, которое происходит без нашего ведома.



    Создав whitelist, мы можем начать использовать try-catch в нашем коде. Наш обёрнутый код уже ловит ошибки и может отправлять их пользователю, что очень важно для дебага.



    Но дело в том, что консольные методы из Worker никак не проявляют себя в пользовательском коде. То есть, если открыть консоль и перейти в окружение worker, то они будут доступны, но говорить пользователю «открой консоль и посмотри, что у тебя произошло» было бы неправильно. Я решил сделать дружественную консоль для игрока, где игрок даже без опыта в JavaScript мог бы сам видеть в дружественной форме, что произошло.

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



    Для этого я использую магический patchMethod(), который просто патчит консольные методы, заменяя их на обычный postMessage(). Фактически мы шлём postMessage() на каждый console log, error, warn, info. Мы пропатчили все консольные методы и мы знаем, когда пользователь вызывает консоль. Это нужно, чтобы выводить это всё в обычный <div>, который будет красиво всё это показывать, когда у пользователя ошибка, когда код выполнился и когда нет, чтобы всегда давать пользователю фидбек, что произошло с игрой в конкретный момент времени.



    Я много говорил про асинхронность, что все действия у пользователя асинхронны. Все действия у юнита также асинхронны: какой-нибудь проход к какой-нибудь клетке — это обязательно анимация, это проход через несколько клеток, спустя какое-то время нужно выполнять следующее действие. Я, скажем, вообще не использую promises. Дело в том, что внутри этих функций у меня банально actions, которые в виде объектов передают данные дальше.



    После выполнения всего пользовательского кода у меня получается массив actions. Почему именно так, почему у меня не real-time битва? Дело в том, что real-time битва с участием workers дает проблемы с тем, что нужно настраивать общение между клиентом и worker, а у меня было всего три недели. Я реализовал это максимально быстро и более-менее качественно, чтобы ничего не отваливалось. Основная часть этой игрушки, на которой всё держится, работала. Поэтому весь код, который вы пишете на экране игрока, выполняется до битвы. И потом вся битва уже идет по сценарию.



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


    Разрушительный Math.random()


    Всё было хорошо, я всё сделал, у меня остался буквально один вечер до первой конференции, на которой мы запускали игрушку. И тут я вспомнил про Math.random().

    Вроде он работает как надо, но я вспомнил, что игрушка выполняется только на клиенте, а клиентов у нас может быть несколько и на нескольких клиентах может быть запущена одна и та же битва. А это значит, что Math.random() каждый раз будет выдавать что-то разное.



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



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

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



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



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


    Соль получается из пользовательского кода, ещё я докидываю туда юниты. Мы суммируем все индексы символов в коде пользователя и получаем некую соль, из которой потом делаем функцию random(). Это позволяет работать прозрачно для меня и непрозрачно для пользователей и избавляет нас от кучи проблем с тем, что random() на разных клиентах исполняется по-разному.

    По ссылке весь код, который отправляется в worker, он нужен для того, чтобы сделать JS полностью безопасным. Оранжевая «вставка» — это тот самый пользовательский код. который туда инжектится. Вот насколько немало кода нужно, чтобы просто сделать JS безопасным. Там же инжектятся random() и unit API. Путем инжектов я получаю ещё больший код, который отправляется в worker.



    State sharing: RxJS, сервер и клиенты


    Так мы разобрали, что у нас с клиентом. Сейчас поговорим про state sharing, зачем это нужно и как было организовано. У нас state хранится на сервере, сервер должен шарить его подключенным клиентам. [Слайд 28:48]

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

    Левый экран не может изменять state правого игрока, зритель не может менять ничего, а админ может делать всё.



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



    Для начала скажу, почему на сервере тоже RxJS. Все взаимодействия с двумя и более подключенными пользователями становятся асинхронными. Надо ждать результатов от обоих пользователей. Например, оба пользователя нажали кнопку «Готово», надо дождаться, пока оба нажмут, и только потом выполнит действие. Это всё довольно просто разруливалось на RxJS вот каким способом:



    Снова оперируем потоками, есть поток от сокета, который так и назван socket. Чтобы сделать один поток, который следит только за левым игроком, мы просто берём и фильтруем сообщения из этого сокета по левому игроку (и аналогично с правым). Дальше мы можем объединить их при помощи оператора forkJoin(), который работает как Promise.all() и является его аналогом. Мы ждём оба этих действия и вызываем метод setState(), который устанавливает наше состояние как «ready». Получается, что мы ждём обоих игроков и меняем состояние сервера. На RxJS это получается максимально декларативно, поэтому я его и использовал.

    Остаётся проблема с тем, что игроки могут менять state друг другу. Надо им запретить это делать. Всё-таки они программисты, были прецеденты, что кто-то пытался. Создадим для них отдельные классы, которые унаследованы от Client.



    У них будет базовая логика общения игрока с сервером, а в каждом конкретном классе будет его кастомная логика для фильтрации данных.

    Client — это фактически пул connections, соединений с клиентами.



    Он просто их хранит и у него есть поток onUnsafeMessage, который полностью unsafe: ему нельзя доверять, это просто сырые сообщения от пользователя, которые он принимает. Мы эти сырые сообщения записываем в поток.

    Дальше при реализации конкретного игрока мы берем этот onUnsafeMessage и фильтруем его.



    Нам нужно фильтровать только те данные, которые мы можем получить от этого игрока, которым мы можем доверять. Левый игрок может изменять state только левого игрока, соответственно мы берём из всех данных, которые он мог прислать, только state левого игрока. Если не прислал — ладно. Если прислал — берём. Таким образом мы из полностью unsafe сообщений мы получаем safe сообщения, которым мы можем доверять при работе в комнате.



    У нас есть игровые комнаты, которые объединяют игроков. Внутри комнаты мы можем писать те самые функции, которые могут изменять state напрямую, просто подписываясь на эти потоки, которым мы уже можем доверять. Мы абстрагировались от кучи проверок. Мы сделали проверки, завязанные на ролях, и назвали их отдельными классами. Разделили код таким образом, что внутри контроллера, где выполняются важные функции смены state, код стал максимально простым и декларативным.

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

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



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

    А когда из потока уже что-то пришло, мы вызываем функцию setState(). Это довольно просто, тот самый подход, который позволяет нам делать все прозрачно и декларативно. То самое, ради чего я взял на проект RxJS и в чем он мне отлично помог.



    Я создаю потоки, которые у меня довольно понятно названы, с которыми понятно как работать, всё декларативно, вызываются нужные функции, нет возни с большим количеством if и фильтрацией событий, всё это делает RxJS.

    История: из синглплеера в мультиплеер



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

    Мне поступило новое предложение: расширить игрушку на всех программистов в компании, чтобы они могли у себя на компьютерах открывать и играть в неё. Чтобы у нас получился список лидеров, мультиплеер, чтобы они могли играть вместе. Тут я понял, что мне предстоит большой рефакторинг.



    В итоге оказалось не так сложно. Я просто объединил все сущности, которые у меня были, в отдельные комнаты. У меня появилась сущность «Комната», которая могла объединять все роли. Теперь напрямую с сервером общаются не сами игроки, а комнаты. Комнаты уже проксировали запросы напрямую к серверу, заменяя state, и стейт стал у каждой комнаты отдельно.



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

    JS Gamedev и его проблемы



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

    Какие же проблемы есть в разработке игр на JS? Всё отличается от наших бизнес-приложений, где не проблема написать что-то с нуля. Более того, много где это даже приветствуется: сделаем всё своё, сами помните истории с NPM и left-pad.



    В JS Gamedev так поступить невозможно, потому что все технологии для вывода графики являются настолько низкоуровневыми, что писать что-то на них банально экономически невыгодно. Если бы я взялся за эту игрушку и начал писать её с нуля на WebGL, я бы тоже просидел около полугода за ней, просто пытаясь разбираться в каких-то странных багах. Самый популярный игровой движок Phaser снял с меня эти проблемы…



    … и добавил мне новые: 5 мегабайт в бандле. И с этим ничего нельзя было сделать, он вообще не знает, что такое treeshaking. Более того, только последняя версия Phaser умеет работать с webpack и бандлами. До этого Phaser подключался только в html-теге script, это было странно для меня.

    Я прихожу из всяких вебпаков-тайпскриптов, а в JS-геймдеве почти ничего в это не умеет. Всякие модули имеют крайне скудную типизацию или не имеют её вообще, либо в принципе не умеют в webpack, надо было находить способы оборачивать. Как оказалось, даже Ace Editor в чистом виде вообще не работает с webpack. Чтобы начал работать, нужно скачивать отдельный пакет, где он уже обёрнут (brace).

    Примерно так же раньше было с Phaser, но в новой версии сделали более-менее нормально. Я продолжал писать на Phaser и нашел, как сделать так, чтобы все работало с webpack так, как мы привыкли: чтобы была и типизация, и тесты можно было прикрутить к этому всему. Нашел, что можно взять отдельно PixiJS, который является рендером у webpack, и найти для него массу модулей, которые готовы с ним работать.



    PixiJS — замечательная библиотека, которая может рендерить либо на WebGL, либо на Canvas. Более того, можно даже писать код будто для Canvas, и он будет рендериться в WebGL. Эта библиотека умеет очень быстро рендерить 2D. Главное знать, как она работает с памятью, чтобы не попасть в положение, когда память закончилась.

    Я отдельно рекоменду на GitHub репозиторий awesome-pixijs, где можно найти разные модули. Больше всего мне понравился React-pixi. Мы можем просто абстрагироваться от решения проблем с вьюхой, когда мы прямо в контроллере пишем императивные функции для рисования геометрических фигур, спрайтов, анимации и прочего. Мы всё можем разметить в JSX. Мы пришли из мира JSX с нашим бизнес-приложением и можем использовать их дальше. То самое, за что я люблю абстракции. React-pixi даёт нам эту знакомую абстракцию.

    Также советую взять tween.js — тот самый знаменитый движок анимации из Phaser, который позволяет делать декларативно анимацию, чем-то похожую на CSS-анимацию: делаем переход между состояниями, а tween.js решает за нас, каким именно образом подвинуть объект.

    Типы игроков: кто они и как с ними подружиться


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

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

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

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

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

    Тестировать нужно только на живых людях. Ваш глаз замыливается, а тестирование обязательно покажет то, что скрыто. Вы разрабатываете игрушку и погружаетесь всё глубже, пилить какие-то фичи, а они, возможно, даже не нужны. Вы идете напрямую к вашим потребителям, показываете им и смотрите, как они играют, на какие клавиши нажимают. Это даёт вам стимул делать именно то, что нужно. Видишь, что некоторые люди постоянно нажимают Ctrl+S, потому что привыкли сохранять код — ну сделай хотя бы запуск кода по Ctrl+S, игрок почувствует себя комфортнее. Нужно создавать комфортную среду для игрока, для этого нужно любить его и следить за ним.

    Работает правило 80/20: вы делаете демку 20% времени от всей разработки игры, а для игрока это выглядит как на 80% завершённая игра. Восприятие работает так, что основная механика готова, всё двигается и работает, значит, игра почти готова, и разработчик скоро допилит. Но на самом деле разработчику ещё предстоит путь из 80%. Как я уже говорил, довольно долго пришлось работать над документацией, чтобы она была понятна для всех. Я показывал её многим людям, которые говорили свои комментарии, я их фильтровал, пытаясь понять суть высказываний. И много времени у меня ушло на поиск багов.

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

    Напоследок я вам оставляю ссылки:

    24-25 мая в Санкт-Петербурге пройдет HolyJS 2019 Piter, конференция для JavaScript-разработчиков. На сайте уже появились первые спикеры.
    Вы тоже можете подать заявку на доклад, Call for Papers открыт до 11 марта.
    Цены на билеты поднимутся 1 февраля.
    JUG.ru Group
    882,00
    Конференции для взрослых. Java, .NET, JS и др. 18+
    Поделиться публикацией

    Комментарии 29

      +3
      В заголовке:
      как я написал свой eval()

      В статье:
      я запустил eval() в Worker


        0
        Насколько я понял, стандартный eval они отменили с помощью Proxy и сделали свой.
        «Мы сделали eval() на других технологиях, он работает почти так же, как тот же eval(), который у нас уже есть. Фактически у меня в коде есть функция eval(), но реализованная с помощью Workers, оператора with и Proxy.»
          +2
          eval() парсит и исполняет исходный код. Если исходный код передать в конструктор Function или на вход Worker или через _script_ это всё равно нельзя назвать «я сделал свой eval».

          В общем мой комментарий о том, что я ожидал рассказ о парсерах/компиляторах/синтаксических деревьях, а получил eval посредством Worker.
          0
          eval() в Worker

          не соглашусь, это именно запуск кода в другом потоке. eval() это же про рантайм выполнение кода, а тут все уже сгенерировано до запуска воркера.
          0

          Отличный рассказ!
          Кстати, видел уже такую идею про вебворкеры здесь https://github.com/asvd/jailed

            +2
            Читая статью, складывается ощущение что попытки организовать песочницу для потенциально опасного кода через web-workers, proxy и with очень сильно смахивает на костыли. Не проще ли было просто распарсить ast-дерево js-кода и провалидировать обращение только к нужным идентификаторам?
              +3
              А такая валидация вообще возможна?

              const getRandomString = _ => Math.random().toString(36).substr(2);
              window[getRandomString()]("Pwned!");
              //есть вероятность, что будет вызван alert
                0
                а зачем предоставлять возможность обращаться к window? Можно предоставить только разрешенный список идентификаторов для управления персонажем (нужный набор апи) и запрещать остальные идентификаторы и тогда обращение к «window» просто не пройдет валидацию
                  0
                  window тут этот как пример уязвимости, а способы могут быть разными. Там выше по коду есть многовложенный вызов new Function(), который идет из прототипа цифры 4 (запрещать Number теперь?).
                  этот код тоже потенциально вызовет alert()
                  (new Function(`${getRandomString()}("Pwned!")`))()
                    +1

                    нет, числа не запрещать не надо, но можно запретить обращение ко всяким свойствам вроде ._proto_, .constructor и т.д, то есть составить белый список того что мы хотим разрешить — вроде базовых типов, выражений, операторов условий и циклов, и дальше в зависимости от того нужны ли другие возможности js можно также добавить конструкции и методы для создания и работы с объектами, массивами и если нужно также базовый апи Math. Date.. А инструменты статической типизации (typescript или flow) могут вообще протипизировать а мы соотвественно провалидировать еще больше фич js и если где-то встречается "any" или работа с типом который не находится в белом списке то не разрешать этому коду выполниться.
                    В любом случае анализ через ast это единственный надежный способ потому что все остальные способы пытаются предоставить все возможности js и убрать опасные а это очень ненадежно, на nodejs есть похожая история с попыткой сделать песочницу через vm-модуль (тут и тут) и можно только удивляться какие хаки можно придумать с этим js

                      0
                      Анализ только через AST так и не довел вывод типов Typescript до конца, лучше была ситуация у Flow но он кажется испытывает не лучшие времена. Все еще кажется что анализ AST это решение для ЛЮБОГО случая?
                      Я понимаю желание поразбирать алгоритмы парсинга деревьев (сам такой), но выходит вопрос о времени, которое уйдет на эту задачу, будет ли игра после этого?
                        0
                        А вы не подскажете, можно ли где-то более развёрнуто почитать про такой подход («обеззараживание» пользовательского JS через манипуляции с AST)? Или, если негде почитать, не хотите ли вы написать об этом?
                  0
                  Меня больше интересует способ определения бесконечной рекурсии, если не использовать воркеры, while(true) в коде одного из соперников просто испортит матч обоим.
                  Суть в том, что статическая анализация динамического ЯП обладает большим количеством проблем, нежели песочница. Вывод следующий — а что из этих двух путей тогда вообще костыль?
                    0
                    Если прям строго судить, то оба костыли. Нужен полный контроль над виртуальной машиной — количество потребляемой памяти, скорость и продолжительность исполнения, вот это вот всё.
                      0
                      отдельный поток (воркер) в любом случае нужно будет использовать, без этого никак.
                        0
                        Тут вопрос в том а нужна ли вся мощь js данном случае? Вполне возможно что для управлением персонажем достаточно будет разрешить только выражения, условия и циклы и обращение к разрешенному списку идентификаторов (апи). Тогда такое подмножество вполне успешно можно провалидировать на уровне статического анализа. Кстати насчет необходимости веб-воркеров то тоже сомнительно — даже если юзер напишет while(true) то на этапе валидации ast можно добавить модификацию — например добавить проверку времени внутри тела цикла на каждую интерацию чтобы при превышении таймаута можно было оборвать цикл.
                          0
                          хорошо, тело цикла закрыли, что насчет рекурсии вложенными фукциями?
                            +1
                            можно в каждую функцию, включая анонимные добавить проверку на рекурсию (точнее проверку на таймаут). Кстати похожим образом работают инструменты покрытия кода и трассировщики — они на каждую строку или на каждый оператор добавляют инкремент счетчика и таким образом детектят что выполнилось а что нет и сколько раз, вот как в этом трассировщике например — www.youtube.com/watch?v=4vtKRE9an_I
                        +1

                        Распарсите вы код, а он обфусцирован. Как валидировать будете?
                        Статья по теме: Почему анализ защищенности JavaScript нельзя по-настоящему автоматизировать?

                        0
                        А можно было написать (или честно стырить) интерпретатор brainfuck'а, и никаких проблем с безопасностью кода.

                        Или сделать специальный PRO уровень сложности в ним ещё можно.
                          +1
                          интерпретатор brainfuck'а

                          Можно и Forth.
                            0
                            Я бы еще прикрутил контроллер с акселерометром, чтобы закладывать сам скрипт жестами) Сам подход игра в brainfuck это круто, но оно не расширяемо, как мне кажется
                              0
                              Не, brainfuck — это явно не для игр. Его нужно использовать как основу для ЧПУ fuckingmashine.
                          0
                          Через прототип в JS можно вытащить практически всё. Удивительно, но все эти методы все равно доступны через прототип.
                          можно пример, как можно вытащить всё через прототип, и как удаление свойств (как на слайде) это предотвратит? я, кстати, не уверен, что delete удалит все свойства, по крайней мере на странице несколько свойств выживают.
                            0
                            так вы просто проверьте) В Window нельзя удалить все свойства, а в Worker — можно. После удаления всех свойств из глобального объекта там остается constructor.prototype через который можно найти все доступные конструкторы. Пользовательская песочница оборачивается в IIFE которая уже содержит контекст обычного объекта и не связана с прототипом глобальной области. Если не удалять свойства глобального объекта, они будут доступны в self даже в песочнице. Есть весь листинг кода для воркера, есть он же на гитхабе (https://github.com/lekzd/script_battle_game/blob/master/src/common/codeSandbox/CodeSandbox.ts).
                            0
                            По описанию, игра отыгрывается в браузере, а сервер лишь шарит полученное состояние между игроками. Как защищена игра от изменения клиента?
                              0

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

                              +2

                              А почему было приятно решение выполнять код на клиенте?


                              Для мультиплеера вроде общепринятый подход прокинуть действия игрока на сервер, на сервере посчитать логику, на клиент вернуть результаты в виде, по сути, обновления графики.


                              И при такой архитектуре можно было бы реализовать некостыльный подход: на сервер запихнуть реальную песочницу, с ограничениями времени/памяти/стека и без лишних объектов по умолчанию, типа всяких window, чтобы не пришлось их выпиливать.

                                +1

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

                              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                              Самое читаемое