Skip to content

STREAM

Guide Home | Previous: Actor | Next: Session and Actor

What you get from this chapter

You can let a program outside the mesh attach over one connection, send requests 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.

A client that reaches a STREAM node uses its language's stream connector. Client stream connector covers installation for Unity, Unreal, browser, Node, .NET, Java, and C++ clients, and the stream connector guide for that language covers connection and packet handling.

Every call so far ran between nodes inside the mesh. A game client or an app is outside it and references no Framework at all. STREAM is where such a program attaches, and while the one connection is open both sides send to each other. This chapter goes as far as accepting a connection and answering one packet; binding that connection to an Actor is covered by Session and Actor.

1. The Role of STREAM

STREAM is the boundary where a client outside the mesh exchanges packets with it over one connection. Channels, Spots and Actors are all paths called from inside the mesh. The caller has to be a member of the mesh and to reference the Framework. A client running on a player's device meets neither condition.

STREAM narrows that boundary to a single connection. The client needs one address and the packet names, and it never learns how many nodes the mesh has.

↗ View larger

One session object stands for one connection. Packets arriving on that connection are handled one at a time in that session, so a session's fields are the place to keep the state of that connection.

2. Deciding the Packets

These are no different from the contracts of a mesh call. They travel to a client outside the mesh, though, so the names have to be the ones that client uses.

// Exchanged over the external TCP connection, not between mesh nodes. The
// stream codec wants 64-bit integers as decimal strings, so the timestamp is
// carried as text rather than as a long.
public record Ping(String sentAtUnixMs) {}

public record Pong(String sentAtUnixMs) {}

3. The Receiving Side — the Node That Accepts Connections

The receiving process

The code in this section goes into the process that accepts the connection.

3.1 Writing the Session

A session stands for one connection. Connect, disconnect, error and packet arrival all come as callbacks, and the callbacks for one connection run in order.

// One connected game client. Callbacks for the same connection run in order.
// The dispatcher is handed in by the Framework and holds the handlers the
// stream node registered.
public final class GameSession implements ZLinkSession {

    private final ZLinkSessionContext context;
    private final ZLinkSessionPacketDispatcher<ZLinkSessionContext> handlers;

    public GameSession(
            ZLinkSessionContext context,
            ZLinkSessionPacketDispatcher<ZLinkSessionContext> handlers) {
        this.context = context;
        this.handlers = handlers;
    }

    @Override
    public ZLinkSessionContext context() {
        return context;
    }

    @Override
    public CompletionStage<Void> onConnected() {
        System.out.println("client connected: " + context.sessionId());
        return CompletableFuture.completedFuture(null);
    }

    @Override
    public CompletionStage<Void> onDisconnected() {
        System.out.println("client disconnected: " + context.sessionId());
        return CompletableFuture.completedFuture(null);
    }

    @Override
    public CompletionStage<Void> onError(ZLinkStreamError error) {
        System.out.println("stream error on " + context.sessionId() + ": " + error);
        return CompletableFuture.completedFuture(null);
    }

    // Every inbound packet arrives here first. Registering a handler is not
    // enough on its own; this method is what routes the packet to it.
    @Override
    public CompletionStage<Void> onDispatch(
            ZLinkSessionDispatchContext dispatch, ZLinkMessage payload) {
        return handlers.tryHandle(context, dispatch, payload)
                .thenCompose(
                        handled -> {
                            if (handled) {
                                return CompletableFuture.completedFuture(null);
                            }

                            ZLinkSessionActor player = dispatch.actor();
                            if (player == null) {
                                var bound = context.actors().bound();
                                player =
                                        switch (bound.size()) {
                                            case 1 -> bound.getFirst();
                                            case 0 ->
                                                    throw new IllegalStateException(
                                                            "Authenticate an Actor before sending player packets.");
                                            default ->
                                                    throw new IllegalStateException(
                                                            "Select an Actor handle when more than one Actor is bound.");
                                        };
                            }
                            return player.relay(dispatch, payload);
                        });
    }
}

