Guideline on How to Build MO Game Server

iFun Engine helps quickly build real-time Multiplayer Online (MO) games. In this chapter, we will see how to implement game sessions supporting real-time multiplayer. Especially, this document assumes a room system for MO games.

  • Asynchronous event processing
  • Lock-free synchronization
  • Asynchronous callback (using C++ 11)

Creating a Project

Let’s create a new project named room.

$ funapi_initiator room
$ room-source/setup_build_environment --type=makefile

Then, we will edit following files.

  • CMakeLists.txt
  • src/event_handlers.cc
  • src/object_model/example.json

To enable C++ 11

Open the top-level CMakeLists.txt and flips the value of set(WANT_CXX11 false) to true. It should look like this:

# 중략

# Needs C++1x features? (Requires modern C++ compiler)
set(WANT_CXX11 true)

MO Sessions

Implementing MO rooms

Say, we have an ORM object for User like this:

src/object_model/example.json

{
  "User": {
    "Name": "String KEY",
    "Level": "Integer",
    "CharacterId": "Integer"
  }
}

Then we might implement the room system like this:

Note

It’s a better idea to define ErrorCode to pass the result. But we use simpler boolean result for brevity.

src/event_handlers.cc

#include <funapi.h>

void SendMessage(const Ptr<Session> &session, const string &msg_type,
                 const bool result) {
  Json message;
  message["result"] = result;
  session->SendMessage(msg_type, message);
  return;
}


// This class represents a single room.
class Room : public boost::enable_shared_from_this<Room> {
 public:
  DECLARE_CLASS_PTR(Room);

  typedef boost::unordered_map<Uuid, Ptr<Room> > RoomMap;

  // This class represents a User in the room.
  // Please note that this is a class different than the ORM class.
  // It's because we want to store room-specific, in-memory data in this class
  // and such data should not stored in the DB.
  struct User {
    User(const Ptr<Session> &_session, const string &_name,
         const int64_t _character_id, const int64_t _level)
      : session(_session), name(_name),
        character_id(_character_id), level(_level) {
    }

    // Pointer to the user Session
    const Ptr<Session> session;

    // The user name.
    string name;

    // The user character id.
    int64_t character_id;

    // the user level.
    int64_t level;
  };

  static Ptr<Room> Create(const string &name,
                          const int64_t joinable_min_level,
                          const string &master_user_name) {
    Uuid id = RandomGenerator::GenerateUuid();
    Ptr<Room> room(new Room(id, name, joinable_min_level, master_user_name));

    boost::mutex::scoped_lock lock(the_mutex);
    the_rooms.insert(std::make_pair(id, room));

    return room;
  }


  static Ptr<Room> Find(const Uuid &id) {
    boost::mutex::scoped_lock lock(the_mutex);

    RoomMap::iterator it = the_rooms.find(id);
    if (it == the_rooms.end()) {
      return Room::kNullPtr;
    }

    return it->second;
  }


  static Json GetRooms() {
    Json rooms;
    {
      boost::mutex::scoped_lock lock(the_mutex);

      BOOST_FOREACH(const RoomMap::value_type &pair, the_rooms) {
        string room_id = boost::lexical_cast<string>(pair.first);
        rooms[room_id] = pair.second->GetInfo();
      }
    }

    return rooms;
  }


  ~Room() {
    sessions_.clear();
  }


  const Uuid &id() const { return id_; }
  const string &name() const { return name_; }
  int64_t joinable_min_level() const { return joinable_min_level_; }
  const string &master_user_name() const { return master_user_name_; }
  void SetMasterUserName(const string &master_user_name) {
    master_user_name_ = master_user_name;
  }


  // This processes user's entering the room.
  // It's invoked once the server receives a "client_join" from the client.
  void HandleJoin(const Ptr<Session> &session, const string &name,
                  const int64_t character_id, const int64_t level) {
    // Checks the conditions if the user can enter.

    // Checks if already in the room.
    if (sessions_.count(session->id()) > 0) {
      SendMessage(session, "client_join", false);
      return;
    }

    // Checks the validity of a name and character id.
    if (name.empty() || character_id < 0) {
      SendMessage(session, "client_join", false);
      return;
    }

    // Checks if user has enough level to enter.
    if (joinable_min_level() < level) {
      SendMessage(session, "client_join", false);
      return;
    }

    // Everything seems OK. Has the user enter.
    Ptr<User> user(new User(session, name, character_id, level));
    bool has_not_joined =
        sessions_.insert(std::make_pair(session->id(), user)).second;

    // This cannot happen as we already checked above.
    BOOST_ASSERT(has_not_joined);

    // Succeeded!
    // Stores the room id in the user session's context.
    // In this way, we can quickly figure out which room the user belongs to.
    session->AddToContext("room_id", boost::lexical_cast<string>(id()));

    // Lets other users in the room know the newbie.
    Json message;

    // Constructs a broadcast message containing the newbie's information.
    message["name"] = name;
    message["character_id"] = character_id;
    message["level"] = level;
    for (const auto &v: sessions_) {
      // Sends the message to the room member as "server_user_join".
      // We assume we do not want to send a message to a user temporarily
      // disconnected, though the user could restore the session.
      if (v.second->session->IsTransportAttached()) {
        v.second->session->SendMessage("server_user_join", message);
      }
    }
  }


