Разработка сетевых игр
Бомж сервер на Go
Я взял самый дешевый сервер в этой галактике стоимостью $5 и развернул на нем прототип игры, написанный на Go
Новые посты публикую в твиттере https://twitter.com/DorogoyYuriy
Я взял самый дешевый сервер в этой галактике за$5 и развернул на нем прототип сетевой игры, написанный на Go. Задача была узнать, какой онлайн может выдержать самый дешевый бомж сервер. В нашем случае это CPU 2Ghz c 1м ядром, 512 RAM и медленным жестким диском
Кто не знает про Go - почитайте здесь. Это язык гугла, который компилируется в бинарный код. Приятный синтаксис и относительно быстрый, вот бенчмарки:
https://hashrocket.com/blog/posts/websocket-shooto...
https://benchmarksgame.alioth.debian.org/u64q/go.h...
http://eric.themoritzfamily.com/websocket-demo-results-v2.html

Отличительная фишка - горутины (go routines). Горутины - это неблокирующие функции, которые go раскидывает по разным потокам ОС. Если потоки выполняются на разных ядрах, то горутины выполняются параллельно. В противном случае у Go есть сложный планировщик, который делает их выполнение concurrent.
Проблема: создать быстрый и масштабируемый игровой сервер на вебсокетах. Сейчас сервер вещает с 60fps и это каждые 17ms.
Прототип игры - 2д аркада, собранная из фри тайлов в стиле JRPG, где игроки могут ходить, стрелять и сталкиваться со стенками

Скачать текущую версию можно тут

После установки Go нужно указать GOPATH
Запуск стресс-теста
cd stresstest
npm install
node main
Сетевая игра это много проблем
настройка сервера
масштабируемость
игровая экономика
монетизация
геймдизайн
парсер уровней для сервера и клиента
сериализация объектов
алгоритм репликаций
предсказание на клиенте (сейчас это линейная интерполяция)
физика на сервере и клиенте
графика на клиенте, портирование
микросервисы, авторизация и много всего другого.
В этой статье я расскажу про физику и производительность сервера.
Игровой сервер разбит на "комнаты", в тестовом примере их будет 100. Каждая из которых является отдельным уровнем и просчитывается независимо. Все игроки внутри одной комнаты получают информацию друг о друге. Такая реализация очень упрощает фильтрацию данных для клиентов. В любом случае, даже для "большого" мира подобная сетка актуальна, например игрок будет знать обо всех игроках из его клетки + всех соседних. Для совсем "открытых" миров типа planetside нужно изощряться с быстрым перестроением графов. Информация о других игроках связана не столько с расстоянием друг до друга, сколько с видимостью
Физический движок
Я не использовал никакие порты физики на go, их мало, они избыточны. В моем случае все, что нужно - это функция определения коллизий, глубины их пересечения и вектор. Зная вектор, можно итеративно расталкивать объекты, как это делают почти все физ движки.
В больших фреймворках, столкновения создают большие структуры данных, вектора, импульсы, считают коэффициенты, основанные на массе и трении. Очевидно, это медленный подход.

Это буквально вся физика, не считая collision detection:
func collideTwo(a *DynamicObject, b *DynamicObject, delta float64) bool {
	var colided bool

	var response collision2d.Response
	aType := a.shape.shapeType
	bType := b.shape.shapeType
	var overlapX float64
	var overlapY float64
	var v *Vec2
	if aType == SBOX && bType == SBOX {
		colided, response = collision2d.TestPolygonPolygon(a.shape.boxPolygon, b.shape.boxPolygon)
		overlapX = response.OverlapV.X
		overlapY = response.OverlapV.Y
	} else if aType == SCIRCLE && bType == SCIRCLE {
		colided, response = collision2d.TestCircleCircle(a.shape.circle, b.shape.circle)
		overlapX = response.OverlapV.X
		overlapY = response.OverlapV.Y
	} else if aType == SCIRCLE && bType == SBOX {
		v = fastCircleAABB2(&b.shape.boxPolygon, &a.shape.circle, &b.shape.box)
		if v != nil {
			overlapX = v[0]
			overlapY = v[1]
		}
	} else if aType == SBOX && bType == SCIRCLE {
		v = fastCircleAABB2(&a.shape.boxPolygon, &b.shape.circle, &a.shape.box)
		if v != nil {
			overlapX = v[0]
			overlapY = v[1]
		}
	}

	if colided == true || v != nil {
		var dx, dy float64
		if math.IsNaN(overlapX) {
			dx = -10 * delta
		} else {
			dx = overlapX * delta
		}

		if math.IsNaN(overlapY) {
			dy = 10 * delta
		} else {
			dy = overlapY * delta
		}

		if !a.Type.HasFlag(typeWall) {
			a.POS[0] -= dx
			a.POS[1] -= dy
		}

		if !b.Type.HasFlag(typeWall) {
			b.POS[0] += dx
			b.POS[1] += dy
		}
	}

	return v != nil || colided
}