The packet-arrival callback is the gate. Registering handlers is not enough on its own; nothing is handled unless this callback routes the packet to them.

Where handlers are registered differs by language

.NET and Node register from the session; Java and Kotlin register alongside the stream node. C++ has no registration surface at all — every packet arrives at one callback, and telling them apart by name is the application's own code.

3.2 Writing a Packet Handler

Instead of returning a value, a handler writes the answer itself. A packet that arrived as a request is answered with reply; pushing to a client that is waiting for none uses send.

// The first type argument is the session context, not the session class.
// messageType() is what pairs the handler with the packet the client sends.
public final class PingHandler
        implements ZLinkTypedSessionPacketHandler<ZLinkSessionContext, Contracts.Ping> {

    @Override
    public Class<Contracts.Ping> messageType() {
        return Contracts.Ping.class;
    }

    @Override
    public CompletionStage<Void> handle(
            ZLinkSessionContext context,
            ZLinkSessionDispatchContext dispatch,
            Contracts.Ping message) {
        // reply answers a request. To push to a client that is not waiting for
        // one, use client().send instead.
        return context.client().reply(new Contracts.Pong(message.sentAtUnixMs())).submit();
    }
}

3.3 Registration

A stream node opens a port and takes one session type. It is a registration of its own, separate from a mesh node.

// The port game clients connect to. One session type per stream node,
// and actor dispatch must be on for a session to relay to its player.
// Session handlers are registered here rather than inside the session.
options.addStreamNode("client-stream")
        .bind("tcp://127.0.0.1:7521")
        .enableActorDispatch()
        .registerSession(GameSession.class)
        .addSessionPacketHandler(PingHandler.class)
        .addSessionPacketHandler(AuthenticateHandler.class);

4. The Attaching Side — a Client Outside the Mesh

The attaching process

The code in this section goes into the process that attaches from outside the mesh. It pairs with the section above.

This process references no Framework. It references one separately published connector and nothing else.

// A game client outside the mesh. It references the connector only, never
// the Framework, and speaks to the port the stream node opened.
ZLinkStreamConnector connector =
        ZLinkStreamConnectorFactory.create(
                new ZLinkStreamConnectorOptions(
                        URI.create("tcp://127.0.0.1:7521"),
                        ZLinkStreamDispatchMode.IMMEDIATE,
                        Duration.ofSeconds(5),
                        1));

connector.connect().submit().toCompletableFuture().join();
System.out.println("connected: " + connector.isConnected());

// A request waits for its reply. Use send for one-way traffic; the server
// then answers with client().send rather than reply.
long sentAt = System.currentTimeMillis();
Contracts.Pong pong =
        connector
                .request(new Contracts.Ping(Long.toString(sentAt)))
                .timeout(Duration.ofSeconds(5))
                .submit(Contracts.Pong.class)
                .toCompletableFuture()
                .join();

System.out.println(
        "round trip: "
                + (System.currentTimeMillis() - Long.parseLong(pong.sentAtUnixMs()))
                + "ms");

The Node connector attaches over a WebSocket

The connector in the other four languages speaks TCP and names a tcp:// endpoint. The Node connector is published for the browser and speaks WebSocket, so both the stream node and the client name a ws:// endpoint.

5. What You See When You Run It

With the tutorial Server running as the README's Run section specifies, start StreamClient with the command below: the request it sends and response it receives appear on the StreamClient process's stdout, while connection and handler records appear on the Server process's stdout or in server.log.

dotnet run --project StreamClient/StreamClient.csproj
# connected: True
# round trip: 56ms

connected turning true means the connection was established, and the round trip is measured by the server echoing back the instant the client sent. Those two lines are the outside and the inside joined.

6. The Difference from a Mesh Call — the Connection Is the Target

In a mesh call the caller is a member of the mesh and the target is a name or an id. In STREAM the connection itself is the target. Nothing chooses among clients, and the server can send first.

When the connection drops that session ends. To carry state beyond one connection, bind the connection to an Actor — covered by Session and Actor.