Integrate with SGA in an afternoon
Four steps. That is the whole protocol.

You implement once. You then work with every launch monitor, simulator, display and accessory that also implements it — including ones that do not exist yet.
The whole thing, in two frames#
// The two frames your device sends. The engine sends session.hello first and
// answers with session.manifest; neither needs anything from you but a read.
{
"type": "session.describe",
"identity": { "device_id": "sga:device:ax3-00119423", "vendor": "acme",
"model": "AX-3", "serial": "AX3-00119423",
"firmware": "4.2.1", "attestation_tier": "A0",
"sensors": ["radar_doppler"] },
// A0 = no agreement, no key. Fine to start; see "How far your data counts".
"roles": ["sensor.ball"],
"protocol_version": "1.0",
"built_against_bundle": "2026.09.1",
"provides": [
{ "field": "ball.speed_mps", "provenance": "measured" },
{ "field": "ball.launch_angle_deg", "provenance": "measured" },
{ "field": "ball.launch_direction_deg", "provenance": "measured" },
{ "field": "ball.spin_rpm", "provenance": "measured" },
{ "field": "club.speed_mps", "provenance": "inferred",
"model": { "id": "acme.smash", "version": "2.1" } }
],
"wants": [ { "field": "round.player_up" }, { "field": "origin.lie_surface" } ],
"transports": ["tcp"]
}
{
"type": "record.shot",
"field": "ball.speed_mps",
"ball": { "speed_mps": 64.8, "launch_angle_deg": 12.4,
"launch_direction_deg": -1.7, "spin_rpm": 2810 }
}import json, socket, struct
sock = socket.create_connection(("127.0.0.1", 9711)) # the bay's engine
stream = sock.makefile("rb")
def send(msg): # 4-byte big-endian length, then the JSON
b = json.dumps(msg).encode()
sock.sendall(struct.pack(">I", len(b)) + b)
def recv():
n = struct.unpack(">I", stream.read(4))[0]
return json.loads(stream.read(n))
recv() # session.hello -- the engine speaks first
send({
"type": "session.describe",
"identity": {"device_id": "sga:device:ax3-00119423", "vendor": "acme",
"model": "AX-3", "serial": "AX3-00119423",
"firmware": "4.2.1", "sensors": ["radar_doppler"],
# A0 = no agreement, no key. Fine to start; see
# "How far your data counts" below.
"attestation_tier": "A0"},
"roles": ["sensor.ball"],
"protocol_version": "1.0",
# Optional. Which generation of the field catalog you built against --
# for diagnosis only, and it gates nothing. Omit it if you don't track it.
"built_against_bundle": "2026.09.1",
# What you can provide. Say how you know each value.
"provides": [
{"field": "ball.speed_mps", "provenance": "measured"},
{"field": "ball.launch_angle_deg", "provenance": "measured"},
{"field": "ball.launch_direction_deg", "provenance": "measured"},
{"field": "ball.spin_rpm", "provenance": "measured"},
{"field": "club.speed_mps", "provenance": "inferred",
"model": {"id": "acme.smash", "version": "2.1"}},
],
# What you would like back. Ask for anything; unmet wants are just reported.
"wants": [{"field": "round.player_up"}, {"field": "origin.lie_surface"}],
"transports": ["tcp"],
})
manifest = recv() # who is feeding you what, and what nobody
print(manifest["sources"]) # provides. Sent again if the bay changes.
def on_shot(s): # a shot happened
send({
"type": "record.shot",
# REQUIRED: the field this frame is about. It is what the engine routes
# and attributes on, and a frame without it is refused.
"field": "ball.speed_mps",
"ball": {"speed_mps": s.speed, "launch_angle_deg": s.vla,
"launch_direction_deg": s.hla, "spin_rpm": s.spin},
})
# Run it against the reference bay:
# python participant.pyusing System.Buffers.Binary;
using System.Net.Sockets;
using System.Text.Json;
using var client = new TcpClient("127.0.0.1", 9711); // the bay's engine
var stream = client.GetStream();
void Send(object msg) // 4-byte big-endian length, then the JSON
{
var body = JsonSerializer.SerializeToUtf8Bytes(msg);
var frame = new byte[4 + body.Length];
BinaryPrimitives.WriteUInt32BigEndian(frame, (uint)body.Length);
body.CopyTo(frame, 4);
stream.Write(frame);
}
JsonElement Recv()
{
var header = new byte[4];
stream.ReadExactly(header);
var body = new byte[BinaryPrimitives.ReadUInt32BigEndian(header)];
stream.ReadExactly(body);
return JsonDocument.Parse(body).RootElement.Clone();
}
Recv(); // session.hello -- the engine speaks first
Send(new
{
type = "session.describe",
identity = new { device_id = "sga:device:ax3-00119423", vendor = "acme",
model = "AX-3", serial = "AX3-00119423",
firmware = "4.2.1", sensors = new[] { "radar_doppler" },
// A0 = no agreement, no key. Fine to start; see
// "How far your data counts" below.
attestation_tier = "A0" },
roles = new[] { "sensor.ball" },
protocol_version = "1.0",
// Optional. Which generation of the field catalog you built against --
// for diagnosis only, and it gates nothing. Omit it if you don't track it.
built_against_bundle = "2026.09.1",
// What you can provide. Say how you know each value.
provides = new object[]
{
new { field = "ball.speed_mps", provenance = "measured" },
new { field = "ball.launch_angle_deg", provenance = "measured" },
new { field = "ball.launch_direction_deg", provenance = "measured" },
new { field = "ball.spin_rpm", provenance = "measured" },
new { field = "club.speed_mps", provenance = "inferred",
model = new { id = "acme.smash", version = "2.1" } },
},
// What you would like back. Ask for anything; unmet wants are just reported.
wants = new object[] { new { field = "round.player_up" },
new { field = "origin.lie_surface" } },
transports = new[] { "tcp" },
});
var manifest = Recv(); // who is feeding you what, and what nobody
Console.WriteLine(manifest.GetProperty("sources")); // provides. Sent again if
// the bay changes.
void OnShot(Shot s) => // a shot happened
Send(new
{
type = "record.shot",
// REQUIRED: the field this frame is about. It is what the engine routes
// and attributes on, and a frame without it is refused.
field = "ball.speed_mps",
ball = new { speed_mps = s.Speed, launch_angle_deg = s.LaunchAngle,
launch_direction_deg = s.LaunchDirection, spin_rpm = s.Spin },
});
record Shot(double Speed, double LaunchAngle, double LaunchDirection, int Spin);
// Run it against the reference bay:
// dotnet new console -o participant && cp Program.cs participant/
// cd participant && dotnet runThat is a conformant launch monitor.
The JSON is the standard; the rest is plumbing. Those two frames are what your device has to produce, and they are the same bytes whatever you write them in — which is why they are the default above. The Python and C# options show the same two frames with a socket around them, and both run against the reference bay as they stand.
The plumbing really is that small: a 4-byte big-endian length, then that many bytes of UTF-8 JSON.
00 00 01 63 {"type":"session.describe","identity":{"device_id":"sga:dev...
└─ 355 ──┘ └─ exactly 355 bytes of UTF-8 JSON ───────────────────────────Raw TCP is the binding every engine MUST accept (09 §2.0), so a microcontroller with a TCP stack and a JSON encoder is a fully conformant participant. Everything else in the standard is optional detail on top of it.
Three rules that are not obvious#
1. Units are fixed and live in the field name. ball.speed_mps is always meters per second. There is no units mode to set and no unit tag to send. Convert once, in your adapter, where you know what your sensor produces.
2. Say how you know each value. measured if a sensor observed it, calculated if you computed it from things you measured on that shot, inferred if you modeled it. Nobody is judged for inferred — plenty of excellent hardware infers plenty of fields. Claiming measured for something you modeled is the only failure, and it is what certification checks.
3. Absent is a fine answer. Omit any field you do not have. A shot record with four fields is valid and useful. There is no minimum beyond your device class. Whether a round counts toward a player rating depends on trust, not on how many fields it has (below).
How far your data counts#
A finished result is only as trusted as the lowest level in its chain: the launch monitor, the simulator, and the bay. You start at A0, which is fine for building and for casual play. Becoming a technology partner (free) takes you to A1, where rounds count toward an SGA Player Rating and leagues: you promise not to falsify data or build cheating features, to tell SGA about any way you find that would allow either, and you publish a fingerprint of each release. Signing what you send (A2, A3) unlocks sanctioned events and championships.
The full ladder: How far a result can be trusted.
Next#
| you are | read |
|---|---|
| a launch monitor | Launch monitor integration |
| simulator software | Simulator software integration |
| an accessory, actuator, RFID reader, display or HUD | Accessory and single-purpose device integration |
| hosting tournaments or leagues | ../spec/08-competition.md |
Check yourself before you talk to us. The conformance kit is public, runs offline, and needs no account. From your copy of the repository:
cd sga-ois/conformance
python -m sgaconform path/to/your-descriptor.jsonThe kit checks documents: your descriptor, and a simulator's scorecards. To watch live frames such as record.shot, use the reference bay (Testing your integration).
Full field reference: the field catalog, generated from the current bundle.
Need a field that does not exist? The right route is to work with the community to add it to the common namespace, so every product uses the same name for the same thing (extending the standard). We never want to be the reason you wait, though: your own x-yourvendor. namespace is always available, so you can ship today and move the field into the common namespace once it is agreed.
Testing your integration · Become a technology partner