
WebGL and asyncio multiplayer online shooter, part two

В этом материале постарался описать создание браузерного
3D
-шутера, начиная от импорта симпатичных моделей танков на сцену и заканчивая синхронизацией игроков и ботов между собой с помощью websocket
и asyncio
и балансировкой их по комнатам.Введение
1. Структура игры
2. Импорт моделей и blender
3. Подгрузка моделей в игре с babylon.js и сами модели
4. Передвижения, миникарта и звуки игры в babylon.js
5. Вебсокеты и синхронизация игры
6. Игроки и их координация
7. Балансировка игроков по комнатам и объектный питон
8. Asyncio и генерация поведения бота
9. Nginx и проксирование сокетов
10. Асинхронное кэширование через memcache
11. Послесловие и RoadMap
Всех кому интересна тема асинхронных приложений в
Python3
, WebGL
и просто игр, прошу под кат.Введение
Сама статья задумывалась как продолжение темы о написании асинхронных приложений с использованием aiohttp и
asyncio
, и если первая часть была посвящена тому, как лучше сделать модульную структуру по типу django
, поверх aiohttp( asyncio )
, то во второй уже захотелось заняться чем то более творческим. Естественно, мне показалось интересным сделать
3D
-игрушку, а где еще как не в играх может понадобится асинхронное программирование. We will have a toy without registration, with the ability to hit each other, bots, rooms, a simple chat, and a simple landscape. As game models, we will take a couple of tanks, as the most simple and familiar, and, at the same time, indicative game models. Well, as part of the framework, let's recall caching through
memcached
. For clarity, some processes will be represented by pictures remotely reminiscent of infographics, because network programming does not always look straightforward and more convenient all the same when the arrows show what is being transmitted and what is being called.
The code examples, both in infographics and in the usual format, will very often be greatly simplified, for a better understanding of the general scheme of work. Moreover, the full code with the latest fixes can be viewed on github.
But it should be understood that this is not a full game - in the sense that he wrote
git clone
and went to the ATM. It is, rather, an attempt to make the game framework, demo opportunities asyncio
and webgl
in one bottle. There is no showcase, ratings, thoroughly tested security, etc., but, on the other hand, it seems to me that for a open sourse
project developed from time to time, in my spare time, it turned out quite normally.2 Import models and blender
Naturally, we need
3D
character models for the toy, we need models to simulate the landscape, buildings, etc. Characters can be people, tanks, planes. There are two options - draw a model, and import the finished one. The easiest way is to find ready-made models on one of the specialized sites, for example here or here . Who are interested in the process of drawing tanks and other models on YouTube is full of videos, Habré found materials on this subject .
Let us dwell in more detail on the import process itself. Of the imported
blender
formats, are most often found .die .obj .3ds
. Import / export has a number of nuances. For example, if we import
.3ds
, then, as a rule, the model is imported without textures, but with materials already created. In this case, we just need to load each material from the disk texture. For .obj
, as a rule, in addition to textures, a .mtl
file should go , if it is present, then usually the likelihood of problems is less. Sometimes after exporting the model to the scene, it may turn out that it
chrome
crashes, with a warning that it has problems with displaying webgl
. In this case, you should try to remove all unnecessary in the blender, for example, if there are collisions, animations, etc. Further, one of the most important points. For the models that we are going to move around the map,
we need to glue all the objects that make up the model. Otherwise, we will not be able to move them, visually it will look in such a way that only a machine gun will drive instead of the tank, and when you try to set the necessary coordinates for the tree, the location on the map will change only the stump of the tree, and everything else will remain in the center of the map.
To solve this problem, there are two ways:
1) Combine all the details of the model and make it one object. This method is a little faster, but it only works if we have one texture in the form of
UV
a scan. To do this, you can select all objects through an outliner with a shift, they will be highlighted with a characteristic orange color and object
select an item in the menu join
.
2) The next option is to link all the details according to the Parent-Child principle. In this case, there will be no problems with textures, even if for each detail we have our own texture. To do this, right-click to select the parent object and the child in turn, press
ctrl+P
select in the menu object
. As a result, in the offline mode, we should see that all the objects that make up our model belong to the same parent. 
And yet, I would like to insert such a remark that quite often some model may for some reason not be imported into a blender or imported in the form of some kind of junk. And very often it happens that some textures that come with the model do not want to be applied. In such cases, nothing can be done, and we must move on to other options.
3. Loading models in the game with babylon.js and the models themselves
The loading of models itself looks quite simple, we indicate the location of the mesh on the disk:
loader = new BABYLON.AssetsManager(scene);
mesh = loader.addMeshTask('enemy1', "", "/static/game/t3/", "t.babylon");
After that, we can check at any place that our model is already loaded and then set its position and convert it if necessary.
mesh.onSuccess = function (task) {
task.loadedMeshes[0].position = new BABYLON.Vector3(10, 2, 20);
};
One of the most common operations in our case is the cloning of objects, for example, there are trees, so as not to load each tree separately, you can simply load it once and clone it across the scene with different coordinates:
var palm = loader.addMeshTask('palm', "", "/static/game/g6/", "untitled.babylon");
palm.onSuccess = function (task) {
var p = task.loadedMeshes[0];
p.position = new BABYLON.Vector3(25, -2, 25);
var p1 = p.clone('p1');
p1.position = new BABYLON.Vector3(10, -2, 20);
var p2 = p.clone('p2');
p2.position = new BABYLON.Vector3(15, -2, 30);
};
Also, cloning plays an important role in the case of
AssetsManager
. He draws a simple splash screen until the main part of the scene is loaded, and makes sure that everything we put in is loaded loader.onFinish
.var createScene = function () {
. . .
}
var scene = createScene();
loader.onFinish = function (tasks) {
engine.runRenderLoop(function () {
scene.render();
});
};

