Skip to content

Receiving Packets

Contents | Previous: Sending Packets | Next: Connection Lifecycle

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.

↗ Open larger

// The constructor supplies the packet name and runtime decoding type.
const byType = connector.on<messages.tutorial.Ping>(Ping, handlePing);
// An explicit wire name can differ from the generated class name.
const byNameAndType = connector.on<messages.tutorial.Ping>('Ping', handlePing, Ping);
// Without a runtime type, the codec falls back to Ping.
const byName = connector.on<messages.tutorial.Ping>('Ping', handlePing);
// The same codec decodes Pong with the handler's generated constructor.
const byPong = connector.on<messages.tutorial.Pong>(Pong, (message) => {
  if (!(message.payload instanceof Pong)) {
    rejectPong(new Error('Pong handler received a payload with the wrong generated type.'));
    return;
  }
  resolvePong(message.payload.rank);
});

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.

on(Type, handler) and on(name, handler, Type) call codec.decode(payload, Type). on(name, handler) supplies no type. Writing <T> alone supplies no runtime type information. When only a name is supplied, the decoding result depends on the configured codec.

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.

subscription.dispose();

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.

while (running) {
  await connector.dispatch();
  renderFrame();
}

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.

Pass a name or payload type to waitFor<T>(nameOrType).

The example below waits for one packet by payload type.

const found = await connector
  .waitFor<MatchFound>(MatchFound)
  .where((message) => message.payload.matchId === 'match-7f3a')
  .timeout(30_000)
  .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.

await connector.expectNone<OrderChanged>(OrderChanged).within(100).run();
await connector.send({ stage: 'orders' }).packetName('ReceivingStage').submit();
const steps = await connector
  .waitForSequence<OrderChanged>(OrderChanged)
  .expect((message) => message.payload.status === 'paid')
  .expect((message) => message.payload.status === 'shipped')
  .timeout(2_000)
  .run();

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.

const count = connector.receivedCount('LeaderboardUpdate');

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).

// With only p1 bound, a connector-level packet needs no Actor handle.
let resolveSingle!: (message: ZlinkStreamMessage<NicknameChanged>) => void;
const singlePush = new Promise<ZlinkStreamMessage<NicknameChanged>>((resolve) => {
  resolveSingle = resolve;
});
const receiveSingle = connector.on<NicknameChanged>(PacketNames.nicknameChanged, resolveSingle);
await connector.send(new ChangeNickname('speedy')).submit();
const pushedSingle = await singlePush;
console.log(`pushed: ${pushedSingle.payload.nickname}, actor: ${pushedSingle.actorId}`);
receiveSingle.dispose();

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.

const handles = new Map<string, ZlinkStreamActor>();
const boundNotice = connector.onActorBound((actor) => {
  handles.set(actor.actorId, actor);
  console.log(`actor bound: ${actor.actorId}`);
});
const unboundNotice = connector.onActorUnbound((actor) => {
  handles.delete(actor.actorId);
  console.log(`actor unbound: ${actor.actorId}`);
});
const player1 = handles.get(authenticated.playerId);
if (!player1) throw new Error('Player Actor p1 was not bound.');
const player2 = handles.get(authenticatedP2.playerId);
if (!player2) throw new Error('Player Actor p2 was not bound.');
console.log(`actor handle: ${player1.actorId}`);
console.log(`actor handle: ${player2.actorId}`);

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.

// Each callback receives only the push for its handle's Actor.
let resolveChanged1!: (message: ZlinkStreamMessage<NicknameChanged>) => void;
let resolveChanged2!: (message: ZlinkStreamMessage<NicknameChanged>) => void;
const changed1 = new Promise<ZlinkStreamMessage<NicknameChanged>>((resolve) => {
  resolveChanged1 = resolve;
});
const changed2 = new Promise<ZlinkStreamMessage<NicknameChanged>>((resolve) => {
  resolveChanged2 = resolve;
});
const receive1 = player1.on<NicknameChanged>(PacketNames.nicknameChanged, resolveChanged1);
const receive2 = player2.on<NicknameChanged>(PacketNames.nicknameChanged, resolveChanged2);
await player1.send(new ChangeNickname('speedy-p1')).submit();
await player2.send(new ChangeNickname('speedy-p2')).submit();
const pushed1 = await changed1;
const pushed2 = await changed2;
await actorIdsReceived;
console.log(`pushed: ${pushed1.payload.nickname}, actor: ${pushed1.actorId}`);
console.log(`pushed: ${pushed2.payload.nickname}, actor: ${pushed2.actorId}`);

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 see every push and can tell the Actors apart.
let resolveActorIds!: () => void;
const actorIdsReceived = new Promise<void>((resolve) => {
  resolveActorIds = resolve;
});
let actorIdsSeen = 0;
const receiveActorId = connector.on<NicknameChanged>(PacketNames.nicknameChanged, (message) => {
  console.log(`received actor id: ${message.actorId}`);
  if (++actorIdsSeen === 2) resolveActorIds();
});

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.

receiving: handler=1, frames=1, match=match-7f3a, sequence=paid,shipped, count=2

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