func iterate(af *ActorF, delta float64, iteration int) {
	for _, a := range af.actors {
		a.shape.setPos(&a.POS)
	}

	var res bool
	for _, w := range af.walls {
		for _, a := range af.actors {
			if shouldCollide(w, a) {
			res = collideTwo(w, a, delta)
			if res && iteration == 0 {
				//w.onCollide(a)
				a.onCollide(w)
			}
		   }
		}
	}

	totalActors := len(af.actors)
	for ai, a := range af.actors {
		for i := ai; i < totalActors; i++ {
			b := af.actors[i]
			if a != b {
				col := shouldCollide(a, b)

				if col {
					res = collideTwo(a, b, delta)

					if res && iteration == 0 {
						b.onCollide(a)
						a.onCollide(b)
					}
				}
			}
		}
	}
}

func shouldCollide(a *DynamicObject, b *DynamicObject) bool {
	if (a.shape.collideTypes.HasFlag(b.Type) && 
(b.domain == nil || a.shape.ignoreId != b.domain.player.ID)) ||
		(b.shape.collideTypes.HasFlag(a.Type) && 
(a.domain == nil || b.shape.ignoreId != a.domain.player.ID)) {
		return true
	}
	return false;
}
Конечно, правильно расталкивать объекты нужно пересчитывая скорость, но в нашем случае и так сойдет. Одна из оптимизаций физических движков,это разбиение карты на сетку, когда каждому физическому объекту приписывается ячейка. Затем, для проверки пересечений объекта, он сравнивается только с соседями. Я взял github.com/Tarliton/collision2d для определения коллизий, форкнул и переписал нужные мне методы
Стек сервера
pprof - профайлер для Go
websocket - форк gorilla websocket для fasthttp
govendor - сборщик поставщиков
fasthttp + fasthttp router

Fasthttp - альтернатива встроенному net/http, как указывает автор, должен быть в 10 раз быстрее. В нашем случае не критично, потому что самих игроков / соединений не много, а пишется в сокеты тонны информации. Кто знаком с C10K, скажу в realtime геймдеве эта проблема наступает значительно раньше 10,000 соединений

Первоначальный выбор был gin-gonic (http framework) + melody (websocket). Такой стек оказался очень медленным. На самом деле скоростной буст Go помимо удобного параллелизма во многом заключается в отсутсвии фреймворков. Хотите быстро - реализовывайте только то, что конкретно нужно. Условный gin-gonic будет создавать бесчисленное количество оберток, проверяя на каждом этапе ошибки. Все это проходит через посредников (middlewares) и через профайлер выглядит вот так:
Для стресс теста использовался node.js клиент, который умеет создавать подключения "игроков". Они ходят и стреляют. Иронично, чтобы написать клиент, который просто берет данные сервера, парсит, и отправляет обратно с небольшими изменениями, мне понадобился сервер в несколько раз быстрее чем стендовый бомж сервер. Вот такой вот V8 перфоманс, setInterval 40ms плавно превращался в задержку в 50ms и 80ms. Сервера находились в одной локальной сети с пингом 0.5ms, чтобы можно было нормально потестировать нагрузку
'use strict';
var Parser = require('../front/cocos/assets/scripts/Parser.js'); //Парсит строку, делает js объект
var W3CWebSocket = require('websocket').w3cwebsocket;
var http = require('http');
var clients = [];

const host = "localhost:5050";

var packets = 0;

setInterval(function send(){
    console.log(packets + ' packets per second');
    ackets = 0
}, 1000);

//создаем игрока
function createClient(room) {
    var client = new W3CWebSocket('ws://' + host + '/ws/' + room, null, 'http://localhost/');
    client.clientID = 0;

    clients.push(client)
    console.log('added client to room: ' + room + '; total: ' + clients.length);

    client.onerror = function(e) {
        console.log('Connection Error');
    };

    client.onopen = function() {
        console.log('WebSocket Client Connected');
    };

    client.onclose = function() {
        console.log('echo-protocol Client Closed');
    };
  
    //каждую секунду наш игрок стреляет в случайном направлении и меняет направление
    setInterval(function changeDir() {
        if (!client.me) return;
        var v = 300;
        var angle = Math.random()* Math.PI*2;
        client.VX = Math.cos(angle) * v;
        client.VY = Math.sin(angle) * v;

        client.BVX = Math.cos(angle + Math.PI / 2) * v * 2;
        client.BVY = Math.sin(angle + Math.PI / 2) * v * 2;

        client.bullet = {
            ID: -1,
            CID: client.clientID,
            POS: client.me.POS,
            V: [client.BVX, client.BVY],
            TYPE: 4,
            OWNER: client.me.OWNER
        }
        client.clientID++;
    }, 1000);

        client.onmessage = function(e) {
        if (typeof e.data === 'string') {
            if (!client.playerId) {
                client.playerId = e.data;
            } else {
                client.data = Parser.parse(e.data);
                client.me = null
                for (var i = 0 ;i < client.data.objectsToSync.length; ++i) {
                    if (client.data.objectsToSync[i].ID == client.playerId) {
                        client.me = client.data.objectsToSync[i];
                    }
                }

                if (client.me && client.VX && client.VY) {
                    client.me.V[0] = client.VX;
                    client.me.V[1] = client.VY;
                }
            }
        }
    };
}


