| name | introducing-network-messages |
| description | Add or update client/server protobuf network messages for this repo. Use when introducing new packets, changing websocket message flow, editing `multiplayer/proto/network.proto`, wiring new messages through `multiplayer/mp-client/src/utils/NetworkManager.ts`, `multiplayer/server/Server.cs`, `multiplayer/server/World/Game/GameWorld.cs`, or related server/player packet handlers. |
Introducing Network Messages
Use this skill when adding a new message that travels between the client and server.
Follow These Rules First
Before editing networking code, follow:
multiplayer/.cursor/rules/common-guidlines.mdc
multiplayer/.cursor/rules/server-csharp-guidelines.mdc
multiplayer/.cursor/rules/client-typescript-guidelines.mdc
If the server-side packet flow is relevant, also follow:
multiplayer/.cursor/rules/server-threading-and-packet-flow.mdc
multiplayer/docs/SERVER_THREADING_AND_PACKET_FLOW.md
Files To Inspect
Start from these files:
multiplayer/proto/network.proto
multiplayer/mp-client/src/utils/NetworkManager.ts
multiplayer/server/Server.cs
multiplayer/server/World/Game/GameWorld.cs
Often also inspect:
multiplayer/server/World/Game/GameWorldPlayer.cs
multiplayer/mp-client/src/proto/generated/network.ts
- whichever client scene, store, or manager should react to the new packet
Core Principles
- Do not add legacy or backward-compat fallbacks for packet fields (sentinel values, coalescing to local state, etc.); server and client stay in sync via
proto/ and joint deploys. See Server/client network sync in multiplayer/.cursor/rules/common-guidlines.mdc.
multiplayer/proto/network.proto is the source of truth for shared packet definitions.
- When a scalar field is semantically "present only in some cases", prefer
optional in proto3 instead of sentinel values such as 0 or empty string.
- Do not hand-edit
multiplayer/mp-client/src/proto/generated/network.ts.
- Never manually generate or substitute protobuf code. If
proto:generate fails, fix the tooling (see below) instead of writing encode/decode logic by hand.
multiplayer/server/Server.cs is the network edge: socket lifetime, frame reads, protobuf parse, and routing.
- Put gameplay packet handling in
multiplayer/server/World/Game/GameWorld.cs, multiplayer/server/World/Game/GameWorldPlayer.cs, or another dedicated server class, not in Server.cs.
- Keep
NetworkManager.ts thin. It should encode, send, decode, and hand off, not own broad gameplay logic.
- When packets represent long-lived client world state such as remote players, prefer keeping the latest authoritative snapshot in
NetworkManager.ts and let scenes hydrate from that state when they become ready, instead of relying on transient pending-event queues.
Workflow
1. Define the protobuf message
Edit multiplayer/proto/network.proto.
- Add the new leaf message type such as
MoveRequest or PlayerMoved.
- Add it to
ClientMessage or ServerMessage under the correct oneof payload.
- Use a new field number. Do not renumber or reuse existing field numbers.
- Keep packet names explicit about direction and intent.
Default pattern:
- client -> server:
SomethingRequest
- server -> client:
SomethingResponse, SomethingUpdate, or another explicit event name
2. Regenerate protobuf code
For the client:
npm run proto:generate
Run that in multiplayer/mp-client/.
For the server:
- C# protobuf code is generated by the server build from
multiplayer/server/Server.csproj.
- Run
dotnet build in multiplayer/server/ after editing multiplayer/proto/network.proto.
3. Wire client-to-server sending
If the client needs to send the new message:
- Encode it through
ClientMessage.encode({ payload: { $case: '...' } }) in NetworkManager.ts or a thin helper it calls.
- Keep the actual payload shape aligned with the generated TypeScript types.
- If a scene, store, or manager triggers the packet, keep the trigger in that domain and let
NetworkManager handle transport.
4. Wire server-side receiving
The current server flow is:
Server.cs reads websocket frames
Server.cs parses ClientMessage
Server.cs routes ClientPacketMessage into the player's current GameWorld
GameWorld switches on message.Message.PayloadCase
GameWorld delegates to GameWorldPlayer or other world-owned logic as needed
When adding a new client packet:
- Do not add gameplay branching to
Server.cs.
- Extend the switch in
GameWorld.HandleClientPacket(...).
- If the behavior is player-specific, prefer a method on
GameWorldPlayer.
- If the behavior mutates shared world state, keep that logic in
GameWorld or a world-owned server type.
5. Wire server-to-client sending
If the server needs to send a new message back:
- Construct a
ServerMessage in world-owned code such as GameWorld or GameWorldPlayer.
- Send it through the existing per-connection callback/path instead of writing directly to the websocket.
- Let
Server.cs keep ownership of socket writes through the outbound queue and send loop.
6. Wire client-side receiving
When the client receives a new server packet in NetworkManager.ts:
- Decode
ServerMessage once in handleMessage.
- Switch on
message.payload?.$case.
- Keep
NetworkManager responsible for transport-level handling and dispatch.
- Forward gameplay/UI consequences to the right place, often via EventBus, a store, or a domain manager.
Do not let NetworkManager become a large gameplay controller.
Validation
After changes:
- Run
npm run proto:generate in multiplayer/mp-client/.
- Run
dotnet build in multiplayer/server/.
- If client TypeScript changed beyond generated code, run the relevant client build or checks.
- Verify both sides agree on the same oneof case name and payload fields.
- Test the full packet path end to end.
Common Pitfalls
- Editing generated TypeScript instead of
multiplayer/proto/network.proto
- Manually writing protobuf encode/decode code when
proto:generate fails instead of fixing the grpc-tools binary install
- Forgetting to regenerate client protobufs
- Putting gameplay handling in
multiplayer/server/Server.cs
- Adding a new server packet but not decoding it in
NetworkManager.ts
- Reusing protobuf field numbers
- Renaming a oneof case on one side without regenerating and updating the other side
- Letting transport code directly mutate gameplay state across boundaries
Minimal Example Pattern
For a new client packet:
- Add
MoveRequest to multiplayer/proto/network.proto under ClientMessage.payload
- Run
npm run proto:generate in multiplayer/mp-client/
- Run
dotnet build in multiplayer/server/
- Encode and send
MoveRequest from the client through NetworkManager
- Handle
ClientMessage.PayloadOneofCase.MoveRequest in GameWorld
- Delegate to
GameWorldPlayer or world logic
For a new server packet:
- Add
PlayerMoved to multiplayer/proto/network.proto under ServerMessage.payload
- Regenerate/build
- Create the
ServerMessage in server world-owned logic
- Decode
message.payload?.$case === 'playerMoved' in NetworkManager.ts
- Forward the result to the correct client system