Unity client
cn.deeprespond.im.game lets players of a Unity game join a game room your server created and exchange inputs with other players in real time. It supports iOS, Android, Windows, macOS and Linux; for WebGL games use the Web client. Room rules and requirements are in the game rooms overview.
SDK version
The current version is 1.0.0. It needs Unity 2021.3 or later and works with both Mono and IL2CPP. It has no third-party dependencies.
using DeepRespond.Im.Game;
using UnityEngine;
var im = new ImGameClient(new ImGameClientOptions {
ApiUrl = "https://api.example.com",
AppKey = "<App Key>",
DeviceId = SystemInfo.deviceUniqueIdentifier,
});
await im.Auth.LoginWithTicketAsync("alice", loginTicketFromBusinessServer);
var room = await im.Game.JoinAsync("rank-8812", new JoinOptions { Ticket = ticketFromBusinessServer });
room.OnFrame += f => game.Step(f.Frame, f.Inputs); // 严格按帧号顺序,缺帧自动补齐后再回调
room.OnMember += e => ui.UpdateSeat(e.Seat, e.Type); // Online / Offline / Left
room.OnClose += e => ui.ShowResult(e.Reason, e.ResultJson);
room.Ready(); // all_ready 房间:所有人准备后开局
// Update() 里每帧调用,SDK 按房间帧率发送
room.SendInput(game.LocalInput());
// 玩家退出对局时
await room.LeaveAsync();| To do this | See |
|---|---|
| Add the SDK to your project | Install |
| Sign in | Sign in |
| Join a room | Join |
| Send inputs every frame and advance the game frame by frame | Lockstep |
| Send chat, emotes or turn-based moves | Custom messages |
| Show seats, who is online and latency | Room state and members |
| Handle disconnects and the end of a match | Disconnects and ending |
| Return to the match after the game restarts | Rejoin a match |
| Tell the player why joining failed | Errors |
| Choose the thread callbacks run on | Callback thread |
You / the SDK
| You | The SDK |
|---|---|
| Get a login ticket and a join ticket from your server | Signs in and renews the session when it expires; fetches connection info, connects to the game node, handshakes |
Call SendInput in Update with the local input | Sends at the room's tick rate; orders frames by number, drops duplicates, requests missing frames from the node |
Advance your game logic in OnFrame | Encrypts traffic; falls back to WebSocket when UDP can't connect; heartbeats, measures latency, estimates server time |
Show results or a message from OnClose | Reconnects automatically after a disconnect, fetches new connection info when needed, gives up after the reconnect window |
Install
Option 1: npm registry (recommended; you can update from the Package Manager later). Add scopedRegistries and the dependency to your project's Packages/manifest.json:
{
"scopedRegistries": [
{ "name": "DeepRespond", "url": "https://registry.npmjs.org", "scopes": ["cn.deeprespond"] }
],
"dependencies": {
"cn.deeprespond.im.game": "1.0.0"
}
}Option 2: download the package (intranet or offline). Download cn.deeprespond.im.game-1.0.0.tgz (SHA256SUMS), put it in your project (for example under Packages/), then in the Package Manager click "+" → Add package from tarball and select it.
Sample: select this package in the Package Manager → Samples → "Quick Start" to import GameRoomQuickStart.cs. Attach it to any object in a scene, fill in the address and tickets in the Inspector, and press Play.
Sign in
The Unity SDK includes sign-in, so you don't need a separate IM client SDK. We recommend your server issues a login ticket (with auto_create, the user is created if it doesn't exist); the game only receives a ticket that is valid for 5 minutes:
await im.Auth.LoginWithTicketAsync("alice", loginTicketFromBusinessServer);Option (ImGameClientOptions) | Description |
|---|---|
ApiUrl | IM API address (required) |
AppKey | The app's App Key, needed to sign in |
DeviceId | Device ID (required). Keep it the same on one device; usually SystemInfo.deviceUniqueIdentifier |
DeviceName | Device name shown in the signed-in device list |
Platform | ios, android, windows, macos, linux; detected from the OS when omitted. On phones, pass it explicitly based on Application.platform |
Logger | Log output, for example to Debug.Log. Logs never contain keys or tickets |
- The session lives only in memory. To skip sign-in next time, listen to
im.Auth.SessionChangedand save it yourself (preferably in the system keychain, not as plain text inPlayerPrefs), then callim.Auth.UseSession(saved)at startup. - When the access token expires, the SDK renews it with the refresh token and retries; you don't need to handle it. If renewal also fails,
SessionChangedis called withnulland the player must sign in again. await im.Auth.LogoutAsync()signs out and disconnects from the current room (OnClosewithReasonsigned_out).
Join
var room = await im.Game.JoinAsync("rank-8812", new JoinOptions { Ticket = ticketFromBusinessServer });
Debug.Log($"{room.State} 座位 {room.Seat} 传输 {room.Transport}"); // Connected、本人座位、udp 或 wsOption (JoinOptions) | Description |
|---|---|
Ticket | The ticket your server issued to this player. Omit it for open rooms |
Transport | Auto (default) tries UDP first and switches to WebSocket if it can't connect within 3 seconds; Udp or Ws uses only one |
ClientSessionId | ID of this join (8–64 visible ASCII characters). Generated by the SDK when omitted; stays the same when connection info is fetched again during the same join |
- Returns after connecting to the game node and completing the handshake, usually within 1 second; about 3 seconds when a firewall blocks UDP.
- A client can be in only one room at a time;
im.Game.Currentis the current room. CallingJoinAsyncagain for the same room returns the same object; joining another room throwsInvalidOperationException, so callLeaveAsync()first. - The game must be able to reach the node's UDP port and its
wss://address.
Lockstep
In rooms whose sync_mode is frame (the default), the game node advances tick_rate frames per second. Each frame, the node bundles every seat's input for that frame and sends it to everyone; all players process the same inputs in the same order, so the game stays in sync.
room.OnRoomState += e =>
{
if (e.State == GameRoomState.Started) Debug.Log($"开局,第一帧为 {e.StartFrame}");
};
room.OnFrame += f =>
{
// f.Inputs 只包含这一帧有操作的座位
game.Step(f.Frame, f.Inputs);
};
void Update()
{
// 每帧都可以调用(不超过 128 字节):SDK 每 1000 / tick_rate 毫秒发送一次,只发最新的一份
room.SendInput(game.LocalInput());
}OnFrameis called strictly in ascending frame order, with no duplicates or gaps. When packets are lost, the SDK first fills gaps from the redundancy in later packets, asks the node when it can't, and calls back in order once the gap is filled.SendInputreturnsfalsewhile not connected and doesn't queue — stale inputs are useless. Don't send faster yourself: more than twicetick_rateper second is treated as abuse and the node removes the player from the room; the SDK's throttling prevents this.- Inputs sent before the match starts are ignored.
room.Frameis the last frame delivered. - Encode inputs in your own binary format, for example a few bits each for direction and buttons.
Snapshots: in rooms created with snapshot enabled, the node picks one player to provide the game state periodically. The player whose room.IsSnapshotProvider is true calls PutSnapshot at the interval; when someone has been disconnected too long and is missing too many frames, they first get OnSnapshot and then continue from the frame after the snapshot:
if (room.IsSnapshotProvider) room.PutSnapshot(room.Frame, game.Serialize()); // 不超过 64KB,按建房时的间隔调用
room.OnSnapshot += s => game.ApplySnapshot(s.Data); // 之后的 OnFrame 从 s.Frame + 1 开始Custom messages
relay rooms have no frames; players only exchange custom messages. frame rooms can also use them for data that isn't part of a frame, such as chat and emotes.
room.SendCustom(Encoding.UTF8.GetBytes("gg")); // 发给除自己以外的所有人
room.SendCustom(Encoding.UTF8.GetBytes("出牌:红桃A"), to: new[] { 1 }, reliable: true); // 只发给座位 1,保证送达和顺序
room.OnCustom += m => Debug.Log($"座位 {m.From}:{Encoding.UTF8.GetString(m.Data)}");Each message is at most 1KB; each seat can send at most 60 messages and 32KB per second. Anything over that is dropped by the node, and a player who stays over the limit for 10 seconds in a row is removed from the room.
Room state and members
| Property | Description |
|---|---|
State | Connection state: Connecting, Connected, Reconnecting (resuming), Rejoining (fetching new connection info), Closed, Left |
RoomState | Waiting for the match to start, Started in progress, Closing ending |
Seat | Your seat (0–9) |
Members | Seated members with Seat, Username, Online, ordered by seat; members who left are not included |
Frame | The last frame delivered |
Rtt | Median round-trip time of the last 10 pings (ms); null right after connecting |
Transport | The connection in use: udp or ws |
ServerTimeMs() | Estimated server time (Unix ms), for aligning countdowns across devices |
room.OnMember += e => ui.UpdateSeat(e.Seat, e.Type); // Online 上线、Offline 断线(可能回来)、Left 离开
room.OnState += s => ui.ShowReconnecting(s == GameConnectionState.Reconnecting || s == GameConnectionState.Rejoining);The room is already Connected when JoinAsync returns; OnState fires only for later changes.
Disconnects and ending
You don't need code for disconnects: the SDK reconnects immediately when no data arrives for 5 seconds or the connection drops, then retries after 1, 2, 4, 4… seconds, returns to the same seat, and fills in missing frames in order. While reconnecting, State is Reconnecting or Rejoining and SendInput returns false. When the game is suspended in the background and comes back, it is treated as a disconnect and reconnects automatically.
In the cases below the room ends for this player: OnClose is called once and the SDK stops reconnecting.
Reason | When | Suggested UI |
|---|---|---|
room_closed | The match ended. CloseReason is why (such as closed_by_server, expired), ResultJson is the result your server provided (raw JSON) | Show results |
left | You called LeaveAsync() | — |
timeout | Disconnected longer than the room's reconnect window | "Connection lost, you left the match" |
kicked | Removed by your server | "You were removed from the room" |
replaced | The same account joined this seat elsewhere | "You joined this match on another device" |
node_draining, room_not_found | Game server maintenance, room no longer exists | "The match was interrupted" |
signed_out, destroyed | Signed out, or called room.Dispose() or im.Dispose() (other players aren't notified; the seat is kept for the reconnect window) | — |
room.OnRoomState += e =>
{
if (e.State == GameRoomState.Closing) Debug.Log($"即将结束 {e.Reason} {e.ResultJson}");
};
room.OnClose += e =>
{
if (e.Reason == "room_closed") ui.ShowResult(e.CloseReason, e.ResultJson);
else ui.Toast($"离开对局:{e.Reason}");
};Leave: await room.LeaveAsync() notifies the game node and IM; other players get Left for this seat. Safe to call more than once.
Rejoin a match
After the game restarts, the connection info (kept only in memory) is gone, so join again:
var current = await im.Game.GetCurrentAsync();
if (current != null)
{
// 还在对局中的座位:不需要新票据,重新加入回到原座位
var room = await im.Game.JoinAsync(current.RoomId);
ui.Toast($"回到座位 {room.Seat}");
}GetCurrentAsync returns only rooms this device (this sign-in) hasn't left. A seat past the reconnect window has been released, and your server needs to issue a new ticket.
Errors
JoinAsync throws on failure. Use GameErrors.KindOf(ex) to get the category and decide what to show the player:
try
{
await im.Game.JoinAsync("rank-8812", new JoinOptions { Ticket = ticketFromBusinessServer });
}
catch (Exception ex)
{
switch (GameErrors.KindOf(ex))
{
case GameErrorKind.TicketInvalid: ui.Toast("入场凭证已失效,请重新匹配"); break;
case GameErrorKind.RoomFull: ui.Toast("房间已满"); break;
case GameErrorKind.JoinedOnOtherDevice: ui.Toast("你已在其他设备进入该对局"); break;
case GameErrorKind.RoomClosed: ui.Toast("对局已结束"); break;
default: ui.Toast("暂时无法进入对局,请稍后再试"); break;
}
}| Category | Cause | Suggestion |
|---|---|---|
TicketInvalid | The ticket is wrong, expired or already used | Have your server issue a new one |
GameDisabled | Game rooms aren't enabled on the platform, or the plan doesn't include them | "Can't enter the match right now" |
RoomClosed | The room doesn't exist or has ended | "The match has ended" |
RoomFull | All seats are taken, or the assigned seat is occupied | "The room is full" |
JoinedOnOtherDevice | The same account already joined this room on another device | Tell the player, or have them leave on the other device |
NodeUnavailable | The room's game server is temporarily unavailable | Retry later |
UpgradeRequired | The SDK is too old | Upgrade the SDK |
Network | Network error or timeout | Retry later |
| Exception | When |
|---|---|
ImApiException | An API returned an error. Status, Code, Reason (details.reason) are the same as server errors |
GameNodeException | The game node rejected the connection. Name is the reason, such as room_full |
ImNetworkException | Network error or timeout |
ArgumentException, InvalidOperationException | Invalid arguments (such as SendInput over 128 bytes, SendCustom over 1KB, PutSnapshot over 64KB), not signed in, already in another room |
Callback thread
ImGameClientOptions.EventMode | Where callbacks run | When to use |
|---|---|---|
SynchronizationContext (default) | The thread that called JoinAsync: when called on the Unity main thread, callbacks run there and can change scene objects directly | Most games |
Manual | Wherever you call room.DispatchEvents() | Lockstep games that want to process frames at a fixed point in their own Update / FixedUpdate |
- All callbacks run in the order the events happened; exceptions thrown in callbacks are logged and don't affect the SDK.
- Network I/O runs on background threads, not the main thread. Properties such as
StateandMemberscan be read from any thread.
API reference
im.Game | Description |
|---|---|
Current | The current room, or null |
JoinAsync(roomId, options?) | Join; returns a GameRoom |
GetCurrentAsync() | The room this device is in (RoomId, RoomStatus, Seat, MemberStatus), or null |
LeaveAsync(roomId) | Leave without a room object |
im.Auth | Description |
|---|---|
LoginWithTicketAsync(username, ticket) | Sign in with a login ticket |
LoginWithPasswordAsync(username, password) | Sign in with username and password |
UseSession(session), Session, SessionChanged | Restore and read the saved session |
RefreshAsync(), LogoutAsync() | Renew, sign out |
GameRoom method | Description |
|---|---|
SendInput(data) | Input for this frame, ≤ 128 bytes |
SendCustom(data, to?, reliable?) | Custom message, ≤ 1KB; to is an array of seats |
Ready() | Ready |
Start() | Host starts. Not yet available for rooms created through IM; for manual rooms your server calls the start API |
PutSnapshot(frame, data) | Provide a snapshot, ≤ 64KB |
LeaveAsync() | Leave |
Dispose() | Disconnect without leaving (the seat is kept for the reconnect window) |
DispatchEvents() | Run pending callbacks in Manual mode |
Events OnState, OnRoomState, OnFrame, OnCustom, OnMember, OnSnapshot, OnClose | Subscribe with +=, unsubscribe with -= |
Platforms
| Platform | Notes |
|---|---|
| iOS | The node address must be a domain name: the App Store requires apps to work on IPv6-only networks, where IPv4 addresses can't be reached |
| Android | Set Internet Access to Require in Player Settings so the build includes the network permission |
| WebGL | Not supported (browsers have no UDP); use the Web client |
Changelog
| Version | Date | Changes |
|---|---|---|
| 1.0.0 | 2026-11-10 | First release: sign-in, join, lockstep, custom messages, snapshots, reconnect, rejoin |
License
MIT.