We must avoid further loading anything during the game, for various reasons. Therefore, all characters are loaded during initialization, and already in the processing of sockets and in the class responsible for the appearance and behavior of players, we clone the equipment we need, etc. The diagram looks something like this:

Next, I would like to write a little about the models themselves, and although this version of the map is more of an experiment than a ready-made solution, it will not hurt to understand the general picture.
The characters, in this case, are represented by two types of tanks, T-90 and Abrams. Since we don’t have the game logic of victories and defeats, and in the case of the framework it is implied that all this needs to be invented in each individual case. Therefore, now there is no choice and the first person always plays Abrams, and the bot and all other players are visible as T-90.
On the map itself we see a certain relief, it is created very simply, using the option
babylon.js
called heightMap , for this you need to impose a black and white picture on the soil texture and depending on where the light surface is and where the dark one and mountains and hills form, part characteristics can be specified in the parameters, the more blurred the transition between dark and white, the more gentle the slope. var ground = BABYLON.Mesh.CreateGroundFromHeightMap("ground", "/static/HMap.png", 200, 200, 70, 0, 10, scene, false);
var groundMaterial = new BABYLON.StandardMaterial("ground", scene);
groundMaterial.diffuseTexture = new BABYLON.Texture("/static/ground.jpg", scene);
ground.material = groundMaterial;

Further, we have a small entourage in the form of a house, a water tower near it, several trees and some grass.
The grass came out with the lowest possible polygon, just a plane with a texture on it. And this plane is tilted in different places. In general, the lower the polygon models, the better, in terms of performance, but for obvious reasons, entertainment will suffer.
Of course, you can make the choice in the settings "graphics quality", but not in our case.
Unlike grass, the banana palm has quite a few peaks, so it was decided to leave only a couple of pieces on the map.
The more vertices on the map, the lower it can be
FPS
and so on.
The house is somewhat aloof, and it was decided to cover it with a transparent cube with collisions.
And the last thing we have left is three colorful cubes, they are not synchronized for everyone and represent simple targets. When hit in each, it lights up and disappears.

