📜 ⬆️ ⬇️

Developing a game server on Nadron

In this article I will talk about the main points of developing a game server on the Nadron framework, about the stack of technologies I use in development, and give an example of the structure of the project for a browser game.

Why exactly Nadron?


First of all, it implements all popular protocols for data transfer, and within a single game you can use any number of protocols, besides, you can create and add your own options. The engine allows you to write linear code inside the game rooms and implements the basic commands. For the client, js, as3, java or dart are written ready-made libraries for working with Nadron.

What are we doing?


We make a blank that is suitable for implementing most of your
ideas and will provide an opportunity to figure out what's what for everyone else. In particular, ready-made authentication methods for the social network VKontakte, creating a data scheme, loading game entities, as well as a lobby, simple game rooms, etc.

The example for this article was developed for WebSocket, but this does not really matter, since the differences for any other protocol are minimal. But first I advise you to get acquainted with an example from the author Nadron.
')

Configuration


At first about dependences. We will need Nadron, Spring, Flyway and c3p0 , for the Gradle project it will look like this:

build.gradle
// Apply the java plugin to add support for Java apply plugin: 'java' apply plugin: 'org.flywaydb.flyway' // In this section you declare where to find the dependencies of your project repositories { // Use 'jcenter' for resolving your dependencies. // You can declare any Maven/Ivy/file repository here. jcenter() } buildscript { repositories { mavenCentral() } dependencies { classpath "org.flywaydb:flyway-gradle-plugin:4.0.3" } } flyway { url = 'jdbc:postgresql://localhost:5432/game_db' user = 'postgres' password = 'postgres' } def spring_version = '5.0.1.RELEASE' // In this section you declare the dependencies for your production and test code dependencies { // The production code uses the SLF4J logging API at compile time compile group: 'org.slf4j', name: 'slf4j-log4j12', version: '1.7.25' compile group: 'org.springframework', name: 'spring-core', version: spring_version compile group: 'org.springframework', name: 'spring-context', version: spring_version compile group: 'org.springframework', name: 'spring-jdbc', version: spring_version compile group: 'org.springframework', name: 'spring-tx', version: spring_version compile group: 'io.javaslang', name: 'javaslang', version: '2.0.5' compile group: 'com.github.menacher', name: 'nadron', version: '0.7' compile group: 'org.json', name: 'json', version: '20160810' compile 'com.mchange:c3p0:0.9.5.2' compile 'org.flywaydb:flyway-core:4.0.3' runtime("org.postgresql:postgresql:9.4.1212") testCompile 'junit:junit:4.12' } 


Variable parameters with application keys, connection ports and data for accessing the database will be moved to a separate configuration file.

Authentication


Ready solutions in Nadron are simple: by default, the username, password and code of the room to which you want to connect are transferred for user authentication. For our example, you need to redefine the login handler, in which we will transfer the user data to the VC and add automatic detection of all players in the lobby. We will describe the main beans on the server in the class, and those that need to be redefined will have to be entered in the xml (they are not redefined from the class):

beans.xml
 <?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:context="http://www.springframework.org/schema/context" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.2.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-3.2.xsd"> <import resource="classpath:/nadron/beans/server-beans.xml"></import> <context:annotation-config /> <bean id="webSocketLoginHandler" class="com.bbl.app.handlers.GameLoginHandler"> <property name="lookupService" ref="lookupService" /> <property name="idGeneratorService" ref="simpleUniqueIdGenerator" /> <property name="reconnectRegistry" ref="reconnectSessionRegistry" /> <property name="jackson" ref="jackson" /> </bean> </beans> 


Next, we create a GameLoginHandler class and inherit it from the standard WebSocketLoginHandler implementation. The framework has the ability to map user classes to deserialize json messages. To do this, simply send the class name from the client like this:

 session.send(nad.CNameEvent("com.bbl.app.events.CustomEvent")); 

But for authorization, this is not suitable, because a message with the data is sent immediately after connecting to the server. Therefore, as a message for a login, we will pass an associative array; for this, in nad-0.1.js we will replace the method of creating a login message with:

 nad.LoginEvent = function (config) { return nad.NEvent(nad.LOG_IN, config); } 