  // This processes user's leaving the room.
  void HandleLeave(const Ptr<Session> &session) {
    string user_name;
    {
      auto user = sessions_.find(session->id());

      // The user is not in the room.
      if (user == sessions_.cend()) {
        SendMessage(session, "client_leave", false);
        return;
      }

      user_name = user->second->name;

      // Removes the user from the member list.
      sessions_.erase(user);

      // If the room becomes empty, clears the room here.
      // We assume unnamed room indicates an empty room.
      if (sessions_.size() == 0) {
        name_ = "";
      }

      // Sends a message to the leaving user that it's all set.
      SendMessage(session, "client_leave", true);
    }

    // If remaining users in the room, let them know the user has left.
    Json message;
    message["name"] = user_name;
    for (const auto &v: sessions_) {
      // We assume only connected users are interested in this event.
      if (v.second->session->IsTransportAttached()) {
        v.second->session->SendMessage("server_user_leave", message);
      }
    }
  }


  // Handler for a chat message from the client.
  void HandleChat(const Ptr<Session> &session, const string &msg) {
    string user_name;
    {
      auto user = sessions_.find(session->id());

      // The user is not in the room.
      if (user == sessions_.cend()) {
        SendMessage(session, "client_chat", false);
        return;
      }

      user_name = user->second->name;
    }

    // User name cannot be empty in this example.
    BOOST_ASSERT(not user_name.empty());

    // Constructs a message and broadcasts to the room.
    Json message;
    message["name"] = user_name;
    message["msg"] = msg;
    for (const auto &v: sessions_) {
      // We assume only connected users are interested in this event.
      if (v.second->session->IsTransportAttached()) {
        v.second->session->SendMessage("server_user_chat", message);
      }
    }
  }


  // Handler for match start in the room.
  // Here, we assume this handler is invoked when the room leader click on
  // the "Start' button.
  void HandleStartMatch(const Ptr<Session> &session,
                        const string &user_name) {
    // Only room leader can start.
    if (user_name != master_user_name()) {
      session->Close();
      return;
    }

    Json message;

    // Randomly selects a map for new match.
    int64_t map_id = RandomGenerator::GenerateNumber(100, 200);

    // Specifies how long the users will fight. Here, we assume 180 sec.
    message["match_time"] = 180;
    message["match_map_id"] = map_id;

    // Broadcasts the match start message to the users.
    for (const auto &v: sessions_) {
      // session 에 transport 가 연결되어 있을 경우에만 메시지를 전송.
      if (v.second->session->IsTransportAttached()) {
        v.second->session->SendMessage("server_start_match", message);
      }
    }

    // Sets a timer to keep track of when it must end.
    // If match can be canceled for some reason, we can do it by calling
    // Timer::Cancel(match_timer_id).
    match_timer_id_ =
        Timer::ExpireAfter(
            WallClock::FromMsec(180 * 1000),
            bind(&Room::EndMatch, shared_from_this()));
  }


  // Finishes the match.
  void EndMatch() {
    // Checks who won and who lost.
    // Then, sends the result message to the users.
    Json message;

    // ...

    for (const auto &v: sessions_) {
      // session 에 transport 가 연결되어 있을 경우에만 메시지를 전송.
      if (v.second->session->IsTransportAttached()) {
        v.second->session->SendMessage("server_end_match", message);
      }
    }
  }


 private:
  Room(const Uuid &id, const string &name, const int64_t joinable_min_level,
       const string &master_user_name)
    : id_(id), name_(name), joinable_min_level_(joinable_min_level),
      master_user_name_(master_user_name) {
  }


  // Returns a list of room in JSON.
  Json GetInfo() const {
    Json info;
    info["name"] = name();
    int64_t user_count = sessions_.size();
    info["user_count"] = user_count;
    info["joinable_min_level"] = joinable_min_level();
    info["master_user_name"] = master_user_name();

    return info;
  }


