Unity Client SDK
The Unity SDK (cn.hetudb.clientsdk) is how a Unity game talks to a HeTu
server. It wraps the WebSocket protocol, the message pipeline (MessagePack +
zlib + crypto), and the subscription bookkeeping behind a single
HeTuClient.Instance.
This page assumes you can already start a server and have read
Concepts
— Components, Systems, and Subscriptions are
mentioned without re-explanation.
Install
Add the package via UPM (Window → Package Manager → + → Add from git URL):
https://github.com/Heerozh/HeTu.git?path=/ClientSDK/unity/cn.hetudb.clientsdkAfter import, open HeTu → Setup Wizard… and walk through the three steps it offers:
- NuGet dependencies — MessagePack, BouncyCastle.
- UPM dependencies —
UniTaskon Unity 2022.3,Awaitableis built in on Unity 6000+.
The wizard pops automatically on first import; that’s expected.
Connect, call, disconnect
The whole client is a singleton: HeTuClient.Instance. There is no separate
“client builder” object — you configure callbacks on the singleton and call
Connect.
using HeTu;
using UnityEngine;
public class NetBootstrap : MonoBehaviour
{
public long SelfID = 1;
async void Start()
{
// Hook BEFORE Connect so the handshake-complete event isn't missed.
HeTuClient.Instance.OnConnected += () =>
{
// Forget() = fire-and-forget; queued and sent in order.
HeTuClient.Instance.CallSystem("login", SelfID).Forget();
};
// Connect() returns only when the connection ends.
// Use a while loop for auto-reconnect on transient disconnects.
while (true)
{
var err = await HeTuClient.Instance.Connect("ws://127.0.0.1:2466/hetu/MyGame");
if (err is null || err == "Canceled") break; // normal close or app exit
Debug.LogError($"Reconnecting after error: {err}");
await Awaitable.WaitForSecondsAsync(1f); // UniTask.Delay on 2022.3
}
}
void OnDestroy() => HeTuClient.Instance.Close();
}Key points the source enforces but isn’t always obvious from a snippet:
- URL format —
ws://<host>:<port>/hetu/<instance>(orwss://...when the server / reverse-proxy terminates TLS). The/hetu/prefix is required, and<instance>must match one of theINSTANCES:entries in your server’sconfig.yml(or the--instanceflag if it was started from CLI). An unknown instance is rejected after the handshake — by design, so port scanners can’t enumerate valid names. Connectis async method but long-blocked. It awaits until the socket closes, returningnullon a clean close,"Canceled"on app-exit /Close(), or an error string otherwise. Don’tawaitit on the same path that needs to start sending RPCs — kick offCallSystemfromOnConnected(or a separate task), not from below theawait Connect.Connect(url, authKey)is the same call but signs the handshake with a pre-shared key; use this if your server runs with--authkey.- One
Close()perConnect().Close()cancels in-flightCallSystem/Get/Rangecalls and tears the socket down — call it fromOnDestroyso quitting Play Mode doesn’t leak a worker task.
Calling Systems
CallSystem(name, args...) invokes a server-side System by name. You have
two ways to use it:
// Fire-and-forget: returns immediately, queued, sent in order.
HeTuClient.Instance.CallSystem("move_to", x, z).Forget();
// Await: waits for the server's reply (default "ok", or whatever
// ResponseToClient(...) returned from the System).
var resp = await HeTuClient.Instance.CallSystem("buy", itemId);
Debug.Log(resp.To<string>());Use .Forget() (or _ = CallSystem(...)) for fast input streams like per-
frame movement; await for actions whose result you actually need.
Local pre-callbacks. You can register a client-side hook that runs every
time you call a System with that name — useful for client-side prediction:
HeTuClient.Instance.SystemLocalCallbacks["move_to"] = args =>
{
// optimistic local update before the server round-trip
transform.position = new Vector3((float)args[0], 0, (float)args[1]);
};Subscriptions: Get vs Range
Both subscriptions are live: the server pushes deltas as the underlying rows change in Redis.
| API | Returns | Use it when |
|---|---|---|
Get<T>(index, value) | RowSubscription<T> (one row, or null if no row matched) | You want exactly one row by a unique key — your own HP, your own inventory record. |
Range<T>(index, left, right, limit, desc, force) | IndexSubscription<T> (a dictionary of rows, kept in sync) | You want a window over an indexed column — nearby players, top-N leaderboard, the last 100 chat messages. |
The T parameter is your strongly-typed Component class (see next section).
Drop it for DictComponent, a string-keyed Dictionary you index manually.
Range’s force=true (default) keeps the subscription alive even if the
initial query returns zero rows, so newly-inserted rows still trigger
OnInsert / ObserveAdd. Set force=false if you not want subscript an empty query.
Typed components vs DictComponent
The server can generate matching C# classes from your Component definitions
via hetu build. The result implements IBaseComponent:
public class Position : IBaseComponent
{
public long ID { get; set; } // ID is mandatory; matches `id` on the server
public long owner;
public float x;
public float y;
}With a typed T, you read fields directly: sub.Data.x. Without it, you
get a DictComponent (a Dictionary<string, object>), and you read with
Convert.ToSingle(sub.Data["x"]) — flexible but verbose, and you lose
compile-time field checks.
Two ways to react to data changes
The same subscription object exposes both an event-based API and an R3 reactive API. Pick whichever fits the call site — they coexist, they’re backed by the same internal state, and you can mix them in the same codebase.
Pattern A — event callbacks
Plain C# events. No extra dependencies.
async void SubscribeOthers()
{
var players = await HeTuClient.Instance.Range<Position>(
"owner", 1, 999, 100);
players.AddTo(gameObject); // dispose when this GameObject is destroyed
// Initial rows are already populated:
foreach (var p in players.Rows.Values)
AddPlayer(p);
// Server-side INSERT into the index range
players.OnInsert += (sender, rowID) =>
AddPlayer(sender.Rows[rowID]);
// Server-side UPDATE on a row already in the range
players.OnUpdate += (sender, rowID) =>
{
var p = sender.Rows[rowID];
MovePlayer(p.owner, new Vector3(p.x, 0.5f, p.y));
};
// Server-side DELETE, or row leaving the range
players.OnDelete += (sender, rowID) =>
RemovePlayer(sender.Rows[rowID].owner);
}For a single-row RowSubscription<T> the events are simpler:
OnUpdate(sender) and OnDelete(sender).
Pattern B — R3 reactive streams
The same subscription exposes
Observable<T> streams. This pays off when you’re chaining operators or
binding to UI.
async void SubscribeOthers()
{
var players = await HeTuClient.Instance.Range<Position>(
"owner", 1, 999, 100);
players.AddTo(gameObject);
// Add stream — initial rows are emitted first, then live inserts.
players.ObserveAdd()
.Subscribe(p => AddPlayer(p))
.AddTo(ref players.DisposeBag);
// Remove stream — emits the row ID that left the range.
players.ObserveRemove()
.Subscribe(rowID => RemovePlayer(rowID))
.AddTo(ref players.DisposeBag);
// Per-row update stream — completes (OnCompleted) when the row is removed.
foreach (var rowID in players.Rows.Keys)
BindRow(players, rowID);
players.ObserveAdd().Subscribe(p => BindRow(players, p.ID))
.AddTo(ref players.DisposeBag);
}
void BindRow(IndexSubscription<Position> players, long rowID)
{
players.ObserveRow(rowID)
.Subscribe(p => MovePlayer(p.owner, new Vector3(p.x, 0.5f, p.y)))
.AddTo(ref players.DisposeBag);
}For RowSubscription<T>, use sub.Subject — it emits the current row first,
then every update, and is ideal for direct UI binding:
var hp = await HeTuClient.Instance.Get<HP>("owner", SelfID);
hp.AddTo(gameObject);
hp.Subject
.Select(x => x.ID != 0 ? $"HP: {x.value}" : "Dead")
.SubscribeToText(hpLabel) // R3 Unity extension
.AddTo(ref hp.DisposeBag);When to prefer which:
R3approach is recommended, as it is more concise and clear, and involves less code.Eventsfor a couple of simple side-effects (spawn, move, despawn).
Subscription lifecycle (don’t skip this)
Every subscription holds a server-side resource. The SDK’s finalizer logs an
error if a subscription is GC’d without Dispose() — that is a real leak,
not a warning to ignore.
Three correct patterns:
// 1. Tie to a GameObject — disposes on Destroy.
sub.AddTo(gameObject);
// 2. Tie to a DisposableBag (for nested R3 subscriptions, or grouping).
sub.AddTo(ref _bag);
// 3. Manual.
try { /* use sub */ } finally { sub.Dispose(); }Dispose() does two things: tells the server “stop pushing me changes for
this query” and tears down all R3 streams chained off the subscription.
After dispose, the Subject / ObserveRow streams will not emit further.
Unity version notes
- Unity 6000+ —
Connect,CallSystem,Get, andRangereturnAwaitable<T>. Useawait Awaitable.WaitForSecondsAsync(...)for delays. - Unity 2022.3 — same APIs return
UniTask<T>. Install UniTask through the Setup Wizard. Useawait UniTask.Delay(ms)for delays.
Both code paths are compiled behind #if UNITY_6000_0_OR_NEWER, so your
calling code only needs to choose one delay style.
Where to next
- Advanced
—
Systemcopies, scheduled future calls, rawEndpoints, custom pipeline layers, and the engine internals you’ll reach for once a project gets real. - Concepts
— re-read the Subscriptions section now that
you’ve seen the client side; permissions / RLS filter what
GetandRangecan return. - Tutorial: Chat Room
— a complete client-and-
server example using the patterns above.
](concepts.md)** — re-read the Subscriptions section now that
you’ve seen the client side; permissions / RLS filter what
GetandRangecan return. - Tutorial: Chat Room — a complete client-and- server example using the patterns above.