Quickpong — разработка сетевой игры на основе фреймворка Twisted

    Разработал и запустил на домене quickpong.com онлайн версию игры Pong. В игре (by design) реализован только режим мультиплейера, то есть игра идет не против искусственного интеллекта, а против другого человека.

    Игра представляет из себя клиент-серверное приложение, серверная часть написана на питоновском фреймворке Twisted, клиентская — на флэшовом фреймворке FlashPunk.

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

    Два слова об игровом процессе


    На игровом поле слева и справа находятся 2 доски, способные перемещаться по вертикали. Каждой из досок управляет человек. Между досками перемещается шарик, отскакивающий от стенок игрового поля и досок. Задача игрока — не допустить попадания шарика за стенку, возле которой находится его доска.

    Выбор используемых технологий


    При освоении новых технологий и инструментов огромную роль играет наличие грамотной подробной документации. Выбирая серверный фреймворк я рассматривал питоновские Twisted, Tornado и, находящийся у всех на слуху, Node.js.

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

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

    Алгоритм взаимодействия клиента и сервера


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

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

    Кроме того, хотелось бы избежать мошенничества со стороны клиентов: при желании и умении клиентская swf-ка может быть модифицирована и может передавать на сервер читерские данные.

    Таким образом, в первой версии алгоритма я решил действовать так:
    1. каждый клиент сам обсчитывает состояние своего игрового мира (положение шарика, своей доски и доски соперника),
    2. каждый клиент 20 раз в секунду передает на сервер информацию об изменении положения своей доски и информацию о параметрах шарика (координаты, вектор, скорость) в своем игровом мире,
    3. сервер пересылает каждому игроку информацию о положении доски соперника,
    4. сервер сам рассчитывает текущее состояние игрового мира и сравнивает его с данными полученными от клиентов (пункт 2). Если есть рассинхрон между состояниями, рассчитанными клиентом и сервером, то сервер принудительно возвращает клиентов в состояние, которое считает правильным.
    Алгоритм получился нерабочим. Он мог бы быть рабочим только в случае, если все 3 участника обмена данными имели бы одинаковое состояние. На практике в 100% случаев был рассинхрон и играть было невозможно.

    Во второй версии алгоритма я решил избавиться от одного из звеньев — от расчета состояния игрового мира на сервере:
    1. каждый клиент по прежнему сам рассчитывал координаты шарика и досок и передавал данные о своем состоянии на сервер,
    2. сервер сравнивал состояния клиентов и если они различались, то принудительно возвращал одного из клиентов к состоянию второго.
    Этот подход тоже получился нерабочим. Даже клиенты запущенные на двух идентичных машинах рассчитывали состояние игрового мира немного по разному и, несмотря на то, что теперь у меня сравнивалось 2 состояния, а не 3, как в предыдущем случае, все равно состояние одного из клиентов постоянно принудительно изменялось, выглядело это как заметные “скачки” шарика и играть было невозможно.

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

    Погуглив я нашел такие вот интересные ссылки:

    Алгоритм описанный по последней ссылке и был взят мною для этого проекта. Суть его такова:
    1. все расчеты ведутся только на сервере. Клиенты передают на сервер дельты изменений своего состояния, в моем случае это изменение положения доски относительно предыдущей передачи данных.
    2. Сервер рассчитывает положения досок (подавляя возможный читеринг со стороны клиента) и шарика и передает сформированный дата-фрейм клиентам.
    3. Клиенты рендерят полученные дата-фреймы, в моем случае, с запозданием на 3 фрейма. Это важный момент. Данные от сервера клиенту приходят неравномерно, например, из-за проблем с сетью клиент может получить данные о новом положении шарика не через положенные 50 мс, а через 60-70 мс. То есть может случиться так, что последний дата-фрейм уже отрендерен, а новый еще не пришел. В такой ситуации у клиента не будет данных для отрисовки шарика и, в моем случае, шарик просто остановится ожидая получение новой порции данных. Для того чтобы предотвратить такие ситуации клиент отрисовывает данные с запозданием на 3 дата-фрейма, что дает некоторый запас, благодаря которому даже если данные от сервера не получены вовремя у клиента будет что рендерить. Неприятная сторона этого алгоритма — заметная задержка между действием пользователя (нажатие кнопок на клавиатуре) и отображением изменений на экране. Есть методы устранения этой задержки, но конкретно в этом случае я решил не усложнять клиент.


    Реализация


    Исходники сервера и клиента я выложил на Гитхабе:

    На этой схеме: quickpong.com/images/quickpong.png показана логика взаимодействия клиента и сервера.

    С точки зрения реализации сервера все достаточно прозрачно. На 10080 порту стартует реактор (event loop), в котором работает серверная фабрика — класс QuickpongServerFactory. При инициализации фабрики создается экземпляр класса Quickpong, который содержит всю логику по взаимодействию сервера с клиентом.

    При подключении нового клиента фабрика вызывает метод buildProtocol и создает для каждого присоединившегося клиента экземпляр класса QuickpongProtocol, созданный объект передается в Quickpong. Таким образом, объект класса Quickpong имеет доступ ко всем присоединившимся клиентам и может выполнять с ними необходимую работу: объединять в пары, обсчитывать состояние игрового мира и т.д.

    Объект класса QuickpongProtocol содержит только методы для получения и передачи данных от/к клиенту.

    С реализацией клиента тоже всё просто, единственным интересным моментом было следующее. Используя FlashPunk я могу задать частоту обновления картинки (FPS), при этом FlashPunk может гарантировать, что он отрисует N кадров в секунду, но не может гарантировать того, что каждый кадр будет отрисован за 1/N секунды. То есть при FPS 50 в идеальном случае каждый кадр должен отрисовываться за 20 мс, в реальном случае один кадр может быть отрисован за 15 мс, а другой за 25 мс. Если шарик двигается с постоянной скоростью, например 10 пикселов в секунду, и отрисовка каждого дата-фрейма сопадает с отрисовкой кадра ФлэшПанком, то шарик будет двигаться неравномерно, рывками, так как в одном случае он переместится на 10 пикселов за 15 мс, а в другом за 25.

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

    Тестирование и мониторинг


    Самый интересный для меня вопрос — сколько игроков онлайн сможет выдержать этот сервер? Для теста я написал на питоне небольшой клиент, который эмулировал действия человека.

    Тест проводил на виртуальной машине, под которую выделено 1 ядро Intel® Xeon® CPU E31275 @ 3.40GHz.

    Средствами того же Twisted я повесил на 10082 порт веб-сервер, который выводит через запятую число юзеров онлайн и число активных игр. На основе этой информации, а также при помощи питоновской библиотеки psutil и связки rrdtool + py-rrdtool я написал скрипты, которые выводят информацию о текущем числе пользователей онлайн и потребляемых ресурсах в удобоваримом виде: quickpong.com/stats.html (картинки обновляются раз в минуту).

    При 5000 (5 тысяч) игроков программа отъедает около 100 Мб оперативной памяти, грузит CPU в среднем на 30-40%.

    Roadmap


    Идеи, которые остались нереализованными:
    • html5-клиент,
    • усложнение игрового процесса благодаря изменению угла отскока от доски, в зависимости от точки соприкосновения шарика и доски,
    • введение режима игры вчетвером, по одной доске на каждой стороне игрового поля :)),
    • реализация алгоритма lag compensation,
    • исправление мелких багов.

    На данный момент я потерял интерес к развитию этого проекта, возможно мои наработки покажутся кому-нибудь познавательными или интересными, по этому я выложил все исходники на Гитхаб:
    Поделиться публикацией
    Похожие публикации
    Реклама помогает поддерживать и развивать наши сервисы

    Подробнее
    Реклама
    Комментарии 13
    • +10
      А всё равно лагает, ракетка не плавно а рывками двигается как моя, так и соперника.
      • +1
        Да, клиент несовершенен. Если нажать тильду, то можно будет увидеть отладочную информацию и положение доски, там где она должна находиться по мнению клиента. Эта доска двигается плавно. При желании можно допилить клиента так, чтобы движения досок были более плавными.
        • 0
          немного лагает
          опять желтый победил :)
        • 0
          Пока в игре побеждает тот, у кого меньше лагает, либо тот, кто к лагам лучше приноровился. А в целом понравилось, если хочется немного отвлечь мозг, то самое оно.
          • 0
            Как часто статистика обновляется? Пока что там голяк!
            • 0
              Статистика обновляется раз в минуту. Попробуйте Ctrl+R нажать, возможно в браузере картинка закешировалась.
            • 0
              Еще можно в мультиплеер добавить бота, чтобы не ждать когда кто-то еще присоеденится к игре. Например если за 10-20 секунд реальный юзер не появился, то подставляем бота (но не говорим об этом пользователю).
              • 0
                Хотелось бы увидеть более подробное описание протокола — что используется в качестве транспорта? голый TCP? как осуществляется фрагментация сообщений? было бы разумно использовать какие-нибудь WebSockets + JSON для этого. С HTML5 это будет работать хорошо, а вот насчет флеша не знаю.
                • +3
                  Формат обмена данными

                  Серевер клиенту при инициализации (первый пакет)

                  При инициализации игры сервер передает клиенту массив с данными:
                  0 'left or right'
                  1 координаты шарика по оси Х;
                  2 координаты шарика по оси У;
                  3 дельта шарика по оси Х по отношению к предыдущему дата фрейму;
                  4 дельта шарика по оси Y по отношению к предыдущему дата фрейму;
                  5 счет левого игрока;
                  6 счет правого игрока;
                  7 координата У левой доски;
                  8 координата У правой доски;
                  9 дельта У левой доски;
                  10 дельта У правой доски;
                  11 номер «дата фрейма» (не номер отрендеренного флешом кадра, а номер передачи данных);
                  12 клиент добавляет к этому массиву время, за которое кадр должен быть отрендерен (1 / число дата фреймов в секунду)

                  Серевер клиенту во время игрового процесса

                  При игре сервер передает клиенту аналогичные данные, для сохранения совместимости с данными инициализации первый элемент передается пустым:
                  0 ''
                  1 координаты шарика по оси Х;
                  2 координаты шарика по оси У;
                  3 дельта шарика по оси Х по отношению к предыдущему дата фрейму;
                  4 дельта шарика по оси Y по отношению к предыдущему дата фрейму;
                  5 счет левого игрока;
                  6 счет правого игрока;
                  7 координата У левой доски;
                  8 координата У правой доски;
                  9 дельта У левой доски;
                  10 дельта У правой доски;
                  11 номер «дата фрейма» (не номер отрендеренного флешом кадра, а номер передачи данных);
                  12 клиент добавляет к этому массиву время, за которое кадр должен быть отрендерен (1 / число дата фреймов в секунду)

                  Клиент серверу во время игры

                  0 'left or right'
                  1 изменение положения своей доски
                  2 номер отправленного фрейма. Нужен чтобы сервер не обрабатывал дважды один и тот же фрейм.

                  Перед отправкой данных и клиент, и сервер конвертируют их в JSON и в таком виде передают. Вот тут код отвечающий за передачу данных сервером:
                  github.com/romka/quickpong/blob/master/quickpong/quickpong.py#L179
                  github.com/romka/quickpong/blob/master/quickpong/protocol.py#L72
                  • +2
                    что используется в качестве транспорта? голый TCP?

                    Да, голый TCP.

                    как осуществляется фрагментация сообщений?

                    Каждый дата-фрейм это преобразованный в JSON-массив с данными, описание массива в соседнем комментарии.

                    было бы разумно использовать какие-нибудь WebSockets + JSON для этого

                    Согласен, но сейчас у меня нет возможности допилить еще и HTML5-клиент. Для того и выложил на гитхабе, чтобы желающие смогли усовешенствовать клиент :)
                    • 0
                      Спасибо! Это я ожидал увидеть в статье.
                  • 0
                    2 номер отправленного фрейма. Нужен чтобы сервер не обрабатывал дважды один и тот же фрейм.

                    А почему клиент может один фрейм два раза отправить?

                    Фрейм это «Клиент серверу во время игры»?

                    Интересный пост для начала изучения проблем гем дева, python и Twisted:) по разбираюсь.
                    • 0
                      Хех, хороший вопрос :) Это наследие одного из предыдущих алгоритмов, когда мне нужно было сравнивать состояния игровых миров двух пользователей. Логично, что сравнивать надо было два дата-фрейма с одинаковыми номерами. В текущем рабочем алгоритме информация о номере дата-фрейма не нужна.

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