4. Movement, minimap and game sounds in babylon.js
Speaking about movements, this is mainly about the first person, since the movements of other players and bots are always just a change of position, which is most often broadcast using sockets from the server, which will be described below.
In itself, movement is simply controlling the camera and the visible part of the player. For example, a player turns right, so we must turn the camera to the right relative to where we are looking, or rotate the scene to the desired degree. Also, a model depicting, for example, some means of defeating opponents, should also turn.
Basically, in games made
babylon.js
for first-person movement, there are two cameras:FreeCamera
- As a rule, he is the parent of the character and the character simply follows her, it is very convenient to use for advanced technology, for people and everything flying, itFreeCamera
has the ability to adjust inertia and speed, which is also very important.FollowCamera
- on the contrary, this is a camera that follows some kind of object, it is more convenient to use for cases when the control from the mouse and keyboard is different. That is, the review does not depend on the direction of movement.
Examples:
//FollowCamera
var camera = new BABYLON.FollowCamera("camera1", new BABYLON.Vector3(0, 2, 0), scene);
camera.target = mesh;
``````javascript
//FreeCamera
var camera = new BABYLON.FreeCamera("camera1", new BABYLON.Vector3( 0, 2, 0), scene);
mesh.parent = camera;
There should be some sounds during the movement, during the hit, during the shot, during the movement, etc. It
babylon.js
has a good sound management API . The topic of sound control is quite extensive, so we will consider only a couple of small examples. Initialization Example:
var music = new BABYLON.Sound("Music", "music.wav", scene, null, {
playbackRate:0.5, // скорость воспроизведения.
volume: 0.1, // громкость воспроизведения.
loop: true, // указывает нужно повторять или нет звук.
autoplay: true // указывает нужно ли сразу запускать проигрывание.
});
Example of the sound of a shot - when you click, we check that the interlock is removed and play the sound of a single shot.
var gunS = new BABYLON.Sound("gunshot", "static/gun.wav", scene);
window.addEventListener("mousedown", function (e) {
if (!lock && e.button === 0) gunS.play();
});
We hang the sound of the movement of equipment on the event of pressing the arrows back and forth and, accordingly, the beginning of playback. At the key release event, stop playback.
var ms = new BABYLON.Sound("mss", "static/move.mp3", scene, null, { loop: true, autoplay: false });
document.addEventListener("keydown", function(e){
switch (e.keyCode) {
case 38: case 40: case 83: case 87:
if (!ms.isPlaying) ms.play();
break;
}
});
document.addEventListener("keyup", function(e){
switch (e.keyCode) {
case 38: case 40: case 83: case 87:
if (ms.isPlaying) ms.pause();
break;
}
});
Minimap
Any shooter should have a minimap on which you can see only your players, or all at once, you can see the structure and the general landscape, in our case, if you look closely, you can see the shells. There
babylon.js
are several ways to implement this. Probably the easiest is to create another camera, place it on top of the map and place the view from this camera in the corner we need. 
In our case, we take
freeCamera
and tell her that it should be placed on top:camera2 = new BABYLON.FreeCamera("minimap", new BABYLON.Vector3(0,170,0), scene);
The larger the coordinate
Y
, the more complete the overview, but the finer the details on the map. Next, we tell the camera how we will place the image from it on the screen.
camera2.viewport = new BABYLON.Viewport(x, y, width, height);
And the last thing - you need to add both cameras to the scene (when the camera is one it is not necessary to do this):
scene.activeCameras.push(camera);
scene.activeCameras.push(camera2);
5. Web sockets and game synchronization
The whole basic structure of the game is built on
websoket
-ax, the player performs some action, mouse rotation or keystroke, an event is hung up on this movement in which the player’s location coordinates are transmitted to the server, and the server broadcasts them to all game participants who are in this game room . Initially, since we use it
FreeCamera
, it is the parent object, therefore, we use its coordinates. For instance:camera.cameraRotation
- it containsX
andY
the coordinates along the axes of rotation.camera.position
- containsX
,Y
and theZ
coordinates of the location of the mesh on the map.
In the picture below, we see an example process from the beginning of opening a connection to the server and creating a new player, to exchanging messages when responding to some user actions.

