Receiving Packets¶
Contents | Previous: Sending Packets | Next: Connection Lifecycle
View in another language — C++ · C#/.NET · Java · Kotlin · Node/TypeScript
After reading this chapter
You can receive packets the server sends first through a handler and release that registration when you choose. You can also wait for a single packet and read how many arrived under a name.
A packet the server sent stays in the receive queue until a handler takes it or a wait surface consumes it. This chapter covers how packets leave that queue. Answers and heartbeats do not pass through it — an answer goes straight to the request waiting for it, and a heartbeat serves the connection itself.
1. Typed Receiving and Decoding¶
The connector finds a handler by the incoming packet name, decodes its payload, and places it in a message. A message carries the packet name, payload, metadata, and Actor ID. A codec converts payload bytes into an application type. One codec is configured when the connector is created.
1.1 Receiving — Registering a Typed Handler¶
These registrations come from the tutorial. The .NET, Java, Kotlin, and C++ examples receive
NicknameChanged in the existing JSON flow. The Node example receives the generated Protobuf
class Ping. Each tutorial README provides the execution procedure.
auto single_notice = connector.on<nickname_changed_t> (
[&] (const auto &changed) { single_changed.set_value (changed); });
1.2 How the Type Reaches the Codec¶
on(Type, handler) derives the packet name from the type and decodes the payload as that type.
The named form selects packets by an explicit wire name while specifying the decoding type
separately. This is useful when the type name differs from the server's packet name.
The T in on<T>(handler) and on<T>(name, handler) is the decoding type.
Generated Protobuf messages are decoded through codec_traits<T> using ParseFromString.
Node Protobuf Codec
The patched codec decodes with the constructor supplied by on(Type, handler) and uses the factory fallback when no type is supplied.
The envelope codec also forwards the handler type. Node Protobuf Messaging
describes the difference from released 0.28.0 and the execution steps.
2. Releasing a Registration¶
Registration returns a value that can be released. This keeps a client that registers a subscription for the lifetime of one screen from having to rebuild the connection when the screen closes. A released handler does not run afterwards, and releasing the same value twice is not an error.
Whether the value's lifetime is the registration's lifetime is decided by the language. In a language that expresses ownership as a value, the registration ends when the value goes away, so the value is kept for as long as the handler must run. In the others the registration stays until it is released explicitly, even if the value is discarded.
The connection-state, error, and disconnect handlers return the same kind of value. They are covered by Connection Lifecycle.
3. When a Handler Runs¶
Under the default setting the receive path does not call handlers directly; it queues them. When the application calls the pump, the handlers queued so far run in the execution context that called it. A game loop calls it once per frame. The pump processes what has accumulated and returns; it does not wait for a new packet.
Switching to immediate execution runs handlers on the receive path with no pump. A slow handler then blocks that path and delays the receive work behind it.
The wait surfaces below observe the receive queue directly rather than running registered handlers, so they work in a configuration that never calls the pump.
4. Waiting for One Packet¶
To wait for a single packet at a point in a scenario, use a wait surface instead of registering a handler. It consumes the matching packet and returns that message; a packet that does not match stays in the queue for a later handler or wait. Without an explicit timeout, the connector's default wait timeout applies.
A packet name can be explicit or derived from the payload type.
Use wait_for<T>() or wait_for<T>(name).
The example below waits for one packet by payload type.
auto found = connector.wait_for<match_found_t> ()
.where (
[] (const auto &message) { return message.payload.match_id == "match-7f3a"; })
.timeout (std::chrono::seconds (30))
.submit ();
Predicates and returns deal in messages, not payloads. A predicate given the payload alone cannot see the packet name or the metadata. When the value to filter on sits inside the payload, read that field inside the predicate — there is no surface dedicated to a status field.
5. Packets That Must Not Arrive, and Arrival Order¶
Verifying a scenario means confirming not only that a packet arrived, but also that one did not and that several arrived in order. The first names an observation window and confirms the packet does not arrive during it. The second applies predicates in order, confirms packets of the same name arrived in that order, and returns the list of messages.
auto quiet = connector.expect_none<order_changed_t> ()
.within (std::chrono::milliseconds (100))
.submit ();
require (quiet, "quiet");
connector.send (receiving_stage_t{"orders"}).submit ();
auto steps = connector.wait_for_sequence<order_changed_t> ()
.expect ([] (const auto &m) { return m.payload.status == "paid"; })
.expect ([] (const auto &m) { return m.payload.status == "shipped"; })
.timeout (std::chrono::seconds (2))
.submit ();
A failed observation — nothing arrived in time, something arrived that should not have, the order was wrong — is reported as a validation failure. A wait that cannot continue because the connection ended is reported as no connection. The caller has to tell an observation that did not hold from an observation that lost its subject.
6. Received Counts¶
The count of packets received under a name is readable per name. Consuming one does not lower it, so the count still answers how many arrived under that name after a handler has run through the pump. The count also rises when the packet arrives, whatever the setting for when handlers run.
The reference point is the moment the connection is established. The count starts at zero then, and a reconnect is a new connection, so it starts at zero again. Unconsumed messages left from the previous connection are cleared at the same moment — otherwise a wait surface would hand back a packet from before the drop as if it belonged to the new connection.
7. The Receive Queue — No Limit Is Set¶
The connector keeps receiving and processing what arrives. There is no limit on the queue, no message is dropped, and a long queue is never a reason to close the connection. The connector does not implement a socket of its own and uses what the runtime provides, so there is no place to hold reads back either.
In a client that works correctly, messages do not accumulate: a handler processes them or a wait surface consumes them. Continued growth means the client is not calling the pump, which is why the received count is not a basis for flow control.
8. With Several Actors — Tell Them Apart by Actor Handle¶
8.1 With One Actor¶
When the server binds only one Actor to this connection, keep the send and receive code you have. A packet sent from the connector carries no Actor slot, and the server session hands a packet without a slot to the one Actor bound to that connection (Session and Actor Connection).
std::promise<sc::message_t<nickname_changed_t>> single_changed;
auto single_received = single_changed.get_future ();
{
auto single_notice = connector.on<nickname_changed_t> (
[&] (const auto &changed) { single_changed.set_value (changed); });
connector.send (change_nickname_t{"speedy"}).submit ();
if (single_received.wait_for (options.wait_timeout) != std::future_status::ready)
throw std::runtime_error ("NicknameChanged was not received for p1");
const auto pushed = single_received.get ();
std::cout << "pushed: " << pushed.payload.nickname
<< ", actor: " << pushed.actor_id.value_or ("none") << std::endl;
}
8.2 Get Handles from the Bound Notice¶
When the server binds several Actors to one connection, the connector creates an Actor handle for
each. When the server binds an Actor, the bound notice arrives before that Actor's first packet, and
when the binding ends, the unbound notice arrives after its last packet. The example below registers
both notices and finds the handles of the authenticated p1 and p2 by Actor ID.
auto bound_notice = connector.on_actor_bound ([] (const auto &actor) {
std::cout << "actor bound: " << actor->actor_id () << std::endl;
});
auto unbound_notice = connector.on_actor_unbound ([] (const auto &actor) {
std::cout << "actor unbound: " << actor->actor_id () << std::endl;
});
auto player = connector.actor (authenticated.player_id);
auto player2 = connector.actor (authenticated2.player_id);
if (!player || !player2)
throw std::runtime_error ("Player Actor was not bound");
std::cout << "actor handle: " << player->actor_id () << std::endl;
std::cout << "actor handle: " << player2->actor_id () << std::endl;
8.3 Send Through a Handle and Receive per Handle¶
A packet sent through a handle carries that Actor's slot, and the server session hands the packet to that slot's Actor. A receive handler registered on a handle receives only the messages that Actor sent.
std::promise<sc::message_t<nickname_changed_t>> changed1;
std::promise<sc::message_t<nickname_changed_t>> changed2;
auto received1 = changed1.get_future ();
auto received2 = changed2.get_future ();
auto player_notice = player->on<nickname_changed_t> (
[&] (const auto &changed) { changed1.set_value (changed); });
auto player2_notice = player2->on<nickname_changed_t> (
[&] (const auto &changed) { changed2.set_value (changed); });
player->send (change_nickname_t{"speedy-p1"}).submit ();
player2->send (change_nickname_t{"speedy-p2"}).submit ();
if (received1.wait_for (options.wait_timeout) != std::future_status::ready)
throw std::runtime_error ("NicknameChanged was not received for p1");
if (received2.wait_for (options.wait_timeout) != std::future_status::ready)
throw std::runtime_error ("NicknameChanged was not received for p2");
const auto pushed1 = received1.get ();
const auto pushed2 = received2.get ();
std::cout << "pushed: " << pushed1.payload.nickname
<< ", actor: " << pushed1.actor_id.value_or ("none") << std::endl;
std::cout << "pushed: " << pushed2.payload.nickname
<< ", actor: " << pushed2.actor_id.value_or ("none") << std::endl;
Running it prints pushed: speedy-p1, actor: p1 and pushed: speedy-p2, actor: p2.
8.4 Tell Actors Apart by Actor ID at the Connector Level¶
You can also receive at the connector level without a handle. The message's Actor ID then tells you the server-side Actor. A message that arrived without a slot has an empty Actor ID.
// Connector-level callbacks can distinguish the same pushes by ActorId.
auto actor_id_notice = connector.on<nickname_changed_t> ([] (const auto &message) {
std::cout << "received actor id: " << message.actor_id.value_or ("none") << std::endl;
});
8.5 A Packet Sent to an Unbound Actor¶
An unbound handle is closed. Sending through a closed handle doesn't send and ends with
ValidationFailed in the connector. If a packet sent just before the unbind reaches the server late,
the server doesn't hand it to another Actor — a request ends with an InvalidOperation error reply,
and a one-way send is dropped.
9. Tutorial Execution Result¶
The unsubscribe, pump, predicate wait, sequence, and count examples run in each language's StreamClient --receiving mode.
STREAM_RECEIVING_ENDPOINT is the address of a server sending JSON packets. Node uses WebSocket; the other examples use TCP.
The verification peer sends LeaderboardUpdate twice, followed by MatchFound and ordered OrderChanged messages.
All five language programs produced the same result.
The pump runs the handler once. After unsubscribe, the second packet still increases the received count without invoking the handler. The program checks the matching message, the paid/shipped sequence, and the absence of OrderChanged before the sequence starts.
10. Next Chapters¶
- Connection state, reconnection, close reasons — Connection Lifecycle
- Errors raised on the receive path — Error Handling