Skip to content

Actor

Guide Home | Previous: Spot | Next: STREAM

What you get from this chapter

You can create one entity by id, send messages to it, and receive answers. The code comes from the tutorial README in the examples repository; follow its Download, Build, and Run sections to reproduce the results below.

Spot covered a place several parties share, such as a room or a queue. An Actor holds per-entity state instead — one player, one session. Both are called by id and process one thing at a time; what differs is that an Actor is always inside some Spot. This chapter goes as far as creating one Actor and calling it; moving between rooms and membership are covered by Actor Membership.

1. An Actor Always Belongs to a Spot

An Actor does not float on its own. Right after creation it belongs to an Entry Spot, and entering a room moves it to that User Spot. The Entry Spot is the Spot the Framework creates when the Object Server starts, and it is the default place for an Actor that belongs to no room yet.

↗ View larger

That is why a handler receives both the Spot and the Actor. A message sent to an actor id runs inside the Spot that Actor belongs to now, so that Spot's state and the Actor's state are handled in one place.

An Actor uses the same Location Store as a Spot. The registration code is the one in Spot.

2. The Receiving Side — the Node That Hosts the Actor

The receiving process

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

2.1 Writing the Actor

An Actor holds its state in fields and reads its own id from the context.

// A player is addressed by its own id and carries state that outlives any one
// connection. Like a room, its messages run one at a time.
struct player_t : fw::actor_t
{
    std::string nickname = "anonymous";

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

    void bind_context (fw::actor_context_t value)
    {
        _context = std::make_unique<fw::actor_context_t> (std::move (value));
    }

    void rename (std::string value) { nickname = std::move (value); }

  private:
    std::unique_ptr<fw::actor_context_t> _context;
};

An Actor is not made by a constructor. The Framework makes it through a factory, so any dependency it needs is taken there.

// The Framework creates players through this factory rather than by calling a
// constructor, so dependencies can be injected here.
class player_factory_t : public fw::actor_factory_t<player_t>
{
  public:
    fw::task_t<std::shared_ptr<player_t>> create (fw::actor_context_t context,
                                                  std::stop_token) override
    {
        auto player = std::make_shared<player_t> ();
        player->bind_context (std::move (context));
        co_return player;
    }
};

2.2 Writing the Entry Spot

This is the first place an Actor lands. Unlike a room, an application does not create it; one is registered per Object Server.

// Every new player lands here before joining a room, and returns here after
// leaving one. A node that hosts players registers exactly one of these.
class lobby_spot_t : public fw::entry_spot_t<player_t>
{
  public:
    explicit lobby_spot_t (fw::entry_spot_context_t context) : _context (std::move (context)) {}

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

    // A message addressed to a player runs inside the Spot the player currently
    // occupies, so a handler receives both the Spot and the player.
    void configure () override
    {
        _context.handlers ().add_actor_send<&lobby_spot_t::change_nickname> ();
        _context.handlers ().add_actor_request<&lobby_spot_t::get_player> ();
    }

    fw::task_t<void>
    change_nickname (player_t &player, fw::message_context_t &, const change_nickname_t &message)
    {
        player.rename (message.nickname);

        // Pushes over the connection bound to this player. The same handler also runs
        // on an HTTP path with no bound connection, where push ends with InvalidOperation.
        // Rename is already complete, so only that failure is discarded.
        try {
            co_await player.context ()
              .bound_session ()
              .send (nickname_changed_t{player.nickname})
              .async ();
        }
        catch (const fw::framework_exception_t &error) {
            if (error.kind () != fw::framework_error_kind_t::invalid_operation)
                throw;
        }
    }

    player_info_t get_player (const player_t &player, fw::message_context_t &, const get_player_t &)
    {
        return player_info_t{std::string (player.context ().actor_id ().value ()), player.nickname};
    }

    // Runs when a brand-new player is created. Rejecting here means the create
    // call fails and no player exists.
    fw::task_t<fw::actor_create_response_t> on_create_actor (player_t &,
                                                             const fw::message_t &) override
    {
        co_return fw::actor_create_response_t::accept ();
    }

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

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

  private:
    fw::entry_spot_context_t _context;
};

2.3 Writing the Handlers

A handler receives both the Spot and the Actor as its first two arguments. A handler that returns no value receives a message that arrived by send.

fw::task_t<void>
change_nickname (player_t &player, fw::message_context_t &, const change_nickname_t &message)
{
    player.rename (message.nickname);

    // Pushes over the connection bound to this player. The same handler also runs
    // on an HTTP path with no bound connection, where push ends with InvalidOperation.
    // Rename is already complete, so only that failure is discarded.
    try {
        co_await player.context ()
          .bound_session ()
          .send (nickname_changed_t{player.nickname})
          .async ();
    }
    catch (const fw::framework_exception_t &error) {
        if (error.kind () != fw::framework_error_kind_t::invalid_operation)
            throw;
    }
}

