Skip to content

Spot

Guide Home | Previous: Channel Messaging | Next: Actor

What you get from this chapter

You can create a stateful object addressed by id, send messages to it, and receive answers. The code in this chapter comes from the language-specific example repositories.

In Channel Messaging a call was received by one of the nodes serving the name. That path does not work when the recipient is already decided. A Spot is a stateful object found by id, and it processes the work addressed to it in a single line. This chapter goes as far as creating one Spot and calling it; the differences between the kinds and the full lifecycle are covered by How Spots Execute.

1. The Role of a Spot

Spot creates units that remember something and process the work addressed to them in order — one chat room, one matchmaking queue. A channel cannot express that: a channel call goes to one of the nodes serving that name, so two messages sent to the same room can arrive at different nodes.

▶ Try it in the interactive tour — Lobby and dungeon

A Spot has an id. Messages sent to the same id always arrive at the same Spot, and inside that Spot they run one at a time. A Spot's fields therefore need no lock.

↗ View larger

Neither the creator nor the caller picks a node. That much matches a channel; what differs is that the target is settled to exactly one.

2. The Location Store — a Prerequisite for Registering a Spot

A Spot is called by id, not by host. That requires somewhere to record which node it is on right now. That is the Location Store, and it is required once you register even one Spot.

// Rooms are addressed by id, not by host, so their current location is
// kept here. Every node reads and writes the same store under the same
// prefix.
options.add_location_store<fw::redis::redis_location_store_t> ()
  .set_connection_string ("127.0.0.1:6379")
  .set_key_prefix ("zlink-tutorial-cpp:location:");

Register a Relocation Store alongside it. Registering a Spot factory is itself the condition, so it is required even with relocation turned off as it is in this chapter.

// Registering any Spot factory requires this store, even with relocation
// turned off: the registration itself is the condition.
options.add_relocation_store<fw::redis::redis_relocation_store_t> ()
  .set_connection_string ("127.0.0.1:6379")
  .set_key_prefix ("zlink-tutorial-cpp:relocation:");

A node that only calls needs the Location Store alone — the calling side.

3. The Receiving Side — the Node That Hosts the Spot

The receiving process

The code in this section goes into the process that runs the Spot.

3.1 Writing the Spot

A Spot holds its state in fields and initializes itself from the payload it receives when it is created.

// A room owns its own state and is addressed by a global SpotId. Messages sent
// to one room run one at a time, so the fields below need no synchronization.
//
// Every C++ user Spot names the actor type it can admit. This room admits none,
// so it names the base type and rejects every join. Actors are a later chapter.
class game_room_t : public fw::spot_t<fw::actor_t>
{
  public:
    explicit game_room_t (fw::spot_context_t context) : _context (std::move (context)) {}

    fw::spot_context_t &context () noexcept override { return _context; }

    const fw::spot_context_t &context () const noexcept override { return _context; }

    // Handlers are member functions named here. A member returning nothing is a
    // send handler; one returning a value answers a request with it.
    void configure () override
    {
        _context.handlers ().add_handler<&game_room_t::post_chat> ();
        _context.handlers ().add_handler<&game_room_t::get_room_state> ();
    }

    void post_chat (const post_chat_t &message)
    {
        auto line = message.player_id + ": " + message.text;
        _chat.push_back (line);
    }

    // The return value is the reply. This handler only reads.
    room_state_t get_room_state (const get_room_state_t &) const
    {
        return room_state_t{_title, _chat};
    }

    // Runs before the room accepts any message. Rejecting here means the create
    // call fails and no room exists. Omit this method to accept every request.
    fw::task_t<fw::spot_create_response_t> on_create (const fw::message_t &request) override
    {
        const auto &body = request.decode<open_room_t> ();
        _title = body.title;
        co_return fw::spot_create_response_t::accept ();
    }

    // No actor ever joins this room, so the three membership callbacks below say
    // so and do nothing else.
    fw::task_t<fw::spot_actor_join_result_t> on_actor_join (std::string_view,
                                                            const fw::message_t &) override
    {
        co_return fw::spot_actor_join_result_t ::reject ();
    }

    fw::task_t<void> on_actor_joined (fw::actor_t &) override { co_return; }

    fw::task_t<void> on_leave_actor (fw::actor_t &) override { co_return; }

  private:
    fw::spot_context_t _context;
    std::string _title = "untitled";
    std::vector<std::string> _chat;
};

To refuse at creation, refuse in the create callback. The create call then fails and no Spot remains. Omit that callback and every create request is accepted.

3.2 Writing the Handlers

A handler receives the target Spot as its first argument. Unlike a channel handler, it works on that Spot's state directly.

// Handlers are member functions named here. A member returning nothing is a
// send handler; one returning a value answers a request with it.
void configure () override
{
    _context.handlers ().add_handler<&game_room_t::post_chat> ();
    _context.handlers ().add_handler<&game_room_t::get_room_state> ();
}

void post_chat (const post_chat_t &message)
{
    auto line = message.player_id + ": " + message.text;
    _chat.push_back (line);
}

// The return value is the reply. This handler only reads.
room_state_t get_room_state (const get_room_state_t &) const
{
    return room_state_t{_title, _chat};
}

A handler with no return value receives a message that arrived by send; a handler that returns a value makes that value the response. Within one room the two handlers run one at a time.

3.3 Registration

The mesh node picks the Object Server role once, and the Spot factory is registered on it.

// A mesh node picks this role once. Keep the builder and reuse it,
// because calling objects().server() a second time is rejected at
// startup.
auto objects = mesh.objects ().server ();

// "game-room" is the stable type a caller names when opening a room. Any
// node that registers it is a candidate to host one. Exactly one
// relocation policy is required; moving a live room to another node is a
// separate topic.
objects.add_spot_factory<game_room_t> ("game-room").disable_relocation ();

