Permalink
bf65962 Sep 21, 2018
1 contributor

Users who have contributed to this file

275 lines (220 sloc) 11.6 KB

This guide is made to help you getting started with Yojimbo, integrate it into your game and cover some of the basic usages of the library.

Yojimbo is meant to be used in a client/server architecture. The first thing you will need is a way to run your game "headless" (with no window / rendering context) for the server. Game servers usually run in VPS machines where there is no real use for a window or rendering to the screen. Let's create our main server class called GameServer and let it handle everything the server needs to do. The different parts of Yojimbo you need on the server are Server, ClientServerConfig and Adapter.

The Server is the main part, used to receive and send messages. The ClientServerConfig allows you to configure the server: connection timeout, memory used or the different channels (reliable, unreliable). The Adapter allows you to get callbacks when a client connects or disconnects and is also responsible for providing Yojimbo with the MessageFactory. Both the ClientServerConfig and the Adapter are shared between client and server. Here is what it could look like:

// a simple test message
enum class GameMessageType {
    TEST,
    COUNT
};

// two channels, one for each type that Yojimbo supports
enum class GameChannel {
    RELIABLE,
    UNRELIABLE,
    COUNT
};

// the client and server config
struct GameConnectionConfig : yojimbo::ClientServerConfig {
    GameConnectionConfig()  {
        numChannels = 2;
        channel[(int)GameChannel::RELIABLE].type = yojimbo::CHANNEL_TYPE_RELIABLE_ORDERED;
        channel[(int)GameChannel::UNRELIABLE].type = yojimbo::CHANNEL_TYPE_UNRELIABLE_UNORDERED;
    }
};

// the adapter
class GameAdapter : public yojimbo::Adapter {
public:
    explicit GameAdapter(GameServer* server = NULL) :
        m_server(server) {}

    yojimbo::MessageFactory* CreateMessageFactory(yojimbo::Allocator& allocator) override {
        return YOJIMBO_NEW(allocator, GameMessageFactory, allocator);
    }

    void OnServerClientConnected(int clientIndex) override {
        if (m_server != NULL) {
            m_server->ClientConnected(clientIndex);
        }
    }

    void OnServerClientDisconnected(int clientIndex) override {
        if (m_server != NULL) {
            m_server->ClientDisconnected(clientIndex);
        }
    }

private:
    GameServer* m_server;
};

// the message factory
YOJIMBO_MESSAGE_FACTORY_START(GameMessageFactory, (int)GameMessageType::COUNT);
YOJIMBO_DECLARE_MESSAGE_TYPE((int)GameMessageType::TEST, TestMessage);
YOJIMBO_MESSAGE_FACTORY_FINISH();

Note that the adapter will have a null GameServer pointer when used on the client. If you prefer, you can also create a different adapter for the client, but it needs to provide Yojimbo with the same MessageFactory as the server.

Let's take a look at the TestMessage class and briefly cover basic serialization:

class TestMessage : public yojimbo::Message {
public:
    int m_data;

    TestMessage() :
        m_data(0) {}

    template <typename Stream>
    bool Serialize(Stream& stream) {
        serialize_int(stream, m_data, 0, 512);
        return true;
    }

    YOJIMBO_VIRTUAL_SERIALIZE_FUNCTIONS();
};

Yojimbo uses a unified serialization system for both reading from and writing to a Stream. It might take a bit of getting used to, and for more complex messages, you will sometimes have to split the code path, but we won't get into details here. For now we just want to read/write a simple int, so we use the serialize_int helper. There are several serialization helpers provided by Yojimbo and you will probably need to define several more to cover the needs of your game.

The GameServer will then have a GameConnectionConfig, a GameAdapter and a Server and initialize everything like so:

static const uint8_t DEFAULT_PRIVATE_KEY[yojimbo::KeyBytes] = { 0 };
static const int MAX_PLAYERS = 64;

GameServer::GameServer(const yojimbo::Address& address) :
    m_adapter(this),
    m_server(yojimbo::GetDefaultAllocator(), DEFAULT_PRIVATE_KEY, address, m_connectionConfig, m_adapter, 0.0)
{
    // start the server
    m_server.Start(MAX_PLAYERS);
    if (!m_server.IsRunning()) {
        throw std::runtime_error("Could not start server at port " + std::to_string(address.GetPort()));
    }

    // print the port we got in case we used port 0
    char buffer[256];
    m_server.GetAddress().ToString(buffer, sizeof(buffer));
    std::cout << "Server address is " << buffer << std::endl;

    // ... load game ...
}

void GameServer::ClientConnected(int clientIndex) {
    std::cout << "client " << clientIndex << " connected" << std::endl;
}

void GameServer::ClientDisconnected(int clientIndex) {
    std::cout << "client " << clientIndex << " disconnected" << std::endl;
}

Note the use of a null private key. How to make secure connections to the server is a topic on its own that won't be covered here. But for development, you will be running both the server and the client on your own machine so you're fine with insecure connections.

Also note that the GameServer takes an Address. The IP part of the address must be set to the external IP address of the machine. You are free to choose the port. For development, just use "127.0.0.1" and a port of your liking. For several LAN machines connecting together, use the local IP address of the server.

The server will need a custom game loop, running at a fixed timestep. Game loops and whether to use fixed timestep or not is also a topic on its own, but for netcode, fixed timestep, for both server and client, is often a good idea. We chose to run the server at 60Hz. Here is what it looks like:

void GameServer::Run() {
    float fixedDt = 1.0f / 60.0f;
    while (m_running) {
        double currentTime = yojimbo_time();
        if (m_time <= currentTime) {
            Update(fixedDt);
            m_time += fixedDt;
        } else {
            yojimbo_sleep(m_time - currentTime);
        }
    }
}

In the Update method, there are 3 methods of the Server class that you need to call: AdvanceTime, ReceivePackets and SendPackets. Note that the order is very important. Using another order might add a couple frames of delay to your server's latency. Here is what it looks like:

void GameServer::Update(float dt) {
    // stop if server is not running
    if (!m_server.IsRunning()) {
        m_running = false;
        return;
    }

    // update server and process messages
    m_server.AdvanceTime(m_time);
    m_server.ReceivePackets();
    ProcessMessages();

    // ... process client inputs ...
    // ... update game ...
    // ... send game state to clients ...

    m_server.SendPackets();
}

The ProcessMessage function consists in looping over all the connected clients and read the messages received from each of them:

void GameServer::ProcessMessages() {
    for (int i = 0; i < MAX_PLAYERS; i++) {
        if (m_server.IsClientConnected(i)) {
            for (int j = 0; j < m_connectionConfig.numChannels; j++) {
                yojimbo::Message* message = m_server.ReceiveMessage(i, j);
                while (message != NULL) {
                    ProcessMessage(i, message);
                    m_server.ReleaseMessage(i, message);
                    message = m_server.ReceiveMessage(i, j);
                }
            }
        }
    }
}

void GameServer::ProcessMessage(int clientIndex, yojimbo::Message* message) {
    switch (message->GetType()) {
    case (int)GameMessageType::TEST:
        ProcessTestMessage(clientIndex, (TestMessage*)message);
        break;
    default:
        break;
    }
}

void GameServer::ProcessTestMessage(int clientIndex, TestMessage* message) {
    // ... process test message ...
}

Let's leave the server aside for a moment and take a look at the client. You need a Client and the same ClientServerConfig and Adapter as the server. In this example, the game is divided into "screens" and the screen handling the online game is called OnlineGameScreen. It looks like this:

OnlineGameScreen::OnlineGameScreen(const yojimbo::Address& serverAddress) :
    m_client(yojimbo::GetDefaultAllocator(), yojimbo::Address("0.0.0.0"), m_connectionConfig, m_adapter, 0.0)
{
    uint64_t clientId;
    yojimbo::random_bytes((uint8_t*)&clientId, 8);
    m_client.InsecureConnect(DEFAULT_PRIVATE_KEY, clientId, m_serverAddress);
}

Yojimbo requires each client to have a unique clientId. In a game with user accounts, this would typically be the user id. While in development or if you don't have user accounts in your game, you can just pass a random uint64_t number. Note that with a secure connection, the clientId wouldn't be set by the client, but would come inside a connection token received from the web backend, so clients cannot spoof their identity. But this is out of the scope of this guide.

The client is now connecting. You can check the state of the connection in your game loop to know when the connection is established. Note that later on, you will also want to detect disconnections and try to reconnect the client. Just like on the server, you also need to update the Client by calling the AdvanceTime, ReceivePackets and SendPackets methods.

For testing purposes, let's also send a TestMessage when the player presses a key:

void OnlineGameScreen::Update(float dt) {
    // update client
    m_client.AdvanceTime(m_client.GetTime() + dt);
    m_client.ReceivePackets();

    if (m_client.IsConnected()) {
        ProcessMessages();

        // ... do connected stuff ...

        // send a message when space is pressed
        if (KeyIsDown(Key::SPACE)) {
            TestMessage* message = (TestMessage*)m_client.CreateMessage((int)GameMessageType::TEST);
            message->m_data = 42;
            m_client.SendMessage((int)GameChannel::RELIABLE, message);
        }
    }

    m_client.SendPackets();
}

void OnlineGameScreen::ProcessMessages() {
    for (int i = 0; i < m_connectionConfig.numChannels; i++) {
        yojimbo::Message* message = m_client.ReceiveMessage(i);
        while (message != NULL) {
            ProcessMessage(message);
            m_client.ReleaseMessage(message);
            message = m_client.ReceiveMessage(i);
        }
    }
}

void OnlineGameScreen::ProcessMessage(yojimbo::Message* message) {
    switch (message->GetType()) {
    case (int)GameMessageType::TEST:
        ProcessTestMessage((TestMessage*)message);
        break;
    default:
        break;
    }
}

void OnlineGameScreen::ProcessTestMessage(TestMessage* message) {
    std::cout << "Test message received from server with data " << message->m_data << std::endl;
}

The client now sends a TestMessage when a key is pressed and will also log to the console when receiving one. Let's change the server so that when it receives a TestMessage it will log to the console and answer the same message back to the client:

void GameServer::ProcessTestMessage(int clientIndex, TestMessage* message) {
    std::cout << "Test message received from client " << clientIndex << " with data " << message->m_data << std::endl;
    TestMessage* testMessage = (TestMessage*)m_server.CreateMessage(clientIndex, (int)GameMessageType::TEST);
    testMessage->m_data = message->m_data;
    m_server.SendMessage(clientIndex, (int)GameChannel::RELIABLE, testMessage);
}

Note that we used the RELIABLE channel here. We won't get into details but you will want to use the reliable channel for important and unfrequent messages such as initialization or chat messages and the unreliable channel for messages sent every frame like the game state.

You should now be able to run the server, run the client, have it connect to the server and send and receive test messages. Hopefully this guide gave you a better idea on how to get started using Yojimbo!