FirstSteps

Max S edited this page Jun 17, 2016 · 10 revisions

First Steps

Overview

This article is a short tutorial intended to familiarize you with the basics of project development on the Snipe server. Parts of it are explained more fully in other articles dedicated to these components and concepts. All of the code in this article is available in the "examples/firstSteps/" directory.

The basic Snipe server instance consists of a uniserver glueing together the game server, and the cache server into one. This article will make a simple example game based on that. Here's the description of the game: users can register, login into the game and start a new turn-based battle with a monster. The battle consists of hitting the monster until it's dead. The monster hits back every time. Upon winning the battle, the player receives some money as a reward that is stored in his record in database. If the player loses, he receives less money. If the player disconnects during the battle, he loses automatically. To simplify the tutorial, we will restore the player's life to maximum on each battle start. We will also make an automated script that does all mentioned to test the game functionality.

Since the default user registration and login will do fine for this tutorial, we will use them. You can read about user registration and login in detail in User Registration and Login article.

Creating a cache server class

The cache server for this example project will not have any additional functions apart from core ones, so no cache server modules will be created. The resulting class is the bare bones version with nothing apart from the needed minimum.

import snipe.cache.CacheServer;

class CacheServerTest extends CacheServer
{
  function new()
    {
      super();
    }


  static var s: CacheServerTest;
  static function main()
    {
      s = new CacheServerTest();
      s.initServer();
      s.start();
    }
}

This file is enough to make a simple cache server. The provided "cacheserver.hxml" file will do for the compilation.

Compiling the cache server is done with this command:

haxe cacheserver.hxml

Note that you will have to provide correct paths inside.

Creating game server client class

Before we start implementing the game logic, we will need the client class. The client class should extend ClientGame class. For the purpose of this example we will add some additional properties to make life easier.

Here's the client class skeleton:

import sys.net.Socket;

import snipe.slave.ServerGame;
import snipe.slave.ClientGame;

class TestClient extends ClientGame
{
  public function new(s: Socket, srv: ServerGame)
    {
      super(s, srv);
    }
}

We will need to store some game-specific information, the game state and the battle state:

  public var state: String; // game state
  public var battle: {
    monster: {
      name: String,
      life: Int,
      maxLife: Int,
      damage: Int
      }
    };

The game state will be simple in this case - either player is in a battle or idle. So this variable will have two values: "battle" or empty string.

Add these to the constructor just in case:

  public function new(s: Socket, srv: Server)
    {
      super(s, srv);
      state = '';
      battle = null;
    }

We will also need player life and player money:

  public var life(get, set): Int; // player life
  public var money(get, set): Int; // player money

  function get_life(): Int
    {
      return user.attrs.get("life");
    }

  function set_life(v: Int)
    {
      user.attrs.set("life", v);
      return v;
    }

  function get_money(): Int
    {
      return user.attrs.get("money");
    }

  function set_money(v: Int)
    {
      user.attrs.set("money", v);
      return v;
    }

Note the usage of getters and setters. Both "life" and "money" are properties that link directly to the user attributes with these names. Whenever you change them, the attribute value will be changed in user block and later sent out to the cache server and written to database. In other words, the values of these attributes are persistent, unlike the "state" and "battle" fields. You don't have to wrap any user attributes in getters and setters, it just makes their usage more easy and less prone to errors. The data blocks are described in detail in Data Blocks article.

People love counters, so let's add the counters for battles won and battles lost:

  public var battlesLost(get, set): Int; // battles lost
  public var battlesWon(get, set): Int; // battles won

  function get_battlesLost(): Int
    {
      return user.vars.get("battlesLost");
    }

  function set_battlesLost(v: Int)
    {
      user.vars.set("battlesLost", v);
      return v;
    }

  function get_battlesWon(): Int
    {
      return user.vars.get("battlesWon");
    }

  function set_battlesWon(v: Int)
    {
      user.vars.set("battlesWon", v);
      return v;
    }
}

Note that these point to user variables instead. There is not much difference between user attributes and user variables except that logically attributes are intended for something that changes quite often, for example, on each client request. The variables are for things that are changed more rarely, maybe just a couple of times per game session.

