Add a Net Message

In short

Every message is a byte array whose first byte is a MsgId; the fields follow in a fixed order, written with NetWriter and read back in the same order with NetReader. Pick an id in the right range, add a write and a read method, handle it in NetSession.Handle (ids below 30) or CoopRun.OnMessage (30 and up), and bump NetSession.Protocol so older builds are refused instead of misreading it.

Id ranges (enum MsgId : byte in Net/NetMessages.cs)

IdsUsed forHandled in
1-2Ping, Pong (NET TEST overlay)NetRuntime.OnReceived
10-16Session: Hello, Welcome, Refuse, Roster, SetBatter, StartRun, LeaveNetSession.Handle switch
30-38Co-op run: RoundStart, TurnStart, PlayFeed, TurnEnd, RoundEnd, ShopReady, ReadyState, IntroDone, IntroProgressCoopRun.OnMessage
40-44Live spectating: AtBatBegin, AtBatPose, AtBatPitch, AtBatSwing, AtBatResultCoopRun.OnSpectateMessage

Free on main: 3-9, 17-29, 39, 45-255 (37 and 38 were taken by the match intro in #40). NetSession.Handle passes every id of 30 or more to the GameMessage event (which CoopRun subscribes to); ids from 10 to 29 must be a case in its own switch. Never renumber an id.

Steps

  1. Id. Add it to enum MsgId in Assets/_Project/Scripts/Net/NetMessages.cs, in its range, with a comment saying who sends it to whom:
    ShopReady = 35,   // client -> host: done shopping
  2. Write and read. Add a pair of static methods to NetMessages (session and run) or SpectateMessages (spectating). The writer starts with w.Reset().Byte((byte)MsgId.X); the reader reads the fields in the same order (the id byte is already consumed by the dispatcher):
    public static byte[] RoundEnd(NetWriter w, bool cleared, int teamScore, int outsLeft) =>
        w.Reset().Byte((byte)MsgId.RoundEnd).Bool(cleared).Int(teamScore).Byte((byte)outsLeft).ToArray();
    NetWriter / NetReader (Net/NetBuffers.cs) handle Byte, Bool, UShort, Int, ULong, Float, Double, Vec2, Vec3, String (UTF-8 with a 2-byte length) and raw Bytes; NetReader.Rest() takes what is left. For several fields, a struct with Write / Read keeps both sides together (PlayerInfo, TurnInfo).
  3. Send it through the session (Net/NetSession.cs):
    • SendTo(player, msg): host to one player (a client can only send to the host, id 0).
    • SendToAll(msg, reliable): host to every admitted player; on a client this goes to the host.
    • SendToAllExcept(msg, except, reliable): host relays one player’s message to the others. Use reliable: true unless it is a stream where a lost or late copy doesn’t matter (only AtBatPose is unreliable today).
  4. Handle it. For 30 and up, add a case in CoopRun.OnMessage (Run/CoopRun.cs). Read the fields right away (the reader is only valid during the call), check the role with when IsHost / when !IsHost, then Defer(...) the game change so it runs in order with the rest:
    case MsgId.RoundEnd when !IsHost:
        bool cleared = reader.Bool();
        int score = reader.Int(), left = reader.Byte();
        Defer(() => OnRoundEnd(cleared, score, left));
        break;
    For 10-29, add the case to the switch in NetSession.Handle and raise an event from there.
  5. Bump the protocol. In NetSession, change Protocol and extend its comment:
    public const string Protocol = "HRD-COOP-7"; // 2: pitches carry the park's special ball; 3: 9-inning runs on a chosen map; 4: the match intro; 5: intro reading stops and walk-up; 6: The Phantom's decoy balls; 7: boss balls stack with the map's, curveballs / changeups loop sideways
    becomes "HRD-COOP-8" with 8: <what changed>. Add a line to Protocol Versions.

Check it works

  • Add a round trip to a self-test: NetSessionTest.RoundTrips covers the co-op run messages and SpectateFeedTest.MessagesRoundTrip the spectating ones (also RunStructureTest.StartMessageCarriesTheMap, BallEffectTest.PitchMessageCarriesTheBall and PitchMessageCarriesTheDecoys, and MatchIntroTest for IntroProgress). Write it, read the id, read the fields, check r.Remaining == 0.
  • Tools → Home Run Derby → Net → Run Session Test (lobby end to end over LocalTransport, in-process), Run Co-op Rules Test, Run Spectate Feed Test, Run Match Intro Test, Run Outro Test and Run Local Transport Self-Test. See Testing.
  • Then two real copies: a build plus the Editor on LOCAL, or two PCs on Steam (Steam Test Guide).

Gotchas

  • The protocol string is the only real version check. The host refuses a Hello whose protocol or build version differs (“different game version (host X, you Y)”), but the build version is Application.version, which is 1.0 in Project Settings for every build. Bump Protocol whenever messages or co-op rules change.
  • HRD-LOCAL-1 and HRD-STEAM-1 in the transports are separate, transport-level versions. A new message doesn’t change them.
  • A message that is too short throws FormatException; NetRuntime catches it and logs “Malformed message from player N ignored”. It does not crash, but the message is lost.
  • Lists are written with a byte count, so they cap at 255 items; ids, scores and outs written as Byte cap at 255 too (IntroProgress writes done, total and each waiting id as bytes; AtBatPitch writes the decoy count as a byte).
  • Extending an existing message counts too. #41 added bossEffect, arcAngle and the decoys to AtBatPitch and bumped to HRD-COOP-7. #46 added the contact zone, aim, curve and gravity scale to AtBatResult but did not bump: builds from #41 to #45 and from #46 on both say HRD-COOP-7, join each other, and misread each other’s swing results. The next protocol change should bump past 7 anyway.
  • The host is player 0 and the referee: clients ask, the host decides and broadcasts. On relays, trust from, not a player id inside the message (see the PlayFeed case).