  // Room's uuid.
  const Uuid id_;

  // Room's name.
  string name_;

  // Minimum user level to join.
  int64_t joinable_min_level_;

  Timer::Id match_timer_id_;

  // Room leader's name.
  string master_user_name_;

  typedef boost::unordered_map<
      SessionId, Ptr<User>, boost::hash<Uuid>> SessionMap;
  SessionMap sessions_;

  static boost::mutex the_mutex;
  static RoomMap the_rooms;
};


DEFINE_CLASS_PTR(Room);
boost::mutex Room::the_mutex;
Room::RoomMap Room::the_rooms;

As mentioned in the above, iFun Engine enables lock-free synchronization. In fact, this does not mean anything can be run in any way. Instead, it means iFun Engine has an internal way to force event serialization. And to leverage the feature, we need to use its event system in a certain way.

Here, we need two things.

  • Synchronization unit: Room::id()
  • Event handlers to be serialized: Room::HandleJoin(), Room::HandleLeave(), Room::HandleChat(), and Room::HandleStartMatch().

Event serialization

In each client message handler, we will serialize using the room id.

src/event_handlers.cc

void OnSessionOpened(const Ptr<Session> &session) {
}


// Will be invoked once a session gets closed.
// In this example, we assume the user leaves a room only when the session
// gets lost. TCP disconnect handler, OnTcpTransportDetached(), will not
// handle leaving a room.
void OnSessionClosed(const Ptr<Session> &session, SessionCloseReason reason) {
  // Checks if the user is in the game server.
  string user_name = AccountManager::FindLocalAccount(session);
  if (user_name.empty()) {
    return;
  }

  string room_id_str;
  // Extracts the room id from the session context.
  if (not session->GetFromContext("room_id", &room_id_str)) {
    // The user is not in any room.
    return;
  }

  Uuid room_id = boost::lexical_cast<Uuid>(room_id_str);
  Ptr<Room> room = Room::Find(room_id);

  // We assume the room must exist, as we terminate a room only when everyone
  // has left the room.
  BOOST_ASSERT(room);

  // Forgets the user
  bool is_user_logged_out = AccountManager::SetLoggedOut(user_name);

  // We assume this cannot fail.
  BOOST_ASSERT(is_user_logged_out);

  // Serializes the event using the room id.
  Event::Invoke([room, session]() {
        room->HandleLeave(session);
      }, room_id);
}


// This handler will be invoked once the TCP connection is lost.
// This is different from closing a session, as session can continue
// with a new TCP connection. Session closes if it remains idle for some time.
void OnTcpTransportDetached(const Ptr<Session> &session) {
  // We assume TCP disconnection is not related to leaving a room.
}


// Handles user's login.
void OnClientLogin(const Ptr<Session> &session, const Json &message) {
  // Checks if the message is valid.
  if (not message.HasAttribute("user_name")) {
    SendMessage(session, "client_login", false);
    return;
  }

  // Extract the user's name from the message.
  string user_name = message["user_name"].GetString();

  // Fetches the user data from the DB via iFun Engine's ORM.
  Ptr<User> user = User::FetchByName(user_name);

  // If the user does not exist, creates one.
  if (not user) {
    user = User::Create(user_name);

    // If there's a user with the same name exists, this will return null.
    // Please note that this is still possible though we've already checked
    // the user name is not occupied, for other user can use the name, too.
    if (not user) {
      SendMessage(session, "client_login", false);
      return;
    }

    // Initializes the user object.
    user->SetLevel(1);
    user->SetCharacterId(1);
  }

  // Now, the user pointer must be valid.
  BOOST_ASSERT(user);

  // Reads the level and character id.
  int64_t user_level = user->GetLevel();
  int64_t character_id = user->GetCharacterId();

  // Adds code if you need to do.
  // ...

  // Marks the user logged in via AccountManager.
  // We will map the session to the user name.
  // If the player name has already logged in, it will return false.
  // This cannot happen in this example, but we are adding a protection code.
  if (not AccountManager::CheckAndSetLoggedIn(user_name, session)) {
    SendMessage(session, "client_login", false);
    return;
  }

  // Sends the user its information.
  Json response;
  response["result"] = true;
  response["user_level"] = user_level;
  response["character_id"] = character_id;
  session->SendMessage("client_login", response);
}


