Client to server: requests
Clients have two upward channels: the per-tick input struct, and requests — reliable one-off messages sent from a HumanControllerLogic with an optional success callback, for the actions that don't belong in input.
When to use this
- Occasional intentional actions: buy an item, pick a loadout, vote, change team, use a menu.
- Anything the server may accept or reject, where the client needs to know which.
When not to
- Per-tick movement and firing — that is the input struct, which is delta-compressed, redundant and prediction-aware; requests are not.
- Server-to-client messaging — that direction is RPCs.
- High-frequency streams — every request is a reliable packet; a request per tick is the wrong tool.
Minimal example
ShopController.cs
using LiteEntitySystem;
using LiteNetLib.Utils;
public struct BuyItemRequest : INetSerializable
{
public ushort ItemId;
public byte Count;
public void Serialize(NetDataWriter writer)
{
writer.Put(ItemId);
writer.Put(Count);
}
public void Deserialize(NetDataReader reader)
{
ItemId = reader.GetUShort();
Count = reader.GetByte();
}
}
// auto-serialized class form: properties with get AND set are required —
// that is what LiteNetLib's NetSerializer reflects over
public class SellItemRequest
{
public ushort ItemId { get; set; }
public byte Count { get; set; }
public string Reason { get; set; }
}
public class ShopController : HumanControllerLogic<PlayerInput, MyPlayer>
{
public ShopController(EntityParams entityParams) : base(entityParams)
{
if (IsServer)
{
SubscribeToClientRequestStruct<BuyItemRequest>(OnBuyItem);
SubscribeToClientRequest<SellItemRequest>(OnSellItem);
}
}
// client side
public void RequestBuy(ushort itemId, byte count) =>
SendRequestStruct(new BuyItemRequest { ItemId = itemId, Count = count },
success => { /* update the shop UI */ });
public void RequestSell(ushort itemId, byte count) =>
SendRequest(new SellItemRequest { ItemId = itemId, Count = count, Reason = "manual" },
success => { /* update the shop UI */ });
// server side: the bool becomes the client's callback argument
private bool OnBuyItem(BuyItemRequest request)
{
if (!CanAfford(request))
return false;
GrantItem(request);
return true;
}
private bool OnSellItem(SellItemRequest request)
{
// same contract, reflection-serialized payload
return true;
}
private bool CanAfford(BuyItemRequest request) => true;
private void GrantItem(BuyItemRequest request) { }
}
How it works
Two payload styles
Structs with explicit serialization — SendRequestStruct<T> / SubscribeToClientRequestStruct<T>, where T : struct, INetSerializable. You write Serialize/Deserialize yourself; no allocation per send, and full control over the bytes.
Auto-serialized classes — SendRequest<T> / SubscribeToClientRequest<T>, where T : class, new(). LiteNetLib's NetSerializer reflects over the type, so the payload must be declared as properties with both a getter and a setter — plain fields are ignored, and a get-only property will not round-trip. Handy for menu-style data with strings, at the cost of an allocation and reflection.
Types nested inside an auto-serialized class need registering first with RegisterClientCustomType<T>, either as an INetSerializable struct or with explicit write/read delegates.
The result callback
Both send methods have an overload taking Action<bool>. The server handler's return value decides what the client receives: subscribe with a Func<T, bool> to answer explicitly, or with an Action<T> when the answer is always success. The callback is invoked on the requesting client when the response arrives — treat it as a confirmation, not as data transport; anything the client must know afterwards should be state it can read.
Where subscriptions live
Subscriptions are per-controller instance, made on the server — the controller's constructor is the natural place. Since a controller exists only for its owning player, a handler always knows who sent the request: the controller's owner. There is no way to spoof another player's identity through this channel.
Reliability and ordering
Requests are sent reliable ordered on their own: none are lost, and they arrive in the order the client sent them. They are processed on the server at the start of a logic tick, before entity updates.
Behavior details
Pending requests are read at the beginning of the tick, so a handler's changes are visible to every entity update in that same tick. A request addressed to a controller that no longer exists (the player disconnected, the controller was replaced) is dropped. Malformed payloads are logged and ignored rather than throwing.
Warning
Common mistakes
- Sending requests from predicted
Updatecode — re-simulation would repeat them, so the library ignores them during rollback and your action silently never happens. - Using requests for movement or firing — they are reliable and unbuffered, so a packet loss stalls the queue; continuous player intent belongs in the input struct.
- Treating the
boolcallback as data — it is a yes/no receipt; results the client needs to act on belong in synchronized state or an RPC. - Declaring an auto-serialized request class with fields instead of
{ get; set; }properties — the reflection-based serializer skips them and the payload arrives empty. - Subscribing on the client — subscriptions handle incoming requests, which only ever happens on the server; guard with
IsServer.