With this the game client class is ready. Let's move on to game logic in the next section.

Creating game server module

The game server for this example will have a single module that handles the battle. This module must do the following things:

  • handle the client request to start a new battle ("battle.start");
  • handle the client request to go to the next turn in a battle ("battle.turn");
  • make the player lose the battle automatically if he disconnects;

Let's start with the skeleton of the module:

package modules;

import snipe.slave.Module;
import snipe.lib.Params;


class BattleModule extends Module<TestClient, ServerTest>
{
  public function new(srv: ServerTest)
    {
      super(srv);
      name = "battle";
    }


  public override function call(c: TestClient, type: String, params: Params): Dynamic
    {
      var response = null;

      return response;
    }
}

This skeleton overrides the call() method. This method is used as an entry-point to the module. Whenever a logged in client makes a properly formed request with the type "battle.XXX", this request will be routed to this method, because the module has the name "battle". It is up to this method to route the request further to the proper request handler.

The next thing we need is to make a handler for battle start (message type "battle.start"). We add the line to the call() method:

  public override function call(c: TestClient, type: String, params: Params): Dynamic
    {
      var response = null;

      if (type == "battle.start")
        response = start(c, params);

      return response;
    }

And we make the start() method:

  public function start(c: TestClient, params: Params): Dynamic
    {
      // user not idle
      if (c.state != '')
        return { errorCode: 'notIdle' };

      c.state = 'battle';
      c.battle = { monster: {
        name: 'Goblin',
        life: 10,
        maxLife: 10,
        damage: 2
        } }
      c.life = 100;

      return {
        errorCode: 'ok',
        life: c.life,
        battle: c.battle
        };
    }

Whatever the method returns will be sent to the client as a response. This response will have the same type as the request ("battle.start" in this case). We use it to return an error in case when player is already in a battle and to notify the client that the battle has started. We also send the battle and player state in the notification.

The next thing we need is a handler for client request to go to next turn in a battle. We'll modify the call() method:

  public override function call(c: TestClient, type: String, params: Params): Dynamic
    {
      var response = null;

      if (type == "battle.start")
        response = start(c, params);

      else if (type == "battle.turn")
        response = turn(c, params);

      return response;
    }

And we will create the methods that handle the request:

  public function turn(c: TestClient, params: Params): Dynamic
    {
      var type = params.getInt('type');
      if (type != 0 && type != 1)
        type = 0;

      // user not in battle
      if (c.state != 'battle')
        return { errorCode: 'notInBattle' };

      var battleFinished = false;

      // player hits
      var playerDamage = (type == 0 ? 2 : 4);
      c.battle.monster.life -= playerDamage;

      if (c.battle.monster.life <= 0)
        battleFinished = true;

      // monster hits
      if (!battleFinished)
        c.life -= c.battle.monster.damage;

      if (c.life <= 0)
        battleFinished = true;

      // send response by hand so in case of finished battle
      // the client would receive it before the battle finish
      c.response('battle.turn', {
        errorCode: 'ok',
        playerDamage: playerDamage,
        monsterDamage: c.battle.monster.damage
        });

      // battle finish
      if (battleFinished)
        finishBattle(c, (c.life <= 0 ? 'lose' : 'win'));

      return null;
    }


  function finishBattle(c: TestClient, result: String)
    {
      c.state = '';
      var money = (result == 'win' ? 10 : 1);
      c.money += money;

      if (result == 'win')
        c.battlesWon++;
      else c.battlesLost++;

      c.response('battle.finish', {
        errorCode: 'ok',
        type: (result == 'win' ? 'winBattle' : 'loseBattle'),
        moneyWon: money,
        battlesWon: c.battlesWon,
        battlesLost: c.battlesLost,
        money: c.money
        });
    }

Everything is straightforward here except that we return null in the handler making the response() call manually. This is done so that when the battle ends, the message about the end will be sent to the client after the new turn response. Also note that the "battle.turn" method accepts "type" parameter that determines attack type - basic or strong.