Where the config object appears instead of the old data array to which you can add data in the future without changing the library. It will look like this:

 var config = { user:"user", pass:"pass", uid:"1234567", key:"1234567", picture:"", sex:1, }; //       nad.sessionFactory("ws://localhost:18090/nadsocket", config, loginHandler); function loginHandler(session){ session.onmessage = messageHandler; session.send(nad.CNameEvent("com.bbl.app.events.CustomEvent")); } //   function messageHandler(e){ console.log(JSON.stringify(e.source)); } 

Handler implementation on server:

GameLoginHandler.java
 public class GameLoginHandler extends WebSocketLoginHandler { private static final Logger LOG = LoggerFactory.getLogger(GameLoginHandler.class); @Override public void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) throws Exception { Channel channel = ctx.channel(); String data = frame.text(); Event event = getJackson().readValue(data, DefaultEvent.class); int type = event.getType(); if (Events.LOG_IN == type) { LOG.trace("Login attempt from {}", channel.remoteAddress()); @SuppressWarnings("unchecked") Player player = lookupPlayer((LinkedHashMap<String, Object>) event.getSource()); handleLogin(player, channel); if(null != player) handleGameRoomJoin(player, channel, MyGame.LOBBY_NAME); } else if (type == Events.RECONNECT) { LOG.debug("Reconnect attempt from {}", channel.remoteAddress()); PlayerSession playerSession = lookupSession((String) event.getSource()); handleReconnect(playerSession, channel); } else { LOG.error("Invalid event {} sent from remote address {}. " + "Going to close channel {}", new Object[] { event.getType(), channel.remoteAddress(), channel }); closeChannelWithLoginFailure(channel); } } public Player lookupPlayer(Map<String, Object> source) throws Exception { String user = String.valueOf(source.get("user")); String vkUid = String.valueOf(source.get("uid")); String vkKey = String.valueOf(source.get("key")); GameCredentials credentials = new GameCredentials(user, vkUid, vkKey); credentials.setPicture(String.valueOf(source.get("picture"))); credentials.setSex(Integer.valueOf(String.valueOf(source.get("sex")))); credentials.setHash(String.valueOf(source.get("hash"))); Player player = getLookupService().playerLookup(credentials); if (null == player) { LOG.error("Invalid credentials provided by user: {}", credentials); } return player; } } 


The cradle class is inherited from library credentials with the addition of everything we need. Then we pass it to the LookupService to authorize or register an account and return the created instance of the player’s class. The player class (in the example it is called GamePlayer) also needs to be inherited from the standard Player. To verify the key soc. Networks need to have a dongle and application id; they are most conveniently written to the game class. The MyGame class is designed to load various data from the database or files needed for the game: maps, objects, etc.

