Net Messages

In short

Every message is a byte array whose first byte is a MsgId. Ids 1-2 are NET TEST’s ping, 10-16 the lobby (NetSession), 30-38 the co-op run (37-38 the match intro) and 40-44 live spectating (CoopRun). Clients only talk to the host; the host relays a batter’s messages to the others. Everything is reliable except AtBatPose. Never renumber an id.

How it works

Encoding (NetWriter / NetReader): little-endian, no reflection. byte, bool (1 byte), ushort (2), int (4), float (4, raw bits), double (8), Vec2 (2 floats), Vec3 (3 floats), string (ushort length + UTF-8). A short message throws FormatException; NetRuntime catches it and logs “Malformed message from player n ignored”.

Routing.

  1. The transport raises Received(from, bytes).
  2. NetRuntime.OnReceived reads the id: Ping is answered with Pong, Pong stores the round trip; everything else goes to NetSession.Handle.
  3. NetSession handles ids below 30 itself and passes 30 and up to GameMessage (→ CoopRun.OnMessage).
  4. CoopRun queues each message for the next frame.

Sending. NetSession.SendToAll (host: every admitted player; client: the host), SendTo(player), SendToAllExcept(msg, player) (host relay). On a client, “to all” means “to the host”, which relays when needed.

Message table

Direction: c = client, h = host, B = the current batter (host or client). “Relay” means the host forwards the same bytes to every player except the sender.

IdNameDirectionReliablePayload (wire order)
1Pingeither (NET TEST)yesdouble send time (realtimeSinceStartupAsDouble)
2Pongeither (NET TEST)yesdouble the Ping’s send time
10Helloc → hyesstring protocol, string version, PlayerInfo
11Welcomeh → cyesbyte player id
12Refuseh → cyesstring reason
13Rosterh → allyesbyte count, PlayerInfo × count
14SetBatterc → hyesstring batterId, byte count, string cosmeticId × count
15StartRunh → allyesint seed, byte count, byte player id × count (batting order), string parkId, byte difficulty
16Leaveeitheryes(none)
30RoundStarth → allyesbyte inning index, byte pool
31TurnStarth → allyesbyte batter, ushort turnIndex, int teamScore, byte outsLeft, byte pool
32PlayFeedB → h → relayyesbyte batter, string headline, byte r, byte g, byte b, int teamScore, bool homeRun, bool wasOut, float distanceFt
33TurnEndB → hyesint scoreGained, byte homeRuns, float longestFt
34RoundEndh → allyesbool cleared, int teamScore, byte outsLeft
35ShopReadyc → hyes(none)
36ReadyStateh → allyesbyte count, byte player id × count
37IntroDonec → hyes(none): this player finished, skipped, or has cutscenes off
38IntroProgressh → allyesbyte done, byte total, byte count, byte player id × count (who is still reading)
40AtBatBeginB → h → relayyesbyte batter, string batUpgradeId, byte outs, float ringRadius, int teamScore
41AtBatPoseB → h → relaynofloat t, Vec2 aim, Vec3 hands, Vec3 direction, float twist
42AtBatPitchB → h → relayyesfloat at, string pitch type id, Vec3 release, Vec3 plateLocation, float speedMph, float releaseTime, float flightTime, float wobble, bool isInZone, byte effect, byte bossEffect, float arcAngle, byte decoy count, Vec3 decoy × count
43AtBatSwingB → h → relayyesfloat pressTime
44AtBatResultB → h → relayyesfloat at, byte outcome, float timingOffset, float timingQuality, float contactQuality, float quality, bool isGreen, byte greenStreak (clamped 0-255), float exitVelocityMph, float launchAngleDeg, float sprayAngleDeg, byte zone, Vec2 hitAim, float curveAccel, float gravityScale, bool madeContact; if madeContact: float contact time, Vec3 ballPosition, Vec3 batDirection, int score, bool isHomeRun, bool isFair, float distanceFt

PlayerInfo (inside Hello and Roster): byte id, string name, string batterId, byte count, string cosmeticId × count, bool connected.

Times in 40-44 are the batter’s Time.time; the spectator converts them (Live Spectating).

Who handles what

  • Hello, SetBatter: only the host acts on them. Welcome, Refuse, Roster, StartRun: only clients.
  • Leave from the host (id 0) ends a client’s session; on the host, the transport’s PeerLeft that follows removes the player.
  • RoundStart, TurnStart, RoundEnd, ReadyState: clients only (the host applies its own copy directly).
  • PlayFeed: the host sets batter to the sender, relays, and shows it; clients show it.
  • TurnEnd, ShopReady, IntroDone: host only. TurnEnd’s home runs and longest are read and ignored.
  • IntroProgress: clients only; the host shows its own copy directly. Both feed the WAITING FOR NAME line (Cutscenes).
  • 40-44: the host relays only while an inning is playing and only from CoopRules.CurrentBatter; the relay keeps the sender’s reliability (pose unreliable, the rest reliable).

Transport framing (below these messages)

  • Local: each UDP packet starts with a kind byte: Connect 1 (+ string HRD-LOCAL-1), Accept 2 (+ byte peer id), Refuse 3 (+ string), Unreliable 4, Reliable 5 (+ int sequence), Ack 6 (+ int sequence), Heartbeat 7, Disconnect 8.
  • Steam: the host’s first message to a client is the 2 bytes [0xFF, peer id]; everything else is a game message.

See Transports.

Numbers

WhatValueWhere to tune
Pose message41 bytes, 30 per secondSpectateMessages.Pose, CoopRun.PoseInterval
Biggest Local packet1200 bytesLocalTransport.MaxPacket
Writer start size256 bytes (grows)NetWriter

In the code

FileWhat it does
Assets/_Project/Scripts/Net/NetMessages.csMsgId, PlayerInfo, NetMessages (10-38 writers / readers, ReadIntroWaiting), SpectateMessages (40-44, ReadDecoys), TurnInfo, PlayInfo
Assets/_Project/Scripts/Net/NetBuffers.csNetWriter, NetReader (Source gives the whole message for relaying)
Assets/_Project/Scripts/Net/NetRuntime.csOnReceived: ping / pong, then the session
Assets/_Project/Scripts/Net/NetSession.csHandle for 10-16, GameMessage for 30+
Assets/_Project/Scripts/Run/CoopRun.csOnMessage, OnSpectateMessage
Assets/_Project/Editor/NetSessionTest.csRoundTrips: every run message reads back what was written
Assets/_Project/Editor/MatchIntroTest.csIntroGate and the IntroProgress round trip

Gotchas

  • Ids 17-29 and 39 are unused; NetSession silently ignores unknown ids below 30.
  • The end-of-game cutscenes send nothing: every machine plays them from its own state.
  • Adding or changing a payload must bump NetSession.Protocol, or an old build reads garbage. See Protocol Versions. PR #46 broke this rule: it added zone, hitAim, curveAccel and gravityScale to AtBatResult and left the protocol at HRD-COOP-7.
  • Ids and counts are single bytes: player ids, inning index, pool and outs must stay under 256.
  • MULTIPLAYER-PLAN.md section 4 lists a different, older message set (ClockPing, RoundStarted, PitchThrown, Snapshot, Kick…). None of those exist; the table above is the code. Clock sync is not built: the spectator’s offset replaces it.