ZLink Framework¶
Existing frameworks designed for HTTP request-response don't handle TCP-based real-time
messaging. ZLink Framework provides a messaging layer that meets that need, fully
integrated on top of ASP.NET Core, Spring Boot, NestJS, and a C++ host — the way Spring
MVC sits on top of Spring. There's no need to move to a separate runtime.
This need is most apparent in real-time games, but it isn't limited to them. For any system that distributes in-memory state such as rooms, sessions, or players across multiple servers and must deliver it to clients in real time, this one layer absorbs the complexity that an existing web service otherwise takes on when it adds real-time features.
The Purpose of ZLink Framework¶
Why there's never been a standard real-time messaging framework until now
Game servers make this problem clearest. The web shares a single shape — "respond when
a request comes in" — which let standard frameworks like Spring and ASP.NET Core
take hold.
Game servers are different. The genre itself decides the topology: a board game's room-based matching, a MORPG's room/stage split from the lobby, an MMORPG's zone mesh and mass broadcast. With no shape to converge on, every team has redesigned its own topology starting from the socket layer.
A second hard problem layers on top of this — state management. The web delegates state to a DB and scales out statelessly, but games keep room and participant state in memory and process it across multiple threads to protect response speed. From that point on, locks, contention, and deadlocks reach into the middle of business logic.
The connection itself is also something to manage. Users hold long-lived connections, so the server takes on returning a reconnecting user to the room they were in, and protecting in-progress state when a node comes down for a deployment or scale-in.
So for a long time the choice narrowed to two — build all of this yourself from scratch, or move to a separate runtime, a game server engine, and relearn everything from how you write code to how you deploy and operate it.
The industry has actually used four major configurations, and in ZLink all four combine on top of one declarative model. How the four map to each other is covered in Overview chapter 2.
In Code¶
This code runs inside a dungeon room: when a boss is defeated, it applies part of the reward to the player's guild as well. The first handler runs on the player side — it applies the kill reward to the player, then sends a request to the guild. The second handles that request in the guild Instance Spot, applying it without synchronization.
This makes two things clear. There's no lock — both handlers already run serially inside their own spots. And the async call reads like synchronous code — the player-side request to the guild is just the next line, with no callback or futures composition.
using Zlink.Framework.Contracts.Spots;
// Inside the dungeon room -- the handler that processes a boss kill.
public sealed class DefeatBossHandler(IZLinkSpotClient spots)
: IZLinkSpotRequestHandler<PlayerSpot, DefeatBossRequest, DefeatBossResult>
{
public async ValueTask<DefeatBossResult> HandleAsync(
PlayerSpot player,
DefeatBossRequest request,
CancellationToken ct)
{
// No lock -- serial inside this player's spot.
player.Exp += request.RewardExp;
var benefit = new GuildBenefitRequest(request.RewardExp / 10);
// The async call reads just like the next line too.
var reply = await spots.RequestToSpot(player.GuildId, benefit)
.InstanceSpot("guild-workflow")
.InMesh("guild")
.Async<GuildBenefitResult>(ct);
return new DefeatBossResult(reply.Ok);
}
}
using Zlink.Framework.Contracts.Spots;
// One spot, cold-activated by guild id, receives every request for this
// guild serially.
public sealed class GuildBenefitHandler
: IZLinkSpotRequestHandler<GuildSpot, GuildBenefitRequest, GuildBenefitResult>
{
public ValueTask<GuildBenefitResult> HandleAsync(
GuildSpot guild,
GuildBenefitRequest request,
CancellationToken ct)
{
// No lock -- serial inside this guild's spot.
guild.Exp += request.Exp;
return ValueTask.FromResult(new GuildBenefitResult(true));
}
}
#include <zlink/framework.hpp>
using namespace zlink::framework;
// Inside the dungeon room -- the spot that processes a boss kill.
class player_spot_t : public instance_spot_t
{
public:
player_spot_t (instance_spot_context_t context, route_client_t &routes) :
_context (std::move (context)), _routes (routes)
{
}
instance_spot_context_t &context () noexcept override { return _context; }
void configure () override
{
_context.handlers ()
.add_handler<&player_spot_t::defeat_boss> (
defeat_boss_request_t::packet_name);
}
task_t<defeat_boss_result_t> defeat_boss (const defeat_boss_request_t &request)
{
// No lock -- serial inside this player's spot.
_exp += request.reward_exp;
const guild_benefit_request_t benefit{request.reward_exp / 10};
// The async call reads just like the next line too.
auto reply = co_await _routes.request_to_spot (_guild_id, benefit)
.instance_spot ("guild-workflow")
.in_mesh ("guild")
.async<guild_benefit_result_t> ();
co_return defeat_boss_result_t{reply.ok};
}
private:
instance_spot_context_t _context;
route_client_t &_routes;
std::string _guild_id;
long _exp = 0;
};
#include <zlink/framework.hpp>
using namespace zlink::framework;
// One spot, cold-activated by guild id, receives every request for this
// guild serially.
class guild_workflow_spot_t : public instance_spot_t
{
public:
explicit guild_workflow_spot_t (instance_spot_context_t context) :
_context (std::move (context))
{
}
instance_spot_context_t &context () noexcept override { return _context; }
void configure () override
{
_context.handlers ()
.add_handler<&guild_workflow_spot_t::apply_benefit> (
guild_benefit_request_t::packet_name);
}
task_t<guild_benefit_result_t> apply_benefit (
const guild_benefit_request_t &request)
{
// No lock -- serial inside this guild's spot.
_exp += request.exp;
co_return guild_benefit_result_t{true};
}
private:
instance_spot_context_t _context;
long _exp = 0;
};
import java.util.concurrent.CompletionStage;
import systems.zlink.framework.channels.ZLinkRouteClient;
import systems.zlink.framework.spots.ZLinkSpotRequestHandler;
// Inside the dungeon room -- the handler that processes a boss kill.
public final class DefeatBossHandler implements
ZLinkSpotRequestHandler<PlayerSpot, DefeatBossRequest, DefeatBossResult> {
private final ZLinkRouteClient channels;
public DefeatBossHandler(ZLinkRouteClient channels) {
this.channels = channels;
}
@Override
public CompletionStage<DefeatBossResult> handle(
PlayerSpot player, DefeatBossRequest request) {
// No lock -- serial inside this player's spot.
player.setExp(player.getExp() + request.rewardExp());
var benefit = new GuildBenefitRequest(request.rewardExp() / 10);
// The async call chains just like the next line too.
return channels.requestToSpot(player.getGuildId(), benefit)
.instanceSpot("guild-workflow")
.inMesh("guild")
.submit(GuildBenefitResult.class)
.thenApply(reply -> new DefeatBossResult(reply.ok()));
}
}
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import systems.zlink.framework.spots.ZLinkSpotRequestHandler;
// One spot, cold-activated by guild id, receives every request for this
// guild serially.
public final class GuildBenefitHandler implements
ZLinkSpotRequestHandler<GuildSpot, GuildBenefitRequest, GuildBenefitResult> {
@Override
public CompletionStage<GuildBenefitResult> handle(
GuildSpot guild, GuildBenefitRequest request) {
// No lock -- serial inside this guild's spot.
guild.setExp(guild.getExp() + request.exp());
return CompletableFuture.completedFuture(new GuildBenefitResult(true));
}
}
import java.util.concurrent.CompletionStage
import systems.zlink.framework.channels.ZLinkRouteClient
import systems.zlink.framework.spots.ZLinkSpotRequestHandler
// Inside the dungeon room -- the handler that processes a boss kill.
class DefeatBossHandler(
private val channels: ZLinkRouteClient,
) : ZLinkSpotRequestHandler<PlayerSpot, DefeatBossRequest, DefeatBossResult> {
override fun handle(
player: PlayerSpot,
request: DefeatBossRequest,
): CompletionStage<DefeatBossResult> {
// No lock -- serial inside this player's spot.
player.exp += request.rewardExp
val benefit = GuildBenefitRequest(request.rewardExp / 10)
// The async call chains just like the next line too.
return channels.requestToSpot(player.guildId, benefit)
.instanceSpot("guild-workflow")
.inMesh("guild")
.submit(GuildBenefitResult::class.java)
.thenApply { reply -> DefeatBossResult(reply.ok) }
}
}
import java.util.concurrent.CompletableFuture
import java.util.concurrent.CompletionStage
import systems.zlink.framework.spots.ZLinkSpotRequestHandler
// One spot, cold-activated by guild id, receives every request for this
// guild serially.
class GuildBenefitHandler :
ZLinkSpotRequestHandler<GuildSpot, GuildBenefitRequest, GuildBenefitResult> {
override fun handle(
guild: GuildSpot,
request: GuildBenefitRequest,
): CompletionStage<GuildBenefitResult> {
// No lock -- serial inside this guild's spot.
guild.exp += request.exp
return CompletableFuture.completedFuture(GuildBenefitResult(true))
}
}
import { Inject } from '@nestjs/common';
import { ZLINK_SPOT_OUTBOUND, zlinkRequestHandler } from '@zlink-systems/nestjs';
import type {
ZLinkRequestHandler,
ZLinkSpotOutbound,
} from '@zlink-systems/framework';
// Inside the dungeon room -- the handler that processes a boss kill.
@zlinkRequestHandler('play', PacketNames.defeatBossRequest)
export class DefeatBossHandler
implements ZLinkRequestHandler<DefeatBossRequest, DefeatBossResult> {
constructor(
@Inject(ZLINK_SPOT_OUTBOUND)
private readonly outbound: ZLinkSpotOutbound,
) {}
async handle(request: DefeatBossRequest): Promise<DefeatBossResult> {
// The async call reads just like the next line too.
const reply = await this.outbound
.requestToSpot(request.guildId, { exp: request.rewardExp / 10 })
.instanceSpot('guild-workflow')
.inMesh('guild')
.submit<GuildBenefitResult>();
return { ok: reply.ok };
}
}
import { zlinkRequestHandler } from '@zlink-systems/nestjs';
import type { ZLinkRequestHandler } from '@zlink-systems/framework';
// One spot, cold-activated by guild id, receives every request for this
// guild serially.
@zlinkRequestHandler('guild', PacketNames.guildBenefitRequest)
export class GuildBenefitHandler
implements ZLinkRequestHandler<GuildBenefitRequest, GuildBenefitResult> {
async handle(request: GuildBenefitRequest): Promise<GuildBenefitResult> {
// No lock -- serial inside this guild's spot.
applyGuildExp(request.guildId, request.exp);
return { ok: true };
}
}
Building the same thing with a Redis distributed lock means taking and releasing two locks in a fixed order, and the code in between gets scattered across request/response callbacks. None of that is here -- how calls between Spots and an Instance Spot actually behave is covered in 06-spot.
- Zero-downtime relocation — bringing a node down doesn't drop in-progress rooms or users.
- Call by name — all you need is the channel name. No gateway, no service discovery.
- No locks — whether it's a dungeon room or a guild, one owning Spot always processes it serially.
- A language-agnostic mesh — C++,
.NET, and Java share the same contract over the same channel.
Reducing Complexity¶
Drawing the same system -- adding a real-time feature like chat or order tracking to a web API -- two different ways makes the difference obvious at a glance.
The existing approach. Because a connection is pinned to a specific instance, you need a sticky LB; real-time delivery between servers goes through a broker like Redis pub/sub; and a distributed lock keeps order so multiple instances don't modify the same order at once. Adding one real-time feature adds a set of components (orange) nearly as large as the main system.
The ZLink approach. Every orange piece disappears, leaving one location store that tells you where nodes, actors, and spots live. Server-to-server calls and real-time delivery connect directly between runtimes.
Sticky LB, WebSocket server, pub/sub relay, distributed lock, service discovery -- five pieces reduced to one location store. This doesn't replace an existing stack like Kafka or Redis -- what ZLink cuts is the complexity of connection, routing, and state management you used to assemble by hand in between, just to get real-time delivery working.
Core Concepts¶
Every remaining chapter is just a combination of these five.
| What it is | What it solves | |
|---|---|---|
| channel | A logical address for a server-to-server call. request/reply, send, pub/sub | Code never needs to know the target server's address |
| Spot | A unit that holds state and runs serially, like a room, stage, or zone | Processes requests arriving concurrently from multiple sources in order, in one place, so handler code needs no lock |
| Actor | A state object representing one connection/user. Held inside a Spot | Handles per-user message requests and manages their state |
| STREAM | A long-lived connection an external client attaches to (TCP / TLS / WS / WSS) | Socket framing and session lifetime management |
| relocation | The procedure that moves a Spot/Actor to another node | Offsets the weakness of a stateful system where state is pinned to a specific physical machine, keeping location transparency while enabling zero-downtime deployment |
Where It Applies¶
Representative domains where the patterns described above actually show up. What they share: multiple servers split roles and cooperate, and state changes are delivered to clients in real time.
| Domain | Core scenario | What it solves |
|---|---|---|
| Real-time games | Create room -> join -> update state -> client push | Serial processing of concurrent requests, zero-downtime relocation during deployment |
| Customer support chat | Open conversation -> assign agent -> relay messages -> status push | Per-conversation order guarantee, keeping the agent connection on reconnect |
| Order workflow | Take order -> process by stage -> change status -> notify | Per-order serial processing with no distributed lock |
| Delivery dispatch | Dispatch request -> assign/accept -> track status -> real-time push | Serial processing of dispatch status, real-time location push |
Installation¶
The install commands and host registration code per language are on the Installation page. The framework package pulls in the binding and the Core native runtime, so Core is never built separately.
Choosing a Language¶
The guide is fully self-contained per language. Inside the guide for the language you pick, there's only that language's code, and you read it start to finish within it. The switch line at the top of each chapter lets you view the same chapter in another language.
| Language | Server guide | Get started right away | Client-side guide |
|---|---|---|---|
.NET |
Server | Installation and first run | Stream Connector · HTTP Client |
| C++ | Server | Installation and first run | Stream Connector · HTTP Client |
| Java | Server | Installation and first run | Stream Connector · HTTP Client |
| Kotlin | Server | Installation and first run | Stream Connector · HTTP Client |
| Node.js | Server | Installation and first run | Stream Connector · HTTP Client |
The two client-side guides cover libraries deployed separately from the server framework. Stream Connector is the library a client uses to connect to a STREAM endpoint (including Unity, Godot, and browsers), and HTTP Client is what a server uses to call an external HTTP API.
Each language's guide home lays out what order to read in. Chapters 1-17 are shared across all five languages, and C++-only DI, configuration, HTTP hosting, and the execution model continue as chapters 18-21.
How This Documentation Is Built¶
Concept and behavior explanations are the same regardless of language, so they're
written once and generated into per-language guides (common/guide/server/ is the
source). Only chapters whose content itself actually differs by language -- installation,
options, the interface index -- are written directly per language. That's why the
explanation never drifts no matter which language you're reading.
Related Documents¶
| Language-neutral meaning and the public contract | Common Spec |
| The messaging engine underneath — socket patterns, transport, options | Core Guide · Core Spec |
| Using Core directly from a language — the C API binding | Bindings Guide · Bindings Spec |
| Source and issues | github.com/zlink-systems/zlink |
Core is the messaging engine this framework sits on top of. You don't need to reference it when you're only using the framework -- go down to that documentation when you need to handle socket-level behavior or transport options directly. The guide covers patterns and usage; the spec covers the C API's functions, options, and error codes.
A binding is a thin layer that uses that C API from a language (.NET, C++, Java, Node.js, Python, Go, Rust). Start here if you're using zlink from a language with no framework, or you need a socket feature the framework doesn't wrap.