GameLookupService.java
 public class GameLookupService extends SimpleLookupService{ @Autowired private MyGame myGame; @Autowired private GameDao gameDao; @Autowired private LobbyRoom lobby; @Autowired private GameManager gameManager; @Override public Player playerLookup(Credentials loginDetail) { Optional<GamePlayer> player = Optional.empty(); GameCredentials credentials = (GameCredentials) loginDetail; String authKey = myGame.getAppId() + '_' + credentials.getVkUid() + '_' + myGame.getAppSecret(); try { //   if (Objects.equals(credentials.getVkKey().toUpperCase(), MD5.encode(authKey))) { player = gameDao.fetchPlayerByVk(credentials.getVkUid(), credentials.getVkKey()); if(!player.isPresent()){ //   player = Optional.of(cratePlayer((GameCredentials) loginDetail)); gameDao.createPlayer(player.get()); } } } catch (Exception e) { e.printStackTrace(); } return player.orElse(null); } private GamePlayer cratePlayer(GameCredentials loginDetail) { GamePlayer player = new GamePlayer(); player.setVkUid(loginDetail.getVkUid()); player.setVkKey(loginDetail.getVkKey()); player.setName(loginDetail.getUsername()); player.setPicture(loginDetail.getPicture()); player.setSex(loginDetail.getSex()); player.setRef(loginDetail.getHash()); player.setMail(""); player.setCreated(LocalDateTime.now()); player.setRating(0); player.setMoney(10); player.setClanId(0L); return player; } @Override public GameRoom gameRoomLookup(Object gameContextKey) { return lobby; } } 


The lobby is one of the basic mechanics that is required in most games.
Here, instead of a room using the key from the gameRoomLookup method, as in the official example, we always give a copy of LobbyRoom. Thus, all connected players will automatically fall into this room.

Work with database


Let's just digress and consider the option of working with a database. The Flyway library along with its plug-in allows you to automatically execute a sequence of sql files for the database, taking into account versioning, at startup. This is suitable to describe the database structure once and forget about it in the future. By default, the files should be located in the src / main / resources / db / migration folder of your project, and the files themselves should start with V [n] __ name.sql. The user table creation file will look like this:

V1__create_game_tables.sql
 CREATE TABLE public.players ( id bigserial NOT NULL, mail character varying, name character varying, password character varying, vk_uid character varying, vk_key character varying, ref character varying, sex integer NOT NULL, money integer NOT NULL, rating integer NOT NULL, clan_id bigint NOT NULL, created timestamp without time zone NOT NULL, CONSTRAINT players_pkey PRIMARY KEY (id) ) WITH ( OIDS=FALSE ); CREATE EXTENSION IF NOT EXISTS citext; ALTER TABLE players ALTER COLUMN vk_uid TYPE citext; 


For everything to work quickly, you need a pool of connections for the database, - for this we have a c3p0 library with ready caching of requests. Above in GameLookupService, the base is already used to search for player data or to create it.

Developments


So, we logged in and got to the room where now you need to send something to the client. For convenience, we will define our CustomEvent event class, which will help us receive and send any commands without overlapping the framework's basic logic.

CustomEvent.java
 @SuppressWarnings("serial") public class CustomEvent extends DefaultEvent { private EventData source; @Override public EventData getSource() { return source; } public void setSource(EventData source) { this.source = source; } public static NetworkEvent networkEvent(GameCmd cmd, Object data) throws JSONException { EventData source = new EventData(); source.setCmd(cmd.getCode()); source.setData(data); return Events.networkEvent(source); } } 


Lobby and command handling


Implementing the lobby is probably not such an easy task (at least there is a question on the official forum). In our example, the lobby is a regular room with its own state (state) and in a single copy for the game. When entering the lobby, we send all player data. To make it clear, I will provide the code for the entire class:

LobbyRoom.java
 public class LobbyRoom extends GameRoomSession { private static final Logger LOG = LoggerFactory.getLogger(LobbyRoom.class); private RoomFactory roomFactory; public LobbyRoom(GameRoomSessionBuilder gameRoomSessionBuilder) { super(gameRoomSessionBuilder); this.addHandler(new LobbySessionHandler(this)); getStateManager().setState(new LobbyState()); } @Override public void onLogin(PlayerSession playerSession) { LOG.info("sessions size: " + getSessions().size()); playerSession.addHandler(new PlayerSessionHandler(playerSession)); try { playerSession.onEvent(CustomEvent.networkEvent(GameCmd.PLAYER_DATA, playerSession.getPlayer())); } catch (JSONException e) { LOG.error(e.getMessage()); e.printStackTrace(); } } public RoomFactory getRoomFactory() { return roomFactory; } public void setRoomFactory(RoomFactory roomFactory) { this.roomFactory = roomFactory; } } 


In Nadron, there are two types of handlers: one for the player’s session, the second for processing the commands of the room itself.

In order to process incoming commands from the client in the room, you need to send events from the player's session handler. The new method will look like this:

 @Override protected void onDataIn(Event event) { if (null != event.getSource()) { event.setEventContext(new DefaultEventContext(playerSession, null)); playerSession.getGameRoom().send(event); } } 

Thus, the events will go to the current room in which the player is located. The onEvent method will process them, below is an example of such processing. Note that in the absence of a command, we throw a special InvalidCommandException exception. All rooms will execute commands sequentially, but the best practice will be to clone objects .

LobbySessionHandler.java
 @Override public void onEvent(Event event) { CustomEvent customEvent = (CustomEvent) event; GameCmd cmd = GameCmd.CommandsEnum.fromInt(customEvent.getSource().getCmd()); try { switch (cmd) { case CREATE_GAME: createRoom(customEvent); break; case GET_OPEN_ROOMS: broadcastRoomList(customEvent); break; case JOIN_ROOM: connectToRoom(customEvent); break; default: LOG.error("Received invalid command {}", cmd); throw new InvalidCommandException("Received invalid command" + cmd); } } catch (InvalidCommandException e) { e.printStackTrace(); LOG.error("{}", e); } } 


Game rooms


Of all the questions, one important one remained - the creation of a new room and the transfer of players into it. The problem is that you cannot go to a room with the same protocol, otherwise the protocols overlap each other. To correct this mistake, you need to add a check for the existence of handlers:

GameWebsocketProtocol.java
 public class GameWebsocketProtocol extends AbstractNettyProtocol { private static final Logger LOG = LoggerFactory.getLogger(WebSocketProtocol.class); private static final String TEXT_WEBSOCKET_DECODER = "textWebsocketDecoder"; private static final String TEXT_WEBSOCKET_ENCODER = "textWebsocketEncoder"; private static final String EVENT_HANDLER = "eventHandler"; private TextWebsocketDecoder textWebsocketDecoder; private TextWebsocketEncoder textWebsocketEncoder; public GameWebsocketProtocol() { super("GAME_WEB_SOCKET_PROTOCOL"); } @Override public void applyProtocol(PlayerSession playerSession, boolean clearExistingProtocolHandlers) { applyProtocol(playerSession); if (clearExistingProtocolHandlers) { ChannelPipeline pipeline = NettyUtils.getPipeLineOfConnection(playerSession); if (pipeline.get(LoginProtocol.LOGIN_HANDLER_NAME) != null) pipeline.remove(LoginProtocol.LOGIN_HANDLER_NAME); if (pipeline.get(AbstractNettyProtocol.IDLE_STATE_CHECK_HANDLER) != null) pipeline.remove(AbstractNettyProtocol.IDLE_STATE_CHECK_HANDLER); } } @Override public void applyProtocol(PlayerSession playerSession) { LOG.trace("Going to apply {} on session: {}", getProtocolName(), playerSession); ChannelPipeline pipeline = NettyUtils.getPipeLineOfConnection(playerSession); if (pipeline.get(TEXT_WEBSOCKET_DECODER) == null) pipeline.addLast(TEXT_WEBSOCKET_DECODER, textWebsocketDecoder); if (pipeline.get(EVENT_HANDLER) == null) pipeline.addLast(EVENT_HANDLER, new DefaultToServerHandler(playerSession)); if (pipeline.get(TEXT_WEBSOCKET_ENCODER) == null) pipeline.addLast(TEXT_WEBSOCKET_ENCODER, textWebsocketEncoder); } public TextWebsocketDecoder getTextWebsocketDecoder() { return textWebsocketDecoder; } public void setTextWebsocketDecoder(TextWebsocketDecoder textWebsocketDecoder) { this.textWebsocketDecoder = textWebsocketDecoder; } public TextWebsocketEncoder getTextWebsocketEncoder() { return textWebsocketEncoder; } public void setTextWebsocketEncoder(TextWebsocketEncoder textWebsocketEncoder) { this.textWebsocketEncoder = textWebsocketEncoder; } } 


The very same disconnection from the current room and connecting to another will look like this:

 private void changeRoom(PlayerSession playerSession, GameRoom room) { playerSession.getGameRoom().disconnectSession(playerSession); room.connectSession(playerSession); } 

This is all I wanted to talk about. The full code can be found in github .

Source: https://habr.com/ru/post/354292/


All Articles