void OnClientLogout(const Ptr<Session> &session, const Json &message) {
  // Checks if the user is on the game server.
  string user_name = AccountManager::FindLocalAccount(session);
  if (user_name.empty()) {
    SendMessage(session, "client_logout", false);
    return;
  }

  // Extracts the room id from the session context.
  string room_id_str;
  if (not session->GetFromContext("room_id", &room_id_str)) {
    // 만약 Room 에 입장하지 않았다면 room_id 가 없을 수 있습니다.
    // 이 경우에는 단순히 로그아웃되었다고 처리합니다.
    SendMessage(session, "client_logout", true);
    return;
  }

  // The room id cannot be empty.
  BOOST_ASSERT(not room_id_str.empty());

  // Removes the user from the room members list.
  Uuid room_id = boost::lexical_cast<Uuid>(room_id_str);
  Ptr<Room> room = Room::Find(room_id);

  // The room must exist.
  BOOST_ASSERT(room);

  // Marks the user logged out.
  bool is_user_logged_out = AccountManager::SetLoggedOut(user_name);

  // Assume this cannot fail
  // (unless we are trying to log out a user already logged out)
  BOOST_ASSERT(is_user_logged_out);

  // Please note that we are serializing events using the room's id.
  Event::Invoke([room, session]() {
        room->HandleLeave(session);
      }, room_id);
}


void OnClientJoinRoom(const Ptr<Session> &session, const Json &message) {
  // Checks if the user is on the game server.
  string user_name = AccountManager::FindLocalAccount(session);
  if (user_name.empty()) {
    // Ignores if the player is not a local account.
    SendMessage(session, "client_join_room", false);
    return;
  }

  Ptr<User> user = User::FetchByName(user_name);
  // We assume the user data exist.
  BOOST_ASSERT(user);

  int64_t character_id = user->GetCharacterId();
  int64_t user_level = user->GetLevel();

  // If "room_id" is not in the message, creates a new room.
  Ptr<Room> room;
  if (not message.HasAttribute("room_id")) {
    // To create a new room, the user must provide a room name and minimum
    // level to join.
    if (not message.HasAttribute("room_name") ||
        not message.HasAttribute("joinable_min_level")) {
      // The message is missing some information.
      // Kicks the user out.
      session->Close();
      return;
    }

    // Creates a new room.
    const string room_name = message["room_name"].GetString();
    const int64_t joinable_min_level = message["joinable_min_level"].GetInteger();
    room = Room::Create(room_name, joinable_min_level, user_name);
    BOOST_ASSERT(room);
  } else {
    // "room_id" has been given. So, the room must exist.
    string room_id_str = message["room_id"].GetString();

    // "room_id" cannot be empty.
    BOOST_ASSERT(not room_id_str.empty());

    Uuid room_id = boost::lexical_cast<Uuid>(room_id_str);
    room = Room::Find(room_id);

    // The room must exist.
    BOOST_ASSERT(room);
  }

  // We have either created a room or found one.
  // Makes the user enter the room.
  // Please note that we are serializing events using the room's id.
  Event::Invoke([room, session, user_name, character_id, user_level]() {
        room->HandleJoin(session, user_name, character_id, user_level);
      }, room->id());
}


void OnClientLeaveRoom(const Ptr<Session> &session, const Json &message) {
  // Checks if the user is on the game server.
  string user_name = AccountManager::FindLocalAccount(session);
  if (user_name.empty()) {
    // Ignores if the player is not a local account.
    SendMessage(session, "client_leave_room", false);
    return;
  }

  // Extracts the room id from the session context.
  string room_id_str;
  if (not session->GetFromContext("room_id", &room_id_str)) {
    // The user is not in any room. Maybe malicious user.
    // Closes the session.
    session->Close();
    return;
  }

  Uuid room_id = boost::lexical_cast<Uuid>(room_id_str);
  Ptr<Room> room = Room::Find(room_id);

  // The room must exist.
  BOOST_ASSERT(room);

  // Has the user out of the room.
  // Please note that we are serializing events using the room's id.
  Event::Invoke([room, session]() {
        room->HandleLeave(session);
      }, room_id);
}