The name given at registration is the stable type. The calling side names it when opening a room, and the nodes that registered the same name become the candidates. Exactly one relocation policy is required; this chapter turns it off. To preserve state through an adapter, choose preserveStateWith at this registration — Relocation's adapter shows the code that captures and restores that state.

4. The Calling Side — the Node That Calls a Spot

The calling process

The code in this section goes into the other process, the one that creates the room and calls it. It pairs with the section above.

The calling node does not run the Spot, so it registers no factory. It picks the client role on that mesh instead, and registers the Location Store it reads locations from under the same prefix.

// Rooms are looked up by whoever calls them, so a node that hosts none
// still needs the store, pointed at the same prefix.
options.add_location_store<fw::redis::redis_location_store_t> ()
  .set_connection_string ("127.0.0.1:6379")
  .set_key_prefix ("zlink-tutorial-cpp:location:");

// client() rules out registering factories. This node creates and calls
// rooms; a node that picked server() runs them.
mesh.objects ().client ();

4.1 Creating

Create a Spot by naming the stable type: a type used consistently for the same id. The id that comes back is the address for every later call.

First, the tutorial Server defines the game route mesh. A mesh name names the set of nodes where a Spot may be placed, and in_mesh selects a node that participates in that route mesh to host the Spot. The receiving side of Channel Messaging shows how to register a route mesh.

// Both sides must name the mesh identically, or they never see each other
// as peers. The routing id names this node; without it the Framework
// assigns a generated one, which a caller cannot type into a URL.
zlink::routing_id_t routing_id = zlink::routing_id_t::from ("game-server-1");
auto mesh = options.add_route_mesh ("game")
              .listen ("tcp://127.0.0.1:7401")
              .set_routing_id (routing_id)
              // Publish the same loopback address that this node listens on.
              .set_advertise_host ("127.0.0.1");

The call that creates the Spot in that mesh follows.

// spot_manager_t creates a room; the reply carries the id every later call uses.
class open_room_http_handler_t
{
  public:
    explicit open_room_http_handler_t (fw::spot_manager_t &rooms) : _rooms (rooms) {}

    fw::task_t<fw::http_response_t> handle (const fw::http_request_t &request)
    {
        const auto body = nlohmann::json::parse (request.body).get<open_room_t> ();

        auto created = co_await _rooms
                         .create ("game-room") // Picks the factory and the candidate nodes.
                         .in_mesh ("game")
                         .creation_request (body) // Reaches the room's create callback.
                         .async ();

        // From here on the room is addressed by this id alone.
        co_return fw::http_response_t{200, nlohmann::json (created.spot.spot_id ()).dump ()};
    }

  private:
    fw::spot_manager_t &_rooms;
};

4.2 Calling

Give only the id received at creation. The Framework finds which node it is on right now.

class post_chat_http_handler_t
{
  public:
    explicit post_chat_http_handler_t (fw::route_client_t &routes) : _routes (routes) {}

    fw::task_t<fw::http_response_t> handle (const fw::http_request_t &request)
    {
        const auto room_id = request.route_values.at ("roomId");
        const auto message = nlohmann::json::parse (request.body).get<post_chat_t> ();

        // The id is enough; the Framework resolves where the room currently runs.
        co_await _routes.send_to_spot (room_id, message).async ();

        co_return fw::http_response_t{202, ""};
    }

  private:
    fw::route_client_t &_routes;
};

When you need an answer, send a request. The target may be moving, so give a timeout alongside it.

class get_room_state_http_handler_t
{
  public:
    explicit get_room_state_http_handler_t (fw::route_client_t &routes) : _routes (routes) {}

    fw::task_t<fw::http_response_t> handle (const fw::http_request_t &request)
    {
        const auto room_id = request.route_values.at ("roomId");

        auto state = co_await _routes.request_to_spot (room_id, get_room_state_t{})
                       .timeout (std::chrono::seconds (3))
                       .async<room_state_t> ();

        co_return fw::http_response_t{200, nlohmann::json (state).dump ()};
    }

  private:
    fw::route_client_t &_routes;
};

5. What You See When You Run It

With the tutorial Server and Client running as the README's Run section specifies, send these curl requests to the Client's HTTP surface: each HTTP response appears on curl stdout, and handler records appear on the Server process's stdout or in server.log.

curl -X POST http://127.0.0.1:5080/rooms \
  -H 'Content-Type: application/json' -d '{"title":"lobby"}'
# "8213420c-03ff-47a6-8410-ddfb3df26660"

curl -X POST http://127.0.0.1:5080/rooms/8213420c-03ff-47a6-8410-ddfb3df26660/chat \
  -H 'Content-Type: application/json' -d '{"playerId":"p1","text":"hello"}'
# 202

curl http://127.0.0.1:5080/rooms/8213420c-03ff-47a6-8410-ddfb3df26660
# {"title":"lobby","chat":["p1: hello"]}

The Framework makes the id. The second call does not wait for a response; the third receives the answer the room produced. The room held its state between the two calls.

6. The Other Kinds of Spot

What this chapter created is the Spot an application creates explicitly, and most rooms, stages, and zones are that one. There is also the kind the Framework creates when the Object Server starts, and the kind created when the first message arrives. They differ in when they are created and in which lifecycle callbacks they receive.

▶ Try it in the interactive tour — Instance Spot

The differences between the kinds and the full lifecycle are covered by Spot.

  • The other unit addressed by id — Actor
  • The path that calls by name — Channel Messaging
  • What the Location Store records — How Channels Work
  • A running version of this chapter's code — framework/languages/cpp/tutorial