6. The server side device
Above, we saw a brief diagram, as it were, very concise, but now we dwell in more detail on how the server side works. In the function of the socket handler, after receiving the next message, we look at it
action
, what action the player performed and in accordance with this we call the desired function, the event handler:async def game_handler(request):
. . .
async for msg in ws:
if msg.tp == MsgType.text:
if msg.data == 'close':
await ws.close()
else:
e = json.loads( msg.data )
action = e['e']
if action in handlers:
handler = handlers[action]
handler(ws, e)
. . .
For example, if you came to us
move
, it means that the player has moved to some coordinates. In the function handler of this action, we simply assign these coordinates to the class, Player
it processes them and returns them back and we further transmit them to all the other players in the room at the moment.def h_move(me, e):
me.player.set_pos(e['x'], e['y'], e['z'])
mess = dict(e="move", id=me.player.id, **me.player.pos_as_dict)
me.player.room.send_all(mess, except_=(me.player,))
Of course, all that the class
Player
with coordinates is doing now , it simply passes them to the bot so that it focuses on them. And ideally, he should check the entire map with the coordinates, and if the player ran into an obstacle, then, for example, to avoid cheating, he should not let him leak through the wall if the script is changed on the client.class Player(list):
. . .
def __init__(self, client, room, x=0, y=0, z=0, a=0, b=0):
list.__init__(self, (x, y, z, a, b))
self._client = client
self._room = room
Player.last_id += 1
self._id = Player.last_id
room.add_player(self)
. . .
def set_rot(self, a, b): self[3:5] = a, b
def getX(self): return self[0]
. . .
def setX(self, newX): self[0] = newX
. . .
x = property(getX, setX)
. . .
@property
def pos_as_dict(self):
return dict(zip(('x', 'y', 'z'), self.pos))
In the class
Player
we use property
, for more convenient work with coordinates. To whom it was interesting, on a habr there was a good material on this subject.7. Balancing players by room
A fairly important part of any game is to breed players in different clans, rooms, planets, countries, etc. Just because otherwise they all will not fit on one card. In our case, the system is still quite simple - if there are more players in one room than what is set in the settings (by default 4 with the bot), then we create a new room and send the rest of the players there, and so on.
At the moment, all the information about which player in which room, etc., is stored in memory, because since we do not have a storefront and ratings, it makes no sense to use some kind of base.
The room number is assigned to the player when he
/pregame
enters the game from the start page, which is located on the route . When you click on the button, it worksajax
, which if successful, redirects the player to the desired room.if (data.result == 'ok') {
window.location = '/game#'+data.room;
On the server side, we simply go through the dictionary
rooms
, which contains a list of all rooms and players in them, and if the number of players does not exceed a given value, then we return the id
rooms to the client. And if the number of players is greater, then create a new room.def check_room(request):
found = None
for _id, room in rooms.items():
if len(room.players) < 3:
found = _id
break
else:
while not found:
_id = uuid4().hex[:3]
if _id not in rooms: found = _id
For working with the rooms we have a class
Room
. His general scheme of work looks something like this: 
Here we see that it interacts with the class
Player
, perhaps the whole scheme does not look quite linear, but in the end it will allow you to write such chains quite conveniently:# разослать сообщение всем игрокам находящимся в комнате
me.player.room.send_all( {"e" : "move", . . . })
# получить всех игроков комнаты
me.player.room.players
# добавить игрока в комнату
me.player.room.add_player(self)
# удалить игрока из комнаты
me.player.room.remove_player( me.player )
I would like to talk a little about it
me.player
, as for some of my colleagues this raised questions, me
this is a socket that is passed as a parameter in the functions that serve events:def h_new(me, e):
me.player = Player(me, Room.get( room_id ), x, z)
Here, in fact, as the translator would say, a pun. Since we know that everything in python is an object.
This is what will happen more clearly:
player = Player( Room.get(room_id), x, z)
player.me = me
me.player = player
And we get two links, a
player
module and an .player
object me
, both are equal and refer to the same object in memory, which will exist as long as there is at least one link. This can be seen in an even simpler example:
>>> a = {1}
>>> b = a
>>> b.add(2)
>>> b
{1, 2}
>>> a
{1, 2}
In this example,
b
and a
are just references to one common meaning.>>> a.add(3)
>>> a
{1, 2, 3}
>>> b
{1, 2, 3}
We look further:
>>> class A(object):
... pass
...
>>> a = A()
>>> a.player = b
>>> a.player
{1, 2, 3}
>>> b
{1, 2, 3}
>>> a.__dict__
{'player': {1, 2, 3}}
The properties of objects are just syntactic sugar. In this case, they are simply saved in the dictionary.
__dict__
As a result, we just killed one of our links
a
, but instead created another, which belongs to the newly created object, but actually lies in the dictionary of __dict__
this object.>>> a
<__main__.A object at 0x7f3040db91d0>
8. Asyncio and bot behavior generation
In any normal game, there must be at least one bot, and our game is no exception. Of course, for now, all our bot can do is move in concentric circles, gradually approaching the coordinates in which the player is located. If a new player comes in, the bot switches its attention to it.
The lines that send a message to all the players in the room about the coordinates along which the bot moves.
mess = dict(e="move", bot=1, id=self.id, **self.pos_as_dict)
self.room.send_all(mess, except_=(self,))
The general scheme of the bot and its interaction with the client part is as follows:

In the class,
Room
we __init__
create an instance of the class Bot
. And already in def __init__
the class itself, Bot
we are asyncio.async(self.update())
transferring a task that must be performed on each pass. Calling a function containing
await
does not start the function itself, but creates a generator object. As well as calling a function declared as it class
does not start this function, it creates an object served by this class. A call to the function containing await
will occur when the method is called on the generator .__next__()
. In this case - next
located in the decorator async
- it initializes the coroutine.Simply put, every 100 milliseconds we send a message to the client with new coordinates for the bot, and every half second we update the coordinates of the bot.
A simple example of working with tasks in an infinite loop:
import asyncio
async def test( name ):
ctr = 0
while True:
await asyncio.sleep(2)
ctr += 1
print("Task {}: test({})".format( ctr, name ))
asyncio.ensure_future( test("A") )
asyncio.ensure_future( test("B") )
asyncio.ensure_future( test("C") )
loop = asyncio.get_event_loop()
loop.run_forever( )
All the functions that we put in
asyncio.ensure_future
will be executed in a circle with a delay specified asyncio.sleep(2)
in two seconds). The practical application of this is very extensive, in addition to bots for games, you can write just bots for trading systems, for example, and, conveniently, without running for this, for example, separate scripts. Which, in my subjective opinion, simplifies development in some places, which is very valuable, avoids the zoo.9. Nginx and socket proxy
And the last thing worth mentioning in connection with the game is the correct setting
Nginx
for the cases when we know for sure that our project will work with websocket
and with http
. The first thing that comes to mind is something like this configuration:server {
server_name aio.dev;
location / {
proxy_pass http://127.0.0.1:8080;
}
}
And it will work fine locally, but it has one fundamental drawback - sockets will no longer work in production on an external server with this configuration, because they will connect to an external address, for example,
5.5.5.10
not to loalhost
. Therefore, the following idea is to write:
server {
server_name aio.dev;
location / {
proxy_pass http://5.5.0.10:8080;
}
}
But it is also flawed, because python performance
nginx
is orders of magnitude lower than performance , and in any case it proxy_pass
should be. http://127.0.0.1:8080
Therefore, we will use the
Nginx
-a option , which appeared a couple of years ago, by proxying sockets:server {
server_name aio.dev;
location / {
proxy_pass http://127.0.0.1:8080;
}
location /ws {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
For the address when initializing the sockets, we specify port 80
var uri = "ws://aio.dev:80/ws"
, because Nginx
by default it is configured to listen on port 80, unless we explicitly specify it listen
. In this configuration, it will work for
Nginx
th, and will be conveniently accessible and websoket
s, and http
.10. Asynchronous caching.
Since the game was written as one of the plug-in components of the framework, I would like to add a few words about small innovations.
As such, consider an example of page caching using
memcached
for functions with async def
. In any more or less visited classic site, it should be possible to cache the pages visited, for example, the main page, the news page, etc., as well as the ability to reset the cache when the page changes, before the caching time expires. Fortunately, the asynchronous driver for memcached was already written by svetlov , it remained to write a decorator and solve a couple of small problems. In itself, it was decided to do the caching in a rather familiar way in the form of a decorator over any function, for example, as inbeaker . The decorator should specify the caching time and the name from which, in particular, the key for memcached will be generated.
The data
memcached
will be serialized to put them in using pickle
. And one more nuance - since the framework is written on top aiohttp
, it was not serialized CIMultiDict
in it, it is an implementation of a dictionary with the ability to have the same keys, and written for greater speed by the Cython
author aiohttp
. dct = CIMultiDict()
print( dct )
dct = MultiDict({'1':['www', 333]})
print( dct )
dct = MultiDict([('a', 'b'), ('a', 'c')])
print( dct )
dct = dict([('a', 'b'), ('a', 'c')])
print( dct )
{'a': 'c'}
Therefore, those values that were stored in it were not serializable in
pickle
. Therefore, I had to get them and repack them back, but I hope that over time it CIMultiDict
will become serializable pickle
.d = MultiDict([('a', 'b'), ('a', 'c')])
prepared = [(k, v) for k, v in d.items()]
saved = pickle.dumps(prepared)
restored = pickle.loads(saved)
refined = MultiDict( restored )
Full caching code
def cache(name, expire=0):
def decorator(func):
async def wrapper(request=None, **kwargs):
args = [r for r in [request] if isinstance(r, aiohttp.web_reqrep.Request)]
key = cache_key(name, kwargs)
mc = request.app.mc
value = await mc.get(key)
if value is None:
value = await func(*args, **kwargs)
v_h = {}
if isinstance(value, web.Response):
v_h = value._headers
value._headers = [(k, v) for k, v in value._headers.items()]
await mc.set(key, pickle.dumps(value, protocol=pickle.HIGHEST_PROTOCOL), exptime=expire)
if isinstance(value, web.Response):
value._headers = v_h
else:
value = pickle.loads(value)
if isinstance(value, web.Response):
value._headers = CIMultiDict(value._headers)
return value
return wrapper
return decorator
Caching can be applied simply by writing a decorator on top and specifying the cache expiration time and name there.
from core.union import cache
@cache('list_cached', expire=10 )
async def list_tags(request):
return templ('list_tags', request, {})
Afterword
At this stage, this is more of an initial stage of the game’s implementation and, rather, a demonstration of capabilities
WebGL
and asyncio
not a production version. But I hope that in future versions of the framework everything will be much closer to ideal. I would like to make a game in the form of a space saga, where players could choose or dynamically change characters for fights in the air, on the ground, and simply as individual players. Since I always liked the characters from Star Wars, I would like to do everything in a futuristic way than in a historical one.
With the ability to localize any of the levels of the game in the settings, making it the main one, and, as far as possible, dynamically change the cards and characters of the game.
The following are the main, it can be said, shortcomings that are currently present to one degree or another and which are likely to be followed up.
Ping
The server should watch ping from each player and try to synchronize the speed of hit, movement, etc. atleast approximately. When a new player joins, he must get into the room where the opponents have approximately the same ping. Although for commercial use, different options come to mind.
Cheating
Naturally, you should never trust a client, and on a normal server, you should have a complete understanding of the entire map and check all the movements of the players, the trajectory of the bullets, that is, get the initial motion vector and coordinates, and see what exactly the bullets moved along this vector. Otherwise, with minimal popularity of the game, the prosperity of diverse cheating will be inevitable.
Roadmap - games
Client:
- Bot shooting, more weapons.
- Tank control - only the tower rotates with the mouse, the tank itself with arrows
- Movement from different angles
- Adding maps and controls for space / sky warfare
- Adding infantry war maps and characters to a field like Urban Terror
Server:
- Check all movements
- Checking the direction of the shot
- Expanding the capabilities of bots (for example, increasing the number of bots, their dynamic decrease, etc.)
- Базовый ИИ у ботов
Roadmap — фреймворка в целом
- Небольшая CMS
- Конструктор для складского учета (мини ERP)
- Конструктор отчетов
- Web клиент для MongoDB
- Демка мини соц-сети
Surely forgot to write something, somewhere he could be mistaken with the terms. Therefore, I ask all grammatical and other errors to write in PM.
The first part An
overview article on babylon.js and its comparison with three.js A
library on github
Readthedocs documentation
One of the main sites with a large selection of paid and free 3D Models
Website with free 3D models for Blender
Rotation
working with sound in babylon.js Visualization
example sound
Nello world on asynio
the Sleep
Working with tasks
stitched calls
pep-0492
Blog svetlov author aiohttp
Asynchronous driver memcached
Updated documentation for babylon.js aiohttp
documentation on github aiohttp
documentation on readthedocs yield
documentation from
aio-libs - list of libraries
Another more complete list
In more detail about generators:
http://www.dabeaz.com/generators/Generators. pdf
http://www.dabeaz.com/coroutines/Coroutines.pdf
http://www.dabeaz.com/finalgenerator/FinalGenerator.pdf