For a handler that returns a value, that value becomes the response.

player_info_t get_player (const player_t &player, fw::message_context_t &, const get_player_t &)
{
    return player_info_t{std::string (player.context ().actor_id ().value ()), player.nickname};
}

2.4 Registration

Register the Entry Spot and the Actor factory on the same Object Server. The nodes that registered the actor type become the creation candidates.

// One lobby per object server. Newly created players start there.
objects.add_entry_spot<lobby_spot_t> ();

// Nodes that register "player" are candidates to host one.
objects.add_actor_factory<player_t, player_factory_t> ("player").disable_relocation ();

3. The Calling Side — the Node That Calls an Actor

The calling process

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

Registration is the same as for calling a Spot — the code in the node that calls a Spot is used as it stands.

3.1 Creating

Unlike a Spot, the calling side decides the id, because a value that already exists — a player id — is used as it stands. Call again with the same id and nothing is created; the existing one comes back.

The tutorial Server first defines the game route mesh. A mesh name names the set of nodes that can host an Actor, and in_mesh selects the mesh in which a host node is chosen when creating one. The receiving side of Channel Messaging shows the registration code.

// 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 Actor in that mesh follows.

// get_or_create returns the existing player if there is one. The caller does not
// choose which node hosts it.
class create_player_http_handler_t
{
  public:
    explicit create_player_http_handler_t (fw::actor_manager_t &players) : _players (players) {}

    fw::task_t<fw::http_response_t> handle (const fw::http_request_t &request)
    {
        const auto player_id = request.route_values.at ("playerId");
        const auto body = nlohmann::json::parse (request.body).get<create_player_t> ();

        auto created = co_await _players.get_or_create (fw::actor_id_t (player_id), "player")
                         .in_mesh ("game")
                         .creation_request (body)
                         .timeout (std::chrono::seconds (10))
                         .async ();

        const auto state = std::holds_alternative<fw::actor_create_existing_t> (created)
                             ? "existing"
                           : std::holds_alternative<fw::actor_create_created_t> (created)
                             ? "created"
                             : "rejected";
        co_return fw::http_response_t{200, nlohmann::json (state).dump ()};
    }

  private:
    fw::actor_manager_t &_players;
};

The result tells the two apart: created when it was made for the first time, existing when one was found.

3.2 Calling

Give only the actor id. The Framework finds which Spot that Actor is in now.

class change_nickname_http_handler_t
{
  public:
    explicit change_nickname_http_handler_t (fw::actor_client_t &players) : _players (players) {}

    fw::task_t<fw::http_response_t> handle (const fw::http_request_t &request)
    {
        const auto player_id = request.route_values.at ("playerId");
        const auto body = nlohmann::json::parse (request.body).get<change_nickname_t> ();

        // Addressed by player id, like a room is by room id.
        co_await _players.send (fw::actor_id_t (player_id), body).async ();

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

  private:
    fw::actor_client_t &_players;
};

When you need an answer, send a request.

class get_player_http_handler_t
{
  public:
    explicit get_player_http_handler_t (fw::actor_client_t &players) : _players (players) {}

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

        auto info = co_await _players.request (fw::actor_id_t (player_id), get_player_t{})
                      .timeout (std::chrono::seconds (3))
                      .async<player_info_t> ();

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

  private:
    fw::actor_client_t &_players;
};

3.3 Generation in a Reference

The generation in an ActorRef or SpotRef counts which instance of the same id this is. The Location Store issues it when the object is created; it does not change on join, relocation, or RecreateOnRelocation. Destroying and creating the same id again receives a new generation.

Ordinary messages do not look at generation: they go to the object that exists now. Lifecycle calls such as close, delete, and move through a reference take effect only while that reference's generation equals the current generation. That is why a stale reference cannot touch the new instance under the same id.

4. 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/players/p7 \
  -H 'Content-Type: application/json' -d '{"nickname":"rookie"}'
# "created"

curl -X POST http://127.0.0.1:5080/players/p7 \
  -H 'Content-Type: application/json' -d '{"nickname":"rookie"}'
# "existing"

curl -X POST http://127.0.0.1:5080/players/p7/nickname \
  -H 'Content-Type: application/json' -d '{"nickname":"veteran"}'
# 202

curl http://127.0.0.1:5080/players/p7
# {"playerId":"p7","nickname":"veteran"}

The point is that the second call answered existing. Calling again with the same id creates nothing. The third call changed the name, and the fourth read the changed value.

5. The Difference from a Spot

Both are called by id and process one thing at a time. To choose between them, look at who decides the id and what it holds. A Spot's id is issued by the Framework at creation, and it holds a place several parties share, such as a room or a queue. An Actor's id is decided by the calling side, and it holds the state of one entity, such as a player or a session.

Moving between rooms is covered by Actor Membership, the membership callbacks per kind by Activation and Lifetime, and preserving state during a move by Relocation.