Now the battle is working, the only problem is that if tricky player disconnects the client from the game during the battle, he will not receive any money (we decided not to punish the player for defeat). That means that we need to call battle defeat method automatically on player disconnect. Here is how it's done. We subscribe the module to player logout event with this line in the module constructor:

      server.subscribeModule("core/user.logoutPost", this);

And we make the actual event handler contents:

  override function logoutPost(c: TestClient)
    {
      if (c.state == 'battle')
        finishBattle(c, 'lose');
    }

If the player is in a battle, forcibly finish this battle with defeat. The logout event handler will be executed directly after the server detects that the client disconnected while his state is still fully available and unchanged. Event handlers are described in detail in Module Subscriptions article.

The game logic is ready, now we need to create game server class and set up some additional core classes.

Creating game server class

The game server class is not much more than a skeleton here. The only thing it does, is load the battle game module. Take a look:

import snipe.slave.MetaServer;
import snipe.slave.ServerGame;

class ServerTest extends ServerGame
{
  function new(metav: MetaServer, idv: Int)
    {
      super(metav, idv);
    }


  override function initModulesGame()
    {
      loadModules([ modules.BattleModule ]);
    }


  static var s: ServerTest;
  static function main()
    {
      var meta = new MetaServer('game', ServerTest, TestClient);
      meta.initServer();
      meta.start();
    }
}

Now on to setting up the core. This is done through three classes: ServerClass, ItemProtoClass and UserClass. These three classes have to be in the game server directory for each project. Let's see what they contain and do.

ServerClass.hx contents are:

typedef ServerClass = ServerTest;

This type definition is used by the core in all core types that require the game server type as a parameter. It permits the ability to use the project server class in all these places, so when you extend these classes, the "server" property will have the class that you supply here. This helps immensely when you have some properties in your game server class, for example, the game module links. You can simply write something like server.myModule.apiCall() without having to cast the type directly or use the untyped keyword.

ItemProtoClass.hx contains the following line:

typedef ItemProtoClass = snipe.slave.data.ItemProtoCore;

As you might have guessed, it does the same thing as ServerClass but for the item prototype class it is used in items and inventory.

The third file, UserClass.hx, contains this:

import snipe.slave.data.*;

typedef UserClass = UserCore<UserAttributesCore,
  UserVariablesCore,
  UserQuestsCore,
  UserChainsCore,
  InventoryCore,
  EffectsCore,
  BadgesCore>

It does exactly the same thing for user subclasses: quests, inventory, effects, etc. If you decide to extend the core user functionality, you will need to change not only the user class definition but also the contents of this file, supplying the names of new classes that your project uses. It's bit of a hassle, but in return you will receive the great benefit of core module methods and properties using your project classes. It simplifies the code while retaining all the type correctness checks.

Compiling the game server is done with this command:

haxe hserver.hxml

Note that you will have to provide the correct paths inside.

The last thing that remains is to make a uniserver class for your project. The next section discusses that.

Creating a uniserver class

While you can compile the game server and cache server as separate applications using the compilation commands shown earlier and run them separately, chances are you won't need this until much later in your project life. The better way is to glue them together into a single application through the uniserver.

To compile the uniserver properly it must have access to the code of both the cache server and game server. In the case of this example we create the UniServerTest class right in the "examples/firstSteps/" directory and put the paths for game and cache server code into the "uniserver.hxml". You might do it in some other way. The contents of uniserver class are also pretty skeletal:

import snipe.cache.UniServer;

class UniServerTest extends UniServer
{
  function new()
    {
      super();
    }


// main function
  static var u: UniServerTest;
  static function main()
    {
      u = new UniServerTest();
      u.print("UniServerTest " +
        snipe.lib.MacroBuild.getBuildBranch() + "-" +
        snipe.lib.MacroBuild.getBuildDate());
      u.print("Snipe Core " +
        snipe.lib.MacroBuild.getCoreVersion('../..') + "-" +
        snipe.lib.MacroBuild.getCoreBranch('../..') + "-" +
        snipe.lib.MacroBuild.getCoreDate('../..'));
      u.cacheClass = CacheServerTest;
      u.slaveInfos = [
        {
          type: 'game',
          client: TestClient,
          server: ServerTest,
        },
        ];
      u.init();
      u.start();
    }
}

