Quickpong — онлайн версия игры Pong

Разработал и запустил на домене 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 дата-фрейма, что дает некоторый запас, благодаря которому даже если данные от сервера не получены вовремя у клиента будет что рендерить. Неприятная сторона этого алгоритма — заметная задержка между действием пользователя (нажатие кнопок на клавиатуре) и отображением изменений на экране. Есть методы устранения этой задержки, но конкретно в этом случае я решил не усложнять клиент.

Реализация

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

На этой схеме: http://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(R) Xeon(R) CPU E31275 @ 3.40GHz.

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

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

Формат обмена данными

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

При инициализации игры сервер передает клиенту массив с данными:

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

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

При игре сервер передает клиенту аналогичные данные, для сохранения совместимости с данными инициализации первый элемент передается пустым:

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

Параметр номер 11 остался как наследие от предыдущих версий алгоритма, когда мне нужно было сравнивать друг с другом данные присланные клиентами, в текущей версии этот параметр не используется.

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

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

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

Roadmap

Идеи, которые остались нереализованными:

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

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