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)
Live spectating: AtBatBegin, AtBatPose, AtBatPitch, AtBatSwing, AtBatResult
CoopRun.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
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
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).
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).
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.
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).