ULinkRPC Design and Implementation: Strongly Typed Bidirectional RPC for Game Clients and .NET Without Excess Weight
The previous article focused on usage:
Once you consider putting ULinkRPC into a real project, the next questions are usually about design:
- Why center the framework on shared C# contracts?
- How is it fundamentally different from hand-written message ids plus
switchdispatch? - How does bidirectional communication fit onto one connection?
- How are Transport, Serializer, Runtime, and Source Generation decoupled?
- Which layer owns server callbacks, client pushes, request/response, keepalive, compression, and encryption?
This article does not cover environment setup. It explains the design itself: why the layers exist, why C# contracts are the communication boundary, and how bidirectional communication runs over one connection.
One-Sentence Overview
ULinkRPC is a combination of shared C# contracts + generated glue code + a frame-level runtime.
The sentence to remember is: server and client share the same C# contracts; the generator translates those contracts into glue code; the runtime turns that glue code into real network send/receive behavior.
At a high level, it looks like this:
shared interfaces and DTOs"] --> B["Source Generation Layer
generate proxy / binder / facade"] B --> C["Runtime Layer
request, response, Push, KeepAlive"] C --> D["Transport Layer
TCP / WebSocket / KCP"] A1["[RpcService] / [RpcMethod]
[RpcNotificationContract] / [RpcNotification]"] -.defines semantics.-> A C1["RpcClientRuntime / RpcSession
RpcEnvelopeCodec / IRpcSerializer"] -.implements.-> C D1["complete frame send/receive"] -.abstracts.-> D
1. Contract Layer: Only Describe Who Can Call Whom
This layer only cares about shared interfaces and DTOs:
[RpcService]marks service interfaces.[RpcMethod]marks methods clients can call.[RpcNotificationContract]marks the server-to-client notification interface for a service.[RpcNotification]marks server-to-client notification methods.
It defines what can be called and what notifications can be sent. It does not handle network details.
2. Source Generation Layer: Translate Contracts into Runnable Glue
This layer is handled by the Roslyn source generator in ULinkRPC.Analyzers. It reads RPC contract symbols from the current compilation and referenced assemblies, then generates:
- client service proxies
- client notification binders
- the client unified entry point
RpcApi - server binders
- server notification proxies
- server
AllServicesBinder
The generation layer translates interface definitions into code that can send, receive, and dispatch packets.
3. Runtime Layer: Send Frames Over Connections
The runtime is built from several parts:
ITransport: sends and receives complete frames.IRpcSerializer: converts objects to/from payloads.RpcEnvelopeCodec: converts RPC headers to/from binary frames.RpcClientRuntime: manages client requests, pending responses, and notifications.RpcSession: receives, dispatches, responds, and sends notifications on the server.TransportFrameCodec/TransformingTransport: compression and encryption.
The compact version is:
Contracts define semantics, Source Generator creates glue, Runtime sends and receives.
Why ULinkRPC Does Not Encourage Hand-Written Network Messages
Many Unity + server projects start from a familiar pattern:
- Define a message id enum.
- Hand-write request structures.
- Send a
messageIdfrom the client. - Dispatch with a
switchor dictionary on the server. - Hand-write another response structure.
- Add a separate notification protocol when the server needs to notify the client.
That works, but the costs show up as the project grows:
- message ids and business method names become two separate concepts
- client and server can drift into maintaining separate protocol copies
- server notifications often become a different model from request/response
- new team members cannot easily tell which message maps to which business interface
- renaming interfaces or parameters does not automatically update dispatch code
ULinkRPC makes the opposite choice:
The first abstraction is not the message id. The first abstraction is the interface method.
serviceId / methodId are pushed down into implementation details.
You write:
[RpcService(1, NotificationContract = typeof(IPlayerNotifications))]
public interface IPlayerService
{
[RpcMethod(1)]
ValueTask<LoginReply> LoginAsync(LoginRequest req);
}
The runtime still depends on stable numeric ids:
serviceId = 1methodId = 1
This preserves both sides:
- Business readability: application code sees interfaces and methods.
- Protocol stability: the network still carries stable numeric ids.
That is the part that makes it more maintainable than a fully hand-written protocol over time.
Why Each Method Takes One DTO
ULinkRPC now has a clear design convergence: do not map method parameter lists directly to network payloads; RpcMethod and RpcNotification should each take one DTO argument.
Write this:
[RpcMethod(1)]
ValueTask<LoginReply> LoginAsync(LoginRequest req);
[RpcNotification(1)]
void OnNotify(PlayerNotify notify);
Not this:
[RpcMethod(1)]
ValueTask<LoginReply> LoginAsync(string account, string password);
[RpcNotification(1)]
void OnNotify(string message);
Why This Converged
Allowing no-argument, bare single-argument, and multi-argument methods looks flexible at first, but creates long-term problems:
- changing the method signature changes the wire payload shape
- multi-argument methods naturally depend on parameter order
- notification style becomes inconsistent if bare parameters are allowed
- the source generator must maintain branches for no-arg / DTO / multi-arg forms
- docs and samples cannot settle on one clear convention
This is fine for short demos, but it is not friendly to long-lived projects, multi-person work, or mismatched client/server versions.
Why Not Use a Protobuf-Style Field Number Model
The practical reason is that ULinkRPC currently supports both JSON and MemoryPack, and neither serializer is naturally organized around an explicit field-number protocol. Forcing the whole contract model toward protobuf style would make usage heavier and lose the natural “plain C# interface” feel.
The current compromise is pragmatic:
- do not introduce a field-number system
- do not require a separate schema language
- remove the riskiest part: sending raw method parameter lists over the network
- unify on one request DTO / one response DTO / one notification DTO
What This Tradeoff Buys
Benefits:
- more stable wire shapes for RPC methods
- much simpler source generator code
- a more consistent model for callback, request, and response
- easier samples, docs, and team conventions
- versioning discussions happen mainly at the DTO layer, not the parameter-list layer
Costs:
- you write more DTOs
- tiny interfaces look a little more verbose than bare-parameter methods
- this is not a full protobuf v3 compatibility model
This is not the strongest possible compatibility design. It is a balance between natural C# usage, implementation complexity, and long-term maintainability. For ULinkRPC’s positioning, that tradeoff is appropriate: stabilize the contract style first, then tighten DTO versioning rules over time, instead of turning the whole framework into a heavy protocol system from day one.
Layering: Semantics Above, Frames Below
ULinkRPC’s layering is intentionally explicit.
Layer 1: Contracts Own Semantics
In ULinkRPC.Core, the contract layer is thin. It is mainly attributes and method descriptor types:
RpcServiceAttributeRpcMethodAttributeRpcNotificationContractAttributeRpcNotificationAttributeRpcMethod<TArg, TResult>RpcNotificationMethod<TArg>
This layer does not care whether you use TCP or WebSocket, JSON or MemoryPack, or how packets are sent and received.
It cares about one thing: what this interface method means in RPC semantics.
The important result is:
The top-level mental model is not “packet”. It is “service interface”.
Layer 2: Envelopes Compress Semantics into Stable Binary Headers
The network eventually carries bytes, so ULinkRPC needs a stable middle format between Contract and Transport: the Envelope.
RpcEnvelopes / RpcEnvelopeCodec split RPC data into five frame types:
RequestResponsePushKeepAlivePingKeepAlivePong
A rough view of the protocol headers, with integers in big-endian order:
Request
+-----------+-----------------+-----------------+-----------------+------------------+
| Type(1B) | RequestId(4B) | ServiceId(4B) | MethodId(4B) | PayloadLen(4B) |
+-----------+-----------------+-----------------+-----------------+------------------+
| Payload bytes... |
+------------------------------------------------------------------------------------+
Fixed header = 17 bytes
Response
+-----------+-----------------+------------+------------------+--------------+
| Type(1B) | RequestId(4B) | Status(1B) | PayloadLen(4B) | HasError(1B) |
+-----------+-----------------+------------+------------------+--------------+
| Payload bytes... | ErrorLen(4B, optional) | Error bytes...(optional, UTF-8) |
+---------------------------------------------------------------------------+
Fixed header = 11 bytes
If HasError = 1, append ErrorLen(4B) + Error bytes
Push
+-----------+-----------------+-----------------+------------------+
| Type(1B) | ServiceId(4B) | MethodId(4B) | PayloadLen(4B) |
+-----------+-----------------+-----------------+------------------+
| Payload bytes... |
+----------------------------------------------------------------------+
Fixed header = 13 bytes
KeepAlivePing / KeepAlivePong
+-----------+----------------------+
| Type(1B) | TimestampTicks(8B) |
+-----------+----------------------+
Fixed header = 9 bytes
The key distinction:
Request / ResponseneedRequestIdbecause they form a paired round trip.Pushdoes not needRequestIdbecause it is one-way.
ULinkRPC does not force every case into one universal message type. It splits semantics at the protocol layer, which keeps runtime decisions and generated code cleaner.
Why Envelopes and Serializers Are Separate
RpcEnvelopeCodec only encodes headers and payload boundaries. It does not decide how objects inside the payload are serialized.
In other words:
- Envelope answers “is this a request, which service and method is it for, and how long is the payload?”
- Serializer answers “how are fields inside the payload object encoded?”
That separation is why JSON and MemoryPack can be swapped: the protocol header is fixed, while payload encoding is replaceable.
Layer 3: Transport Only Cares About Complete Frames
ITransport is intentionally framed. It does not expose “send byte segment” or “read from stream” APIs. Its boundary is:
SendFrameAsync(ReadOnlyMemory<byte> frame)ReceiveFrameAsync()
That means the transport boundary is frame, not stream.
Why This Matters
It keeps complexity inside transport implementations instead of leaking it into the RPC runtime.
The runtime always sees:
- “I received one complete request frame.”
- “I am sending one complete response frame.”
Therefore:
- TCP solves stream splitting/coalescing internally.
- WebSocket already behaves closer to message frames.
- KCP has its own message boundary behavior.
Those differences are absorbed by the transport layer. The upper runtime does not need to know whether the underlying network is stream-based.
Why ULinkRPC Can Swap Transports
Many frameworks claim multi-transport support but only wrap different socket clients. ULinkRPC’s abstraction is stricter:
If a transport implements complete-frame send/receive, the upper RPC layer does not change.
TCP, WebSocket, and KCP can use the same runtime not because they are similar, but because the interface boundary is accurate.
Layer 4: Runtime Turns Frames into Calls
Attributes and transports do not make RPC work by themselves. The runtime does.
The client core is RpcClientRuntime; the server core is RpcSession. They are mirror components.
What the Client Runtime Does
RpcClientRuntime mainly:
- assigns an increasing
requestIdto each request - serializes the request object and sends it as a
Requestframe - waits for responses in a
_pendingdictionary keyed byrequestId - handles
Response,Push, andKeepAlivePongin the background receive loop
The client is essentially a multiplexed request manager:
- multiple requests can be in flight on one connection
- each request finds its
TaskCompletionSourcebyrequestId - responses wake the matching waiter
The public API is strongly typed CallAsync<TArg, TResult>, but underneath it is a typical asynchronous multiplexing model.
What the Server Session Does
RpcSession represents one server-side connection session. It mainly:
- starts the transport
- receives complete frames in a loop
- decodes
serviceId/methodIdfromRequestframes - finds the matching handler in the registry
- runs business logic, serializes the result, and returns a
Response
Server-to-client messages use:
RpcSession.SendNotificationAsync(serviceId, methodId, arg)
So server push and server response share the same connection and framing; only the frame type differs.
That is the core of bidirectional communication:
- the client is not only a requester
- the server is not only a passive responder
- the connection is a full-duplex session
Request/Response Flow: From Interface Call to Return Value
When Unity code calls:
var reply = await client.Api.Game.Player.LoginAsync(req);
the lower layers roughly follow this path:
(generated proxy) participant CR as RpcClientRuntime participant CT as Client Transport participant ST as Server Transport participant S as RpcSession participant B as PlayerServiceBinder
(generated) participant SV as PlayerService U->>P: LoginAsync(req) P->>CR: CallAsync(rpcMethod, req, ct) CR->>CR: Serialize req CR->>CR: Encode Request envelope CR->>CT: SendFrameAsync(request frame) CT->>ST: network frame ST->>S: ReceiveFrameAsync() S->>S: Decode Request envelope S->>B: InvokeAsync(serviceId, methodId, payload) B->>B: Deserialize req B->>SV: LoginAsync(req) SV-->>B: LoginReply B->>B: Serialize reply B->>S: Response envelope S->>ST: SendFrameAsync(response frame) ST->>CT: network frame CT->>CR: ReceiveFrameAsync() CR->>CR: Match pending by RequestId CR-->>P: complete ValueTask
Step 1: The Call Hits a Generated Client Proxy
It looks like an interface call, but the actual target is the generated PlayerServiceClient.
The proxy defines something like:
private static readonly RpcMethod<LoginRequest, LoginReply> loginAsyncRpcMethod = new(ServiceId, 1);
Then it forwards to:
_client.CallAsync(loginAsyncRpcMethod, req, ct)
Generated code translates the interface method call into a runtime call with serviceId/methodId.
Step 2: Runtime Creates requestId and Serializes the Argument
RpcClientRuntime.CallAsync:
- creates a new
requestId - serializes
reqinto payload bytes - constructs
RpcRequestEnvelope - passes it to
RpcEnvelopeCodec.EncodeRequest
Now a C# object call has become a binary frame ready to send.
Step 3: Transport Sends the Frame
The runtime does not care whether the transport is TCP, WebSocket, or KCP. It calls:
_transport.SendFrameAsync(frame)
If compression or encryption is enabled, the frame may first pass through TransformingTransport:
- compress
- encrypt and authenticate
- send through the underlying transport
Step 4: Server Session Receives a Request Frame
RpcSession reads a frame and calls PeekFrameType.
- If it is
KeepAlivePing, it replies with pong. - If it is not
Request, it ignores it. - If it is
Request, it decodes the request envelope.
Then it looks up a handler by (serviceId, methodId).
Step 5: The Handler Comes from a Generated Binder
The server does not hand-write a giant dispatch table. The source generator creates binders for each service. For example, PlayerServiceBinder binds:
(1, 1)toLoginAsync(1, 2)toIncrStep
The binder:
- deserializes parameters from
req.Payload - obtains the service implementation
- calls the real business method
- serializes the result
- builds
RpcResponseEnvelope
Its core role is:
compile protocol dispatch into static, type-safe registration code.
Step 6: The Service Implementation Runs
Only now does your business class, such as PlayerService, execute.
The business class does not need to know:
- which network connection the request came from
- what the packet header looks like
- how responses are encoded
It only implements ordinary interface methods.
That is one of ULinkRPC’s main goals:
separate business code from network boilerplate.
Step 7: Response Returns to the Client by requestId
When the client receives a Response, RpcClientRuntime.ReceiveLoopAsync:
- decodes the response
- finds the matching
TaskCompletionSourcein_pendingbyresponse.RequestId - completes the result
The outer await client.Api.Game.Player.LoginAsync(req) then resumes.
Step 8: Failures Return Through the Protocol Too
If a server handler is missing, business code throws, or an error status is returned, the response contains:
StatusErrorMessage
The client runtime throws when Status != Ok.
So callers still experience “await a method; failures throw”, even though a full network round trip happened underneath.
Server-to-Client Push: Callback Is Part of the Contract
One of ULinkRPC’s practical strengths is that server push is not designed as a separate system.
The model is unified:
- client -> server:
[RpcService] + [RpcMethod] - server -> client:
[RpcNotificationContract] + [RpcNotification]
The data flow looks like this:
(generated) participant S as RpcSession participant ST as Server Transport participant CT as Client Transport participant CR as RpcClientRuntime participant CB as PlayerNotificationsBinder
(generated) participant R as PlayerNotificationsReceiver SB->>CP: OnNotify(PlayerNotify) CP->>CP: Serialize notify CP->>CP: Encode Push envelope CP->>S: SendNotificationAsync(serviceId, methodId, payload) S->>ST: SendFrameAsync(push frame) ST->>CT: network frame CT->>CR: ReceiveFrameAsync() CR->>CR: Decode Push envelope CR->>CB: Dispatch by serviceId/methodId CB->>CB: Deserialize notify CB->>R: OnNotify(notify)
Why This Matters
Many projects handle request and push with separate systems:
- requests use RPC
- pushes use another message bus
- one business module maintains two protocol definitions
ULinkRPC puts callbacks into the same contract:
[RpcService(1, NotificationContract = typeof(IPlayerNotifications))]
public interface IPlayerService
{
[RpcMethod(1)]
ValueTask<LoginReply> LoginAsync(LoginRequest req);
}
[RpcNotificationContract(typeof(IPlayerService))]
public interface IPlayerNotifications
{
[RpcNotification(1)]
void OnNotify(PlayerNotify notify);
}
This does not mean “the service also happens to have an event subscription.” It means:
IPlayerService naturally has a server-to-client notification contract.
How the Server Notification Proxy Works
Generated server code emits a PlayerNotificationsProxy. It implements IPlayerNotifications, but its method bodies do not run local logic. They:
- serialize the argument
- call
RpcSession.SendNotificationAsync - send a
Pushframe
So when service code writes:
_callback.OnNotify(new PlayerNotify { Message = "hello" })
it looks like a local object call, but it actually sends a server-to-client notification through a notification proxy.
Notification appears as “call an interface” in business code, not “hand-build a push packet.”
What the Client Notification Binder Does
The generated PlayerNotificationsBinder maps (serviceId, methodId) to the notification receiver registered by client code.
When the client runtime receives a Push frame:
- decode
serviceId/methodId - find the matching notification handler
- deserialize the payload
- call the user’s receiver
The two sides close the loop:
- server notification proxy turns interface calls into
Pushframes - client notification binder turns
Pushframes back into interface calls
Why Use Code Generation Instead of Reflection RPC
Runtime reflection is possible in theory:
- scan assemblies at startup
- find
[RpcService] - find
[RpcMethod] - build dynamic dispatch tables
- invoke methods by reflection after receiving requests
ULinkRPC chooses source generation because Unity scenarios make static generation more practical.
The point is not saving a few lines. The point is deciding who maintains the large amount of repetitive, fragile glue code that otherwise drifts from contracts.
1. Unity / AOT Prefer Static Generation
Unity, IL2CPP, and AOT environments are not friendly to runtime reflection plus dynamic generation. Generating static code ahead of time is more robust.
2. Generated Code Is Easier to Inspect
When debugging, generated files show:
- which
serviceId/methodIdmaps to which method - how request parameters are packed
- how the server deserializes
- how callbacks are bound
That is easier to inspect than hidden reflection logic.
3. Runtime Cost Is Lower
Compiling dispatch logic ahead of time reduces:
- runtime reflection scanning
- dynamic binding overhead
- generic boxing/unboxing paths
4. Constraints Are Clearer
The source generator validates contracts while scanning:
- duplicate
ServiceId - duplicate
MethodId - callback/service mismatch
- RPC return types must be
ValueTask/ValueTask<T>
Many protocol mistakes are rejected during generation instead of failing at runtime.
ULinkRPC does not use code generation for its own sake. The generator is a practical tool for making shared C# contracts work, in exchange for:
- Unity compatibility
- static readability
- earlier errors
What the Source Generator Actually Does
More concretely, the generator handles six kinds of work.
1. Parse Contract Source
ContractParser does not scan assemblies at runtime. It analyzes C# source files in the contracts directory.
It extracts:
- service interface name
- service id
- method list
- parameter type and order
- return type
- notification interface and notification methods
- required
usingdirectives
The input is source contracts, not DLLs. Benefits:
- richer information during generation
- validation before contracts are fully consumed by business projects
- a better fit for Unity / shared contract workflows
2. Generate Client Proxies
Each service gets an XxxServiceClient.
Its job is simple:
- bake method ids into
RpcMethod<TArg, TResult>fields - forward interface calls to
_client.CallAsync(...)
Users get a call shape close to a local interface.
3. Generate Client Notification Binders
If a service declares a notification contract, the generator emits XxxNotificationsBinder.
It:
- predefines
RpcNotificationMethod<TArg> - registers notification handlers with the runtime
- deserializes notification arguments
- invokes the notification receiver supplied by user code
4. Generate the Unified Client Facade RpcApi
When a project has multiple services, users should not manually construct many client proxies. The generator emits:
RpcApi- grouped
xxxRpcGrouptypes - extensions on
RpcClient
The end-user experience becomes:
client.Api.Game.Player.LoginAsync(...)
client.Api.Game.Inventory.GetRevisionAsync(...)
This organizes independent proxies into a facade that is easier for business code to consume.
5. Generate Server Binders
Server binders are one of the most important outputs.
They register each (serviceId, methodId) with RpcServiceRegistry and, inside each handler, handle:
- argument decoding
- service implementation lookup
- business method invocation
- return value encoding
- response assembly
Client proxies translate local calls into network requests. Server binders translate network requests back into local calls.
6. Generate Server Notification Proxies and the Aggregate Binder
If a service has notifications, the server also gets notification proxies. Together with AllServicesBinder, this supports:
- automatic service implementation discovery
- automatic rule-based binding
- explicit service instances / factories for larger projects
Small projects work out of the box, while larger projects keep explicit control.
Why the Server Has Both Registry and Session-Scoped Services
First-time readers often notice two server structures:
- a global
RpcServiceRegistry _scopedServicesinsideRpcSession
They are not duplicates. They operate at different levels.
RpcServiceRegistry Answers “How Do I Find the Handler?”
The registry key is:
serviceIdmethodId
It stores RpcSessionHandler, meaning:
- what logic should run when a session receives this RPC method
So the registry owns protocol-level dispatch.
_scopedServices Answers “Which Service Instance Does This Connection Reuse?”
Before a binder calls business code, it gets the service instance with:
server.GetOrAddScopedService(ServiceId, implFactory)
The default semantics are:
- one scoped service instance per session / serviceId
Benefits:
- state on one connection can naturally live on the service instance
- notification proxy can be tied to the current session
- users do not need to maintain a “connection -> service object” map
This fits connection-stateful business logic such as:
- login state
- current player context
- room session state
- callback channel for one connection
The design is not redundant. It pushes connection scope into the framework.
Why Keepalive Lives in Runtime, Not Transport
Keepalive in ULinkRPC is not implemented separately by each transport. The RPC runtime handles it consistently:
- the client sends
KeepAlivePing/ receivesKeepAlivePong - server sessions reply pong when they receive ping
- clients can measure RTT
- servers can also decide a connection is dead based on timeout
Benefits
If keepalive lived in the transport layer, each transport would need its own version:
- TCP version
- WebSocket version
- KCP version
The semantics might diverge.
At the RPC frame layer, keepalive becomes cross-transport behavior:
- any transport that can send frames can support keepalive
In other words, keepalive is part of RPC session semantics, not a property of one concrete socket.
This matches the rest of ULinkRPC:
- transport provides “frame delivery”
- runtime provides “connection session semantics”
Why Compression and Encryption Are in TransformingTransport
Between the RPC runtime and low-level transport, ULinkRPC inserts TransformingTransport.
The idea is deliberately engineering-focused:
- transport owns connectivity
- runtime owns RPC semantics
- security and compression are frame transformations
Therefore:
- compression threshold is controlled by
TransportSecurityConfig - encode/decode logic lives in
TransportFrameCodec - composition wraps the real transport with
TransformingTransport
Benefits
It avoids two common forms of confusion.
Confusion 1: Put Compression into the Serializer
That couples object encoding to network optimization, and each serializer’s behavior becomes more complex.
Confusion 2: Put Encryption into Every Transport
Then TCP, WebSocket, and KCP each need their own compression/encryption implementation, creating duplication.
ULinkRPC instead:
- first obtains a standard frame
- optionally applies frame-level compression/encryption
- decrypts/decompresses on receive, then restores the standard frame
This keeps “security” and “protocol” separated.
One caveat: this encryption is a framework-level symmetric encryption scheme for frame content protection. It is not a full TLS replacement. If your transport already runs over TLS/WSS, whether to add another layer depends on your deployment environment.
Bidirectional RPC Is One Full-Duplex Session, Not Two Systems
This is the most useful mental model.
People often imagine bidirectional RPC as:
- one client -> server system
- a separate server -> client push system
ULinkRPC is not split that way.
It is closer to:
- one full-duplex session underneath
- several frame types on that session
- request/response is one frame combination
- server push is another frame combination
So:
- bidirectional ability comes from the connection model itself
- it does not come from a second protocol stack
This has two practical benefits.
1. Unified Mental Model
Requests, responses, pushes, and keepalive all revolve around:
- frame
- service id
- method id
- payload
2. Lower Maintenance Cost
You do not need a second serializer, message bus, or handler system for push.
This matters especially for Unity clients, which are already a poor place to carry duplicated infrastructure.
Where This Design Fits
After the design, the fit becomes clearer.
Good Fits
1. Unity + .NET Projects with Shared Contracts
This is ULinkRPC’s natural home. You already have:
- a Unity client
- a C# server
- shared DTOs / interfaces
ULinkRPC extends the same C# type system across the client/server communication boundary.
2. Business Domains with Clear Service Boundaries
Examples:
- login
- inventory
- quests
- rooms
- battle control commands
- small-scale state synchronization
These modules naturally split into services.
3. Teams That Want Strong Typing + Bidirectional Callbacks Without Hand-Written Protocols
If you are tired of:
- message id management
- hand-written encode/decode maps
- split push and request protocols
ULinkRPC directly addresses those problems.
Poorer Fits
1. You Need Extremely Free Binary Layout
If you need bit-level control, field compression, or extreme packet layout design, a general RPC abstraction like ULinkRPC will not be the closest fit.
2. You Are Sending High-Frequency Streaming Data, Not Service Calls
Very high-frequency, fine-grained synchronization streams may not be worth modeling as RPC methods. ULinkRPC is better used for:
- control plane
- business call plane
- medium/low-frequency state distribution
Use a more specialized protocol for extremely high-frequency data channels.
3. Server and Client Are Not Both in the C# Stack
ULinkRPC starts from the assumption that a C# server and a C# game client share the same communication contract. If your client is mainly TypeScript, C++, Java, Go, or native mobile, schema-first or multi-language IDL tools are usually a better fit.
Seven Principles for Real Projects
1. Treat Contracts as First-Class
The long-term stable artifact is not the generated file. It is the contract.
2. Do Not Casually Change serviceId / methodId After Release
They are stable protocol identifiers, not just decorative numbers in code.
3. Treat Callback as Part of Service Design
If a service naturally pushes from server to client, put that in the contract from the start instead of adding a side-channel protocol later.
4. Use ULinkRPC for Control Plane and Clear Semantic Calls First
Login, matchmaking, inventory, quests, room management, gameplay commands, and similar APIs are good fits.
5. Let Transport and Serializer Follow the Scenario
- Need easier debugging: start with JSON.
- Need better efficiency: evaluate MemoryPack.
- Need web compatibility: use WebSocket.
- Need a real-time networking prototype: evaluate TCP / KCP.
6. Do Not Hand-Edit Generated Code
Generated code is a projection of contracts, not a manual maintenance layer. Change behavior in contracts or runtime.
7. Keep Business Code Away from Network Details
If business implementations constantly care about headers, message ids, or serialization details, the abstraction has leaked.
Closing: ULinkRPC Solves Boundary Maintenance, Not Just Packet Sending
Many frameworks can move data from A to B. The hard part is keeping all of these true over time:
- C# contracts can be shared between server and client
- interfaces stay strongly typed
- client calls and server pushes share one model
- the transport can be replaced
- the serializer can be replaced
- the Unity / .NET workflow remains usable
- new maintainers can understand the system quickly
ULinkRPC’s answer is simple:
- Use interfaces to define communication boundaries.
- Use source generation to remove binder / proxy / facade boilerplate.
- Use a lightweight runtime for request, response, push, and keepalive.
- Use transport / serializer abstractions to keep low-level infrastructure replaceable.
It is not an all-in-one networking framework. It is a clear composition:
- strongly typed contracts at the top
- generated glue in the middle
- unified framing and replaceable infrastructure at the bottom
For Unity + .NET projects, the workflow is direct: write C# contracts, compile normally, connect the runtime, and let business code implement interfaces and handle callbacks.
The final line:
ULinkRPC is not mainly saving a few packet-sending lines. It is helping you maintain a bidirectional RPC boundary over the long term.