void OnClientChat(const Ptr<Session> &session, const Json &message) {
  // Checks if the user is on the game server.
  string user_name = AccountManager::FindLocalAccount(session);
  if (user_name.empty()) {
    // Ignores if the player is not a local account.
    SendMessage(session, "client_chat_room", false);
    return;
  }

  // Checks if the message is valid.
  if (not message.HasAttribute("chat_msg")) {
    session->Close();
    return;
  }

  string chat_msg = message["chat_msg"].GetString();
  if (chat_msg.empty()) {
    // Kicks the user out.
    session->Close();
    return;
  }

  // Extracts the room id from the session context.
  string room_id_str;
  if (not session->GetFromContext("room_id", &room_id_str)) {
    // The user is not in any room. Maybe malicious user.
    // Closes the session.
    session->Close();
    return;
  }

  Uuid room_id = boost::lexical_cast<Uuid>(room_id_str);
  Ptr<Room> room = Room::Find(room_id);

  // The room must exist.
  BOOST_ASSERT(room);

  // Broadcast the chat message to the users in the room.
  // Please note that we are serializing events using the room's id.
  Event::Invoke([room, session, chat_msg]() {
        room->HandleChat(session, chat_msg);
      }, room_id);
}


// Starts a match. Only room leader can start.
void OnClientStartMatch(const Ptr<Session> &session, const Json &message) {
  // Checks if the user is on the game server.
  string user_name = AccountManager::FindLocalAccount(session);
  if (user_name.empty()) {
    // Ignores if the player is not a local account.
    SendMessage(session, "client_start_match", false);
    return;
  }

  // Extracts the room id from the session context.
  string room_id_str;
  if (not session->GetFromContext("room_id", &room_id_str)) {
    // The user is not in any room. Maybe malicious user.
    // Closes the session.
    session->Close();
    return;
  }

  Uuid room_id = boost::lexical_cast<Uuid>(room_id_str);
  Ptr<Room> room = Room::Find(room_id);

  // The room must exist.
  BOOST_ASSERT(room);

  // Makes the room start.
  // Please note that we are serializing events using the room's id.
  Event::Invoke([room, session, user_name]() {
        // HandleStartMatch() will inspect if the user is the leader.
        room->HandleStartMatch(session, user_name);
      }, room_id);
}


// Sends a list of active rooms to the user.
void OnClientGetRoomList(const Ptr<Session> &session, const Json &message) {
  // Checks if the user is on the game server.
  string user_name = AccountManager::FindLocalAccount(session);
  if (user_name.empty()) {
    // Ignores if the player is not a local account.
    SendMessage(session, "client_get_room_list", false);
    return;
  }

  // Reads the whole rooms and encode into a message.
  // Then, sends it to the user
  Json rooms = Room::GetRooms();
  session->SendMessage("client_get_room_list", rooms);
}


// We will register client messages and events.
void RegisterEventHandlers() {
  // We makes the user leave a room once OnSessionClosed() is invoked.
  HandlerRegistry::Install2(OnSessionOpened, OnSessionClosed);

  // Client messages.
  HandlerRegistry::Register("client_login", OnClientLogin);
  HandlerRegistry::Register("client_logout", OnClientLogout);
  HandlerRegistry::Register("client_join_room", OnClientJoinRoom);
  HandlerRegistry::Register("client_leave_room", OnClientLeaveRoom);
  HandlerRegistry::Register("client_chat", OnClientChat);
  HandlerRegistry::Register("client_start_match", OnClientStartMatch);
  HandlerRegistry::Register("client_get_room_list", OnClientGetRoomList);

  // This handler will be invoked once the TCP connection is lost.
  // This is different from closing a session, as session can continue
  // with a new TCP connection. Session closes if it remains idle for some time.
  HandlerRegistry::RegisterTcpTransportDetachedHandler(OnTcpTransportDetached);
}

Please note that we have passed C++ 11 lambda to Event::Invoke() in OnClient...() message handlers. Thus, all the events related to a specific room is passed via Event::Invoke(). And we have serialized like this:

  • Synchronization unit: events with the same room id will be serialized according to when Event::Invoke has been called.
  • Since this guarantee per-room serialization, we do not have to introduce a lock to access the room’s member variables if all code accessing the room use Event::Invoke.
  • This is on a per-room basis. So, events with different room id s will run concurrently.
  • We used C++11 lambda as a callback. So, we need to list required variables in a Lambda capture statement (e.g., room, session) to make them accessible in the callback. (Of course, Lambda capture statement ([=]) is also OK.)

Note

If you prefer std::function or boost::function over C++11 lambda, either is also fine. The pros of C++11 lambda is that it’s easier to manage passing variables.

For details on C++11 lambda, please refer to the MSDN’s C++11 lambda page or cppreference.com’s lambda page.

Warning

In the example above, we do not handle TCP disconnection event, since a session can continue with a new TCP connection. (So, we have the user leave a room only when the session is closed.)

But some games may want to tear down or to run an AI a room session on TCP disconnection. In this case, you can specify a handler for TCP disconnection event via HandlerRegistry::RegisterTcpTransportDetachedHandler(). (OnTcpTransportDetached in this example)