The first thing that is of interest here is the cacheClass and slaveInfos assignment. The first one gives the uniserver the class that should be used as a cache server class. The second one defines all slave servers that this uniserver should load and get running. You can only have one game server and specifically one class that extends the base ServerGame class. All the other slave servers should extend the Server class.

Also note the use of macros. There are a couple of useful build macros defined in snipe.lib.MacroBuild. They give you the build branch, and build date, and also the Snipe core build branch, and date of last commit (you will have to give the Snipe core directory as an argument in the last two). These lines are not necessary and you can safely delete them if you don't want this information.

Compiling the uniserver is done with this command:

haxe uniserver.hxml

Note that you will have to provide correct paths inside. Of particular note is the line -D uniserver. It changes the compilation mode to produce uniserver. Without it the compilation of uniserver class will fail.

If you did everything right (or used the files that are provided instead) you should have the "uniserver.n" file. The next section discusses how to run the Snipe server and what files will be created in the process.

Running the server

Running the server is done with this command:

neko uniserver.n

You should have the configuration files for the uniserver, cache server and game server in the same directory as the "uniserver.n" file. Their names are: "uniserver.cfg", "cacheserver.cfg" and "hserver.game.cfg". By default the game server will be up on localhost port 2010. You can change the configuration files to your liking. The configuration files for the server are described in detail in the Server Configuration article.

After you start the server you will see a lot of log messages given by the server initialization. By default the log messages are both written to logfiles and printed to stdout. You can change this in the configuration files.

When you first start the server, the following directories and files will be created:

  • "logs.cache/", "logs.game/" - these are directories for logs.
  • "trace.cache/", "trace.game/" - these are directories for important logs. Whenever you log something with "trace" type, it will be copied into the trace log. All module exceptions and low-level errors are also copied there.
  • "stats.cache/", "stats.game/" - these directories contain real-time statistics logs. You can read about it in Realtime Stats article.
  • "tmp/" - this is the directory for Temp subsystem. You can read about it in Temp article.
  • "_serverlist.txt" - this file contains a serialized JSON of all active slave servers connect information. You can open it to external clients and use it to select which slave server to connect to. Its contents are described in Server List article.

Now that the server is up and running with game logic done, one last thing remains. Strictly speaking it's optional but it will be useful to you during the development or bug-fixing. This thing is discussed in the next section.

Creating a script for testing

After you've finished implementing the game logic for battles, you need to test it to see if it works properly. Unfortunately, at this point in the development process, the client functionality may not be ready yet. Instead of waiting until the client is ready, you can use the script-based testing tool provided in the Snipe server package. The main article that describes it is Script-based Testing but we can show the short version here.

The tool can connect to the game server and act as a game client (it can do more but that is the only thing we currently need). Using the script written in a special scripting language, the tool can send messages to the server and check the responses for validity.

The basic script that we need should register a new user, login into the game server, start a new battle, do some turns until the user wins then receive a message about battle finish. Here's the script that does that:

@include include/login.txt

@send.game battle.start
@recv.game
errorCode = ok

// wrong
@send.game battle.start
@recv.game
errorCode = notIdle

@send.game battle.turn
type = 0
@recv.game
errorCode = ok

@send.game battle.turn
type = 0
@recv.game
errorCode = ok

@send.game battle.turn
type = 1
@recv.game
errorCode = ok

@send.game battle.turn
type = 1
@recv.game
errorCode = ok

@recv.game battle.finish
errorCode = ok
battlesWon = 1

The first line of the script includes the basic script for registering a new user and logging in. It is a copy of script located in "tools/scriptTest/include/". After that we send some "battle.turn" messages until the battle should finish and check for correctness receiving "battle.finish".

This tutorial should be enough to get you going in developing your project based on the Snipe server. The only thing that is missing from the usual workflow is the editor. The editor skeleton is provided in the "edit/" directory. You can read about creating the editor modules in the Editor Modules article.

Next: User Registration and Login

You can’t perform that action at this time.
You signed in with another tab or window. Reload to refresh your session. You signed out in another tab or window. Reload to refresh your session.
Press h to open a hovercard with more details.