var CLIENTS_COUNT = 150;
var NUM_ROOMS = 100;
for (var i = 0; i < CLIENTS_COUNT; ++i) {
    var room = Math.floor(Math.random() * (NUM_ROOMS));
    setTimeout(
        createClient.bind(this, room)
    , 200*i)
}

console.time("interval");
setInterval(function send(){
    console.timeEnd("interval")
    console.time("interval")
    var cl = clients.length;
    for (var i = 0; i < cl; ++i) {
        var client = clients[i];
        if (client.me) {
            var str = Parser.serialize(client.me);

            if (client.bullet) {
                str = str + ';'+ Parser.serialize(client.bullet);
                client.bullet = null;
            }
            if (client.readyState == 1) {
                client.send(str);
                packets++;
            }
        }
    }
}, 40);


process.stdin.resume();
process.on('SIGINT', function () {
    console.log('aborted all clients');

    for (var i = 0; i < clients.length; ++i)
        clients[i].close();

    setTimeout(function() {
        process.exit (0);
    })
});
Так выглядит наш уровень населенный ботами:
Бенчмарк
Результаты
Довольно неожиданно, но наш крошечный сервер справился с 250 игроками. Это около 14k операций записи и 6k чтения в секунду. Одновременно 100 комнат, с объектами, физикой, пулями и игроками. Очевидно что для серверных 30fps это будет 400+ игроков и столько же операций. Пустой сервер потребляет 7мб на процесс, нагруженный 15MB * 6 процессов = 90MB. Смешные цифры. Впрочем, это самая малая из проблем, куда интереснее построить хорошо масштабируемую архитектуру. Сейчас на 8 ядрах работает действительно в 8 раз быстрее, потому что отствутствуют блокировки. Единственные блокировки, которые есть, это lock основного потока при вставке и удалении нового игрока.

Про архитектуру напишу позже, вкратце - это несколько слоев горутин, буфферизированные каналы и атомарные операции через atomic.

Для деплоя проекта я использовал Docker. Так выглядит .Dockerfile
FROM golang

ENV GOPATH /glng/
ENV GOBIN /glng/

ADD . /glng/

RUN go install github.com/dearcj/golangproj

ENTRYPOINT ["/glng/golangproj", "-port", "5050"]
EXPOSE 5050
А запускается вот так
docker run --publish 5050:5050 --name game --rm --net=host game
Зачем нужен флаг --net=host: вот и вот

Есть еще особенности настройки Linux системы для websocket сервера. Также необходимо удостовериться, что хостинг не имеет лимитов на количество отправляемых пакетов.

Для работы с библиотеками я использовал govendor.
govendor init //инициализируем govendor, делается 1 раз
govendor add +external

go get github.com/username/library

govendor fetch github.com/username/library //после каждой установки библиотеки, нужно сделать fetch, govendor автоматически переместит в папку vendor
Клиент сделан в Cocos Creator. Это китайский клон Unity, у которого есть буквально одно преимущества - умеет хорошо паковать в HTML5 (помимо этого - под Windows, и мобильные платформы). Главный недостаток - слабое коммьюнити, нужно терроризировать форум, пока получишь ответ на свой вопрос. Единственный пока что уровень сделан в Tiled.
Выводы
Общее впечатление от Go - библиотеки, документация, менджеры пакетов, с этим все очень плохо. Библиотек кажется, что много, но по сравнению с 400k библиотек npm и рядом не стоит. Еще одна история от которой у меня бомбит в Go, это int, int32, int64. Во встроенных библиотеках они используются в произвольном порядке, нужно везде приводить. Вместо менеджера пакетов использовал govendor. С разработкой параллелизма, наоборот, очень хорошо. Запустил билд с параметром -race и в реальном времени можно увидеть состояние гонки, неатомарные операции. Кстати, в текущей версии race condition есть, при подсчете дебажных счетчиков чтения, записи и времени выполнения.

Go быстрый в плане разработки. Весь прототип, вместе с клиентом занял около 10 дней. Можно писать масштабируемые приложения, при этом код будет оставаться высокоуровневым. Я смотрел еще на Erlang, но подход Immutable data не очень подходит для геймдева. Геймдев это и есть мутация данных, векторов, матриц и тому подобного. Можно заморачиваться по поводу атомарных операций, и memory sharing, а можно использовать стандартные каналы и синхронизировать потоки через них. И так и так получается довольно быстро
Made on
Tilda