# Open Meet Display

**Draft 0.2** (2026-10-16), open for feedback. No compatibility promise until 1.0. Comments to
jason@2timer.app.

Open Meet Display (OMD) is a small, open message format for live meet displays: scoreboards, LED
and video boards, broadcast and live-stream graphics, and announcer screens. Meet-management
software sends one JSON message that says _this is what should be on the board now_: a start list,
results, team scores, an announcement, or a blank board. Any program can send it, and any display
can show it.

Every message carries the wire value `format: "meet.display.v1"`. The name and the wire value differ
on purpose: the wire value came first, and receivers may already check that exact string, so it
will not change.

This document has two independent parts:

- **[Part 1: Messages](#part-1-messages)** defines the messages: the envelope, the message kinds,
  and how a receiver must treat them. It does not depend on how messages travel.
- **[Part 2: Transport](#part-2-transport)** defines how messages travel. Today that is HTTP POST.

[Security](#security), [Versioning](#versioning), [Testing and conformance](#testing-and-conformance),
and [Sending from 2Timer](#appendix-sending-from-2timer) follow.

The [JSON Schema](/omd/0.2/schemas/meet-display-v1.schema.json) checks the message shape, and every
example here is also published as a file under [/omd/examples/](/omd/examples).

## Part 1: Messages

### Overview

- A **producer** (meet-management software) sends messages, and a **receiver** (your display
  product) renders them.
- Every message is a JSON object and a **complete snapshot** of what one display target should
  show. There are no row patches, deltas, or per-row acknowledgements.
- The receiver decides how things look. Producers send display-ready strings (places, marks, names,
  wind), so a receiver needs no sport-specific formatting logic.
- A receiver can support one kind at a time. A receiver that only renders `results` is useful, as
  long as it accepts and ignores the other kinds (see [Ignore-unknown rules](#ignore-unknown-rules)).
- The running race clock is **not** part of this format. It comes from the timing system on its
  own channel. OMD carries the context around it: start lists, results, team scores, and messages.

### The envelope

Every message, whatever its kind, has these top-level fields. A `clear` message is exactly this
envelope:

```json
{
	"format": "meet.display.v1",
	"sent_at": "2026-11-14T06:42:05.118Z",
	"source": { "name": "2Timer", "connection_id": "6f1c2a9e-4b7d-4e2a-9c51-0d8e3f7a12b4_3" },
	"meet": {
		"id": "6f1c2a9e-4b7d-4e2a-9c51-0d8e3f7a12b4",
		"name": "District Schools Championship",
		"date_start": "2026-11-14",
		"date_end": "2026-11-15",
		"sport": "tf-outdoor"
	},
	"display": {
		"target": "main",
		"mode": "replace",
		"kind": "clear"
	}
}
```

Kind-specific content sits beside the envelope: `event` + `rows` for `start_list` and `results`,
`team_scores` for team standings, `announcement` for messages, and nothing extra for `clear`.

| Field                              | Type             | Notes                                                                                                                                                                                                                                                                 |
| ---------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `format`                           | string           | Always `meet.display.v1`. A future incompatible version will use a different string.                                                                                                                                                                                  |
| `sent_at`                          | string           | ISO 8601 UTC timestamp of when the producer built the message.                                                                                                                                                                                                        |
| `source.name`                      | string           | Producer name, e.g. `2Timer`.                                                                                                                                                                                                                                         |
| `source.version`                   | string, optional | Producer version. 2Timer does not send it today.                                                                                                                                                                                                                      |
| `source.connection_id`             | string, optional | Identifies the producer connection that sent this message. 2Timer sends `<meet.id>_<its connection number>`. Treat it as opaque. It identifies the sender but is not a credential. Reserved for a future way for receivers to request data and name their connection. |
| `meet.id`                          | string, optional | Opaque, stable identity of the meet (2Timer sends its meet UUID). The same meet keeps it across messages and days.                                                                                                                                                    |
| `meet.name`                        | string           | Display-ready meet name.                                                                                                                                                                                                                                              |
| `meet.date_start`, `meet.date_end` | string           | `YYYY-MM-DD`. Equal for a one-day meet.                                                                                                                                                                                                                               |
| `meet.sport`                       | string           | `tf-outdoor`, `tf-indoor`, `xc` (cross country), `run` (road race). Other values may appear; treat as context, never a reason to reject.                                                                                                                              |
| `display.target`                   | string           | Which board or surface this snapshot is for. See below.                                                                                                                                                                                                               |
| `display.mode`                     | string           | Always `replace` in v1.                                                                                                                                                                                                                                               |
| `display.kind`                     | string           | `start_list`, `results`, `team_scores`, `announcement`, or `clear`. Switch on this.                                                                                                                                                                                   |

**`display.target`** is a free-text name chosen by whoever sets up the producer (default `main`).
It lets one receiver drive several surfaces, for example `main`, `field`, `awards`, or `stream`.
Your product decides which names it understands and maps them to boards, layers, or scenes. Tell
your users which target names to enter.

### Snapshot semantics

- **Each message replaces what the target shows.** The latest accepted message for a target is
  what that target should display. A `results` message arriving while a start list is up replaces
  the start list.
- **Never merge rows across messages.** A results message with 3 rows means "show these 3 rows",
  not "add these 3 rows". If a row disappears between messages (a result was deleted or moved), it
  should disappear from the board.
- **Resends are normal.** Producers resend on request and rebuild the current page whenever a
  result or entry changes. Receiving the same content twice must simply re-render it.
- **`clear` blanks the target.** See [`clear`](#clear).
- **Ordering.** Messages normally arrive in the order they were sent, but delivery order is not
  guaranteed (for example, a manual send can overlap an automatic page flip). If that matters to
  your product, you may drop a message whose `sent_at` is older than the one already showing for
  that target.
- **Rows render in the order given.** Producers sort rows (start order, or place order) before
  sending.

### Ignore-unknown rules

These rules let producers add fields and kinds without breaking existing receivers. _Accept_ and
_reject_ map to transport replies in [Part 2](#required-endpoint-behavior).

| You receive                                                                                                                                      | Do this                                                                                                                      |
| ------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| A field you do not recognise, at any level                                                                                                       | Ignore it.                                                                                                                   |
| A `display.kind` you do not support                                                                                                              | Accept the message; change nothing on the board.                                                                             |
| A `display.mode` other than `replace`                                                                                                            | Accept the message; change nothing on the board.                                                                             |
| An unfamiliar value in `meet.sport`, `event.entry_type`, `rows[].status`, `rows[].scoring_role`, `team_scores.scope`, or `announcement.priority` | Accept the message; show the value as plain text or ignore it.                                                               |
| A `display.target` you do not recognise                                                                                                          | Your choice: show it on a default surface, or reject it with an error naming the target so the operator can fix the setting. |
| An optional field that is `null` or missing                                                                                                      | Treat both the same: "not applicable".                                                                                       |
| `format` other than `meet.display.v1`                                                                                                            | Reject.                                                                                                                      |

Why unknown kinds must be accepted: a producer may send start lists before results as a board
follows the meet. A results-only receiver that rejected start lists would show the operator an
error on every event.

### Message kinds

The track and field examples share one invented meet, and the cross country example uses another.
Names, schools, and marks are fictional.

#### `start_list`

Who is in which lane (or flight position) before a race or field event. Sent when the operator
shows a start list, or automatically when an auto-follow board's event is seeded.

```json
{
	"format": "meet.display.v1",
	"sent_at": "2026-11-14T01:58:40.502Z",
	"source": { "name": "2Timer", "connection_id": "6f1c2a9e-4b7d-4e2a-9c51-0d8e3f7a12b4_3" },
	"meet": {
		"id": "6f1c2a9e-4b7d-4e2a-9c51-0d8e3f7a12b4",
		"name": "District Schools Championship",
		"date_start": "2026-11-14",
		"date_end": "2026-11-15",
		"sport": "tf-outdoor"
	},
	"display": {
		"target": "main",
		"mode": "replace",
		"kind": "start_list"
	},
	"event": {
		"id": "42",
		"event_number": "3",
		"event_name": "Girls 100 Meter Dash",
		"round_number": "1",
		"round_name": "Prelims",
		"section_name": "Heat 2",
		"gender": "F",
		"event_code": "100",
		"entry_type": "individual",
		"wind": null
	},
	"rows": [
		{
			"entry_id": "884",
			"start_assignment": "2",
			"bib_number": "318",
			"participant_name": "Tan Mei Ling",
			"affiliation": { "name": "Riverside High School", "short_name": "RHS" },
			"relay_legs": null,
			"seed": "13.02",
			"mark": null,
			"status": null,
			"place": null,
			"wind": null,
			"team_points": null,
			"scoring_role": null,
			"multi_points": null
		},
		{
			"entry_id": "885",
			"start_assignment": "3",
			"bib_number": "205",
			"participant_name": "Nur Aisyah Binti Rahman",
			"affiliation": { "name": "Hillcrest Secondary", "short_name": "HCS" },
			"relay_legs": null,
			"seed": "12.71",
			"mark": null,
			"status": null,
			"place": null,
			"wind": null,
			"team_points": null,
			"scoring_role": null,
			"multi_points": null
		},
		{
			"entry_id": "886",
			"start_assignment": "4",
			"bib_number": "412",
			"participant_name": "Priya Devi Subramaniam",
			"affiliation": { "name": "Lakeview Academy", "short_name": "LVA" },
			"relay_legs": null,
			"seed": "12.48",
			"mark": null,
			"status": null,
			"place": null,
			"wind": null,
			"team_points": null,
			"scoring_role": null,
			"multi_points": null
		},
		{
			"entry_id": "887",
			"start_assignment": "5",
			"bib_number": "127",
			"participant_name": "Siti Hajar Binti Osman",
			"affiliation": { "name": "Eastgate College", "short_name": "EGC" },
			"relay_legs": null,
			"seed": "12.39",
			"mark": null,
			"status": null,
			"place": null,
			"wind": null,
			"team_points": null,
			"scoring_role": null,
			"multi_points": null
		},
		{
			"entry_id": "888",
			"start_assignment": "6",
			"bib_number": "331",
			"participant_name": "Wong Jia Xin",
			"affiliation": { "name": "Riverside High School", "short_name": "RHS" },
			"relay_legs": null,
			"seed": "12.55",
			"mark": null,
			"status": null,
			"place": null,
			"wind": null,
			"team_points": null,
			"scoring_role": null,
			"multi_points": null
		},
		{
			"entry_id": "889",
			"start_assignment": "7",
			"bib_number": "219",
			"participant_name": "Kavitha Raj",
			"affiliation": { "name": "Hillcrest Secondary", "short_name": "HCS" },
			"relay_legs": null,
			"seed": "12.84",
			"mark": null,
			"status": null,
			"place": null,
			"wind": null,
			"team_points": null,
			"scoring_role": null,
			"multi_points": null
		},
		{
			"entry_id": "890",
			"start_assignment": "8",
			"bib_number": "140",
			"participant_name": "Lim Hui Min",
			"affiliation": { "name": "Eastgate College", "short_name": "EGC" },
			"relay_legs": null,
			"seed": null,
			"mark": null,
			"status": null,
			"place": null,
			"wind": null,
			"team_points": null,
			"scoring_role": null,
			"multi_points": null
		}
	]
}
```

Notes:

- Rows arrive in start order (lane, or flight position for field events). Render them in the
  order given.
- `start_assignment` is the display-ready lane or position. Lanes can be empty; there is no row
  for an empty lane (lane 1 and lane 9 above). It is `null` when the athlete has no lane or
  position yet (common in distance races).
- `seed` is the entry mark, display-ready, or `null` when the athlete has none.
- `mark`, `status`, `place`, `wind`, and points are always `null` in a start list.

#### `results`

Places and marks for one heat/flight, one round, or a whole event. Sent when the operator shows
results, or automatically as results are recorded on an auto-follow board.

This example is one heat of a track race. Wind applies to the whole heat: it is sent in
`event.wind` and repeated on each row.

```json
{
	"format": "meet.display.v1",
	"sent_at": "2026-11-14T02:09:13.884Z",
	"source": { "name": "2Timer", "connection_id": "6f1c2a9e-4b7d-4e2a-9c51-0d8e3f7a12b4_3" },
	"meet": {
		"id": "6f1c2a9e-4b7d-4e2a-9c51-0d8e3f7a12b4",
		"name": "District Schools Championship",
		"date_start": "2026-11-14",
		"date_end": "2026-11-15",
		"sport": "tf-outdoor"
	},
	"display": {
		"target": "main",
		"mode": "replace",
		"kind": "results"
	},
	"event": {
		"id": "42",
		"event_number": "3",
		"event_name": "Girls 100 Meter Dash",
		"round_number": "1",
		"round_name": "Prelims",
		"section_name": "Heat 2",
		"gender": "F",
		"event_code": "100",
		"entry_type": "individual",
		"wind": "+1.4"
	},
	"rows": [
		{
			"entry_id": "887",
			"start_assignment": "5",
			"bib_number": "127",
			"participant_name": "Siti Hajar Binti Osman",
			"affiliation": { "name": "Eastgate College", "short_name": "EGC" },
			"relay_legs": null,
			"seed": null,
			"mark": "12.31",
			"status": "OK",
			"place": "1",
			"wind": "+1.4",
			"team_points": null,
			"scoring_role": null,
			"multi_points": null
		},
		{
			"entry_id": "886",
			"start_assignment": "4",
			"bib_number": "412",
			"participant_name": "Priya Devi Subramaniam",
			"affiliation": { "name": "Lakeview Academy", "short_name": "LVA" },
			"relay_legs": null,
			"seed": null,
			"mark": "12.44",
			"status": "OK",
			"place": "2",
			"wind": "+1.4",
			"team_points": null,
			"scoring_role": null,
			"multi_points": null
		},
		{
			"entry_id": "885",
			"start_assignment": "3",
			"bib_number": "205",
			"participant_name": "Nur Aisyah Binti Rahman",
			"affiliation": { "name": "Hillcrest Secondary", "short_name": "HCS" },
			"relay_legs": null,
			"seed": null,
			"mark": "12.69",
			"status": "OK",
			"place": "3",
			"wind": "+1.4",
			"team_points": null,
			"scoring_role": null,
			"multi_points": null
		},
		{
			"entry_id": "888",
			"start_assignment": "6",
			"bib_number": "331",
			"participant_name": "Wong Jia Xin",
			"affiliation": { "name": "Riverside High School", "short_name": "RHS" },
			"relay_legs": null,
			"seed": null,
			"mark": "12.69",
			"status": "OK",
			"place": "3",
			"wind": "+1.4",
			"team_points": null,
			"scoring_role": null,
			"multi_points": null
		},
		{
			"entry_id": "884",
			"start_assignment": "2",
			"bib_number": "318",
			"participant_name": "Tan Mei Ling",
			"affiliation": { "name": "Riverside High School", "short_name": "RHS" },
			"relay_legs": null,
			"seed": null,
			"mark": "13.10",
			"status": "OK",
			"place": "5",
			"wind": "+1.4",
			"team_points": null,
			"scoring_role": null,
			"multi_points": null
		},
		{
			"entry_id": "889",
			"start_assignment": "7",
			"bib_number": "219",
			"participant_name": "Kavitha Raj",
			"affiliation": { "name": "Hillcrest Secondary", "short_name": "HCS" },
			"relay_legs": null,
			"seed": null,
			"mark": null,
			"status": "DNF",
			"place": "DNF",
			"wind": "+1.4",
			"team_points": null,
			"scoring_role": null,
			"multi_points": null
		},
		{
			"entry_id": "890",
			"start_assignment": "8",
			"bib_number": "140",
			"participant_name": "Lim Hui Min",
			"affiliation": { "name": "Eastgate College", "short_name": "EGC" },
			"relay_legs": null,
			"seed": null,
			"mark": null,
			"status": "DQ",
			"place": "DQ",
			"wind": "+1.4",
			"team_points": null,
			"scoring_role": null,
			"multi_points": null
		}
	]
}
```

A field event looks the same. In this whole-event long jump final, `section_name` is `null`
(the message covers the whole round), each row carries the wind of that athlete's best jump, and
scored places carry `team_points`:

```json
{
	"format": "meet.display.v1",
	"sent_at": "2026-11-14T05:31:47.020Z",
	"source": { "name": "2Timer", "connection_id": "6f1c2a9e-4b7d-4e2a-9c51-0d8e3f7a12b4_3" },
	"meet": {
		"id": "6f1c2a9e-4b7d-4e2a-9c51-0d8e3f7a12b4",
		"name": "District Schools Championship",
		"date_start": "2026-11-14",
		"date_end": "2026-11-15",
		"sport": "tf-outdoor"
	},
	"display": {
		"target": "field",
		"mode": "replace",
		"kind": "results"
	},
	"event": {
		"id": "57",
		"event_number": "18",
		"event_name": "Boys Long Jump",
		"round_number": "1",
		"round_name": "Finals",
		"section_name": null,
		"gender": "M",
		"event_code": "LJ",
		"entry_type": "individual",
		"wind": null
	},
	"rows": [
		{
			"entry_id": "1203",
			"start_assignment": "6",
			"bib_number": "512",
			"participant_name": "Muhammad Hafiz Bin Yusof",
			"affiliation": { "name": "Lakeview Academy", "short_name": "LVA" },
			"relay_legs": null,
			"seed": null,
			"mark": "6.92",
			"status": "OK",
			"place": "1",
			"wind": "+1.8",
			"team_points": "10",
			"scoring_role": null,
			"multi_points": null
		},
		{
			"entry_id": "1204",
			"start_assignment": "2",
			"bib_number": "236",
			"participant_name": "Daniel Chong",
			"affiliation": { "name": "Hillcrest Secondary", "short_name": "HCS" },
			"relay_legs": null,
			"seed": null,
			"mark": "6.71",
			"status": "OK",
			"place": "2",
			"wind": "+0.6",
			"team_points": "8",
			"scoring_role": null,
			"multi_points": null
		},
		{
			"entry_id": "1205",
			"start_assignment": "4",
			"bib_number": "355",
			"participant_name": "Arjun Nair",
			"affiliation": { "name": "Riverside High School", "short_name": "RHS" },
			"relay_legs": null,
			"seed": null,
			"mark": "6.58",
			"status": "OK",
			"place": "3",
			"wind": "-0.3",
			"team_points": "6",
			"scoring_role": null,
			"multi_points": null
		},
		{
			"entry_id": "1206",
			"start_assignment": "1",
			"bib_number": "149",
			"participant_name": "Haziq Bin Kamal",
			"affiliation": { "name": "Eastgate College", "short_name": "EGC" },
			"relay_legs": null,
			"seed": null,
			"mark": null,
			"status": "DNS",
			"place": "DNS",
			"wind": null,
			"team_points": null,
			"scoring_role": null,
			"multi_points": null
		}
	]
}
```

A cross country or road race has no lanes, rounds, or wind, so those fields are `null`. This
message is the third page of a finished Varsity Boys 5K, places 17 to 24, on a board that shows
eight rows. Each runner who holds a scoring position carries its `team_points`, and `scoring_role`
says whether those points count for the runner's own team (`scorer`) or only push other teams back
(`displacer`). A position is not a place: runners who hold none are skipped, such as Daniel Teo,
who runs unattached. So Hakim Bin Rosli, 19th, holds position 18, and Ong Kai Xuan, 21st, holds
position 19. Arif Bin Salleh is Riverside's eighth runner, past its displacers, so he holds no
position either. Points never add up to a team score on their own: the race's team standings
arrive as their own `team_scores` message (see below).

```json
{
	"format": "meet.display.v1",
	"sent_at": "2026-10-24T03:52:07.615Z",
	"source": {
		"name": "2Timer",
		"connection_id": "b83e5d17-2c9a-4f60-8e14-7a5c0f9d3b62_1"
	},
	"meet": {
		"id": "b83e5d17-2c9a-4f60-8e14-7a5c0f9d3b62",
		"name": "District Cross Country Championship",
		"date_start": "2026-10-24",
		"date_end": "2026-10-24",
		"sport": "xc"
	},
	"display": {
		"target": "main",
		"mode": "replace",
		"kind": "results"
	},
	"event": {
		"id": "2",
		"event_number": "2",
		"event_name": "Varsity Boys 5K",
		"round_number": null,
		"round_name": null,
		"section_name": null,
		"gender": "M",
		"event_code": "5000",
		"entry_type": "individual",
		"wind": null
	},
	"rows": [
		{
			"entry_id": "231",
			"start_assignment": null,
			"bib_number": "1067",
			"participant_name": "Faris Bin Hamdan",
			"affiliation": {
				"name": "Riverside High School",
				"short_name": "RHS"
			},
			"relay_legs": null,
			"seed": null,
			"mark": "17:41.2",
			"status": "OK",
			"place": "17",
			"wind": null,
			"team_points": "16",
			"scoring_role": "displacer",
			"multi_points": null
		},
		{
			"entry_id": "268",
			"start_assignment": null,
			"bib_number": "1270",
			"participant_name": "Vikram Pillai",
			"affiliation": {
				"name": "Lakeview Academy",
				"short_name": "LVA"
			},
			"relay_legs": null,
			"seed": null,
			"mark": "17:44.9",
			"status": "OK",
			"place": "18",
			"wind": null,
			"team_points": "17",
			"scoring_role": "scorer",
			"multi_points": null
		},
		{
			"entry_id": "255",
			"start_assignment": null,
			"bib_number": "1219",
			"participant_name": "Hakim Bin Rosli",
			"affiliation": {
				"name": "Hillcrest Secondary",
				"short_name": "HCS"
			},
			"relay_legs": null,
			"seed": null,
			"mark": "17:47.5",
			"status": "OK",
			"place": "19",
			"wind": null,
			"team_points": "18",
			"scoring_role": "scorer",
			"multi_points": null
		},
		{
			"entry_id": "277",
			"start_assignment": null,
			"bib_number": "1301",
			"participant_name": "Daniel Teo",
			"affiliation": null,
			"relay_legs": null,
			"seed": null,
			"mark": "17:52.0",
			"status": "OK",
			"place": "20",
			"wind": null,
			"team_points": null,
			"scoring_role": null,
			"multi_points": null
		},
		{
			"entry_id": "246",
			"start_assignment": null,
			"bib_number": "1196",
			"participant_name": "Ong Kai Xuan",
			"affiliation": {
				"name": "Eastgate College",
				"short_name": "EGC"
			},
			"relay_legs": null,
			"seed": null,
			"mark": "17:55.3",
			"status": "OK",
			"place": "21",
			"wind": null,
			"team_points": "19",
			"scoring_role": "displacer",
			"multi_points": null
		},
		{
			"entry_id": "233",
			"start_assignment": null,
			"bib_number": "1072",
			"participant_name": "Lee Wei Jie",
			"affiliation": {
				"name": "Riverside High School",
				"short_name": "RHS"
			},
			"relay_legs": null,
			"seed": null,
			"mark": "18:01.8",
			"status": "OK",
			"place": "22",
			"wind": null,
			"team_points": "20",
			"scoring_role": "displacer",
			"multi_points": null
		},
		{
			"entry_id": "236",
			"start_assignment": null,
			"bib_number": "1078",
			"participant_name": "Arif Bin Salleh",
			"affiliation": {
				"name": "Riverside High School",
				"short_name": "RHS"
			},
			"relay_legs": null,
			"seed": null,
			"mark": "18:06.4",
			"status": "OK",
			"place": "23",
			"wind": null,
			"team_points": null,
			"scoring_role": null,
			"multi_points": null
		},
		{
			"entry_id": "271",
			"start_assignment": null,
			"bib_number": "1275",
			"participant_name": "Suresh Kumar",
			"affiliation": {
				"name": "Lakeview Academy",
				"short_name": "LVA"
			},
			"relay_legs": null,
			"seed": null,
			"mark": "18:09.1",
			"status": "OK",
			"place": "24",
			"wind": null,
			"team_points": "21",
			"scoring_role": "displacer",
			"multi_points": null
		}
	]
}
```

Notes:

- Rows arrive in display order: finishers by place, then non-finishers. Render them in the order
  given.
- Every row has an outcome: a finish (`OK`), or `DNF`, `DNS`, `DQ`, `FS` (false start), or `SCR`. An athlete still
  competing, such as a runner on the course, is not in the message until they finish or are given
  a status, so a board following a race shows the finishers so far.
- `place` is the printed place within what the message covers: within the heat or flight when
  `event.section_name` is set, otherwise within the round. Ties repeat the place and skip the next
  one (`3`, `3`, `5`). For non-finishers, `place` holds the status label (`DNF`, `DQ`, `DNS`), so a
  simple board can print the `place` column as-is.
- Use `status` to decide styling: `OK` means a valid mark.
- `mark` is display-ready: `12.31`, `1:02.47`, `6.92`, or feet-and-inches when the meet uses
  imperial units. Do not parse it.
- `start_assignment` in results is the lane or flight position the athlete competed from, or
  `null` when none was assigned (common in field events and distance races).
- Relay events use the same shape with `event.entry_type: "relay"`; `participant_name` is then
  the relay team name (for example `Riverside High School A`), and `relay_legs` lists the leg
  runners in running order when they are recorded, for example
  `["Tan", "Lee", "Ong", "Raj"]`.

#### `team_scores`

Team standings, one division and gender at a time. There are no top-level `rows`.
`team_scores.scope` says what the standings cover:

- **`meet`**: standings summed across a division's events, as in a track and field meet. There is
  no `event`.
- **`event`**: standings for one race, as in cross country. The message carries that race's
  `event` block, the same fields a start list or results message carries, with round, section, and
  wind `null`. Match `event.id` to show the standings beside the race's results, or title the board
  with `event.event_name`.

Meet standings for one division and gender:

```json
{
	"format": "meet.display.v1",
	"sent_at": "2026-11-15T09:12:30.415Z",
	"source": { "name": "2Timer", "connection_id": "6f1c2a9e-4b7d-4e2a-9c51-0d8e3f7a12b4_3" },
	"meet": {
		"id": "6f1c2a9e-4b7d-4e2a-9c51-0d8e3f7a12b4",
		"name": "District Schools Championship",
		"date_start": "2026-11-14",
		"date_end": "2026-11-15",
		"sport": "tf-outdoor"
	},
	"display": {
		"target": "main",
		"mode": "replace",
		"kind": "team_scores"
	},
	"team_scores": {
		"scope": "meet",
		"division_name": "Under 18",
		"gender": "F",
		"rows": [
			{
				"place": "1",
				"team": { "name": "Riverside High School", "short_name": "RHS" },
				"points": "112"
			},
			{
				"place": "2",
				"team": { "name": "Eastgate College", "short_name": "EGC" },
				"points": "96.5"
			},
			{
				"place": "3",
				"team": { "name": "Hillcrest Secondary", "short_name": "HCS" },
				"points": "71"
			},
			{ "place": "3", "team": { "name": "Lakeview Academy", "short_name": "LVA" }, "points": "71" },
			{ "place": "5", "team": { "name": "Northfield School", "short_name": null }, "points": "18" }
		]
	}
}
```

Standings for one cross country race:

```json
{
	"format": "meet.display.v1",
	"sent_at": "2026-10-24T03:41:18.206Z",
	"source": { "name": "2Timer", "connection_id": "b83e5d17-2c9a-4f60-8e14-7a5c0f9d3b62_1" },
	"meet": {
		"id": "b83e5d17-2c9a-4f60-8e14-7a5c0f9d3b62",
		"name": "District Cross Country Championship",
		"date_start": "2026-10-24",
		"date_end": "2026-10-24",
		"sport": "xc"
	},
	"display": {
		"target": "main",
		"mode": "replace",
		"kind": "team_scores"
	},
	"event": {
		"id": "2",
		"event_number": "2",
		"event_name": "Varsity Boys 5K",
		"round_number": null,
		"round_name": null,
		"section_name": null,
		"gender": "M",
		"event_code": "5000",
		"entry_type": "individual",
		"wind": null
	},
	"team_scores": {
		"scope": "event",
		"division_name": "Varsity",
		"gender": "M",
		"rows": [
			{
				"place": "1",
				"team": { "name": "Riverside High School", "short_name": "RHS" },
				"points": "42"
			},
			{ "place": "2", "team": { "name": "Eastgate College", "short_name": "EGC" }, "points": "57" },
			{
				"place": "3",
				"team": { "name": "Hillcrest Secondary", "short_name": "HCS" },
				"points": "81"
			},
			{ "place": "4", "team": { "name": "Lakeview Academy", "short_name": "LVA" }, "points": "96" }
		]
	}
}
```

| Field                             | Notes                                                                                                                 |
| --------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `event`                           | The race, when `scope` is `event`. Absent when `scope` is `meet`.                                                     |
| `team_scores.scope`               | `meet` (summed across a division's events) or `event` (one race). Other values may appear; see above.                 |
| `team_scores.division_name`       | The division's own name, such as `Varsity` or `Under 18`, or `null` when there is no division.                        |
| `team_scores.gender`              | `M`, `F`, `X`, or `null` for combined standings.                                                                      |
| `rows[].place`                    | Display-ready place; ties repeat.                                                                                     |
| `rows[].team.name` / `short_name` | Full name and abbreviation. `short_name` may be `null`.                                                               |
| `rows[].points`                   | Display-ready total; may include halves (`96.5`). In cross country, low scores win: rows still arrive in place order. |

Not paged: every team in the standings is sent.

#### `announcement`

A message the operator types: awards calls, weather delays, schedule changes.

```json
{
	"format": "meet.display.v1",
	"sent_at": "2026-11-14T07:20:02.733Z",
	"source": { "name": "2Timer", "connection_id": "6f1c2a9e-4b7d-4e2a-9c51-0d8e3f7a12b4_3" },
	"meet": {
		"id": "6f1c2a9e-4b7d-4e2a-9c51-0d8e3f7a12b4",
		"name": "District Schools Championship",
		"date_start": "2026-11-14",
		"date_end": "2026-11-15",
		"sport": "tf-outdoor"
	},
	"display": {
		"target": "main",
		"mode": "replace",
		"kind": "announcement"
	},
	"announcement": {
		"title": "Weather delay",
		"message": "Competition is paused for lightning. Next update at 3:30 PM.",
		"priority": "normal",
		"expires_at": null
	}
}
```

| Field                     | Notes                                                                                                          |
| ------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `announcement.title`      | Short heading, or `null`.                                                                                      |
| `announcement.message`    | Always present, never empty. Plain text; wrap it to fit.                                                       |
| `announcement.priority`   | `normal` or `high`. 2Timer sends `normal` today.                                                               |
| `announcement.expires_at` | ISO 8601 timestamp after which to stop showing it, or `null` (show until replaced). 2Timer sends `null` today. |

#### `clear`

Blank the target. Envelope only, no content fields.

```json
{
	"format": "meet.display.v1",
	"sent_at": "2026-11-14T07:45:00.000Z",
	"source": { "name": "2Timer", "connection_id": "6f1c2a9e-4b7d-4e2a-9c51-0d8e3f7a12b4_3" },
	"meet": {
		"id": "6f1c2a9e-4b7d-4e2a-9c51-0d8e3f7a12b4",
		"name": "District Schools Championship",
		"date_start": "2026-11-14",
		"date_end": "2026-11-15",
		"sport": "tf-outdoor"
	},
	"display": {
		"target": "main",
		"mode": "replace",
		"kind": "clear"
	}
}
```

Show your product's blank, idle, or logo state for that target. A receiver with no blank concept
may accept `clear` and ignore it.

### Event row reference

`start_list` and `results` share one row shape. Every value is a string or `null`; numbers are
never sent as JSON numbers.

| Field              | Start list | Results        | Notes                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| ------------------ | ---------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `participant_name` | always     | always         | The label to display, already formatted. See below.                                                                                                                                                                                                                                                                                                                                                                                                     |
| `bib_number`       | usually    | usually        | Bib or hip number; `null` if none.                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `affiliation`      | usually    | usually        | `{ "name", "short_name" }`, or `null` for unattached. `short_name` may be `null`. Choose by available width.                                                                                                                                                                                                                                                                                                                                            |
| `start_assignment` | yes        | yes            | Lane or flight position, display-ready; `null` when none is assigned.                                                                                                                                                                                                                                                                                                                                                                                   |
| `seed`             | yes        | usually `null` | Entry mark.                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `mark`             | `null`     | yes            | Time, distance, or height, display-ready. A disqualified athlete keeps the mark they made; other non-finishers have none, so `null`.                                                                                                                                                                                                                                                                                                                    |
| `status`           | `null`     | yes            | `OK`, `DNF`, `DNS`, `DQ`, `FS`, `SCR`, `NR` (no result: competed with no valid time, distance, or height). The contract also reserves `EXH` (exhibition); other short codes may appear, so show unknown codes as text.                                                                                                                                                                                                                                  |
| `place`            | `null`     | yes            | Printed place, or the status label for non-finishers. For `NR` it is what a results sheet prints for that event: `NT` (no time), `NM` (no mark), or `NH` (no height).                                                                                                                                                                                                                                                                                   |
| `wind`             | `null`     | wind events    | Signed m/s without unit: `+1.4`, `0.0`, `-0.3`. `NWI` means no wind reading was recorded for a wind-measured event. `null` when wind does not apply.                                                                                                                                                                                                                                                                                                    |
| `team_points`      | `null`     | scored meets   | Points this place carries in team scoring, as a number string such as `10` or `4.5`; `null` when the place scored none. In cross country a team's scorers' points make its score, and its displacers (its runners after the scorers) carry points too, which count only against other teams; `scoring_role` says which. Don't add up rows for a team score: ghost runners score without being rows, and a page shows only some rows. Use `team_scores`. |
| `scoring_role`     | `null`     | cross country  | `scorer` when the runner's points count toward the team score, `displacer` when the runner holds a scoring position after the team's scorers (their points push other teams back but don't count for their own), otherwise `null`. Other values may appear; show unknown ones as text.                                                                                                                                                                  |
| `multi_points`     | `null`     | multi-events   | Combined-event points total.                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `relay_legs`       | `null`     | relays         | Leg runners' names in running order, formatted like `participant_name` (see [the scoreboard connection](#the-scoreboard-connection)). Legs with no runner yet are left out; `null` when no legs are recorded and for non-relay rows.                                                                                                                                                                                                                    |
| `entry_id`         | always     | usually        | Opaque entry identifier. The same athlete or relay team keeps the same `entry_id` across messages while the meet runs, so you can match rows between snapshots (for example to animate a new finisher). Do not store it beyond the meet. `null` for rows that are not one entry, such as a combined relay team-score row.                                                                                                                               |

`event` fields:

| Field                         | Notes                                                                                                                                                                                                                      |
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                          | Opaque event identifier, unique within the meet.                                                                                                                                                                           |
| `event_number`                | Display event number, e.g. `3`.                                                                                                                                                                                            |
| `event_name`                  | Display-ready, e.g. `Girls 100 Meter Dash`.                                                                                                                                                                                |
| `round_number` / `round_name` | e.g. `1` / `Prelims`, `2` / `Finals`. Both `null` when the message is not narrowed to a round.                                                                                                                             |
| `section_name`                | `Heat 2`, `Section 1`, or `Flight 1` when the message is one heat/section/flight; `null` for a whole round or event.                                                                                                       |
| `gender`                      | `M`, `F`, `X` (mixed), or `null`.                                                                                                                                                                                          |
| `event_code`                  | Short code such as `100`, `110H`, `4X400`, `LJ`, `HJ`, `SP`; may be `null`. Useful for choosing a layout (track vs field) without parsing names.                                                                           |
| `entry_type`                  | `individual`, `relay`, or `multi`. Other values may appear.                                                                                                                                                                |
| `wind`                        | In results for one heat of a wind-measured track race, that heat's reading (same format as row `wind`). Otherwise `null`, including field events, where each row carries its own. Reading wind from the rows always works. |

### Row limits and paging

Receivers never paginate. A `start_list` or `results` message holds exactly the rows that should be
on screen now. When an event has more rows than a board can show, the **producer** pages: it sends
each page as an ordinary replacement snapshot, and there are no page-number fields. When a producer
cycles through pages, the next page simply arrives later as a new message.

Pages never mix heats or flights in a start list: a page holds rows from one section, and
`event.section_name` names it.

`team_scores` and `announcement` are not paged. A `team_scores` message contains every team in the
standings, so fit or truncate on your side.

## Part 2: Transport

Every transport carries the Part 1 messages unchanged. HTTP POST is the one defined today. See
[Planned transports](#planned-transports) for what comes next.

### HTTP POST

The producer sends each message as the JSON body of an HTTP `POST` to a small HTTP server that your
receiver runs:

```text
 Producer (meet software, e.g. in a browser)          Your receiver (same computer or network)
 ───────────────────────────────────────────          ──────────────────────────────────────
 operator sends, or a board auto-follows    ──POST──▶  http://<address>:<port>/omd
 the active event                                      body = one Part 1 message
                                            ◀─200───   { "ok": true }
```

Why HTTP: producers that run in a web browser, such as 2Timer, cannot open raw TCP or UDP sockets,
talk to serial ports, or listen for incoming connections. They can make HTTP requests.

- **The producer is the client.** It pushes. It does not poll or hold a connection open.
- **One request per message.** Requests are small (typically 2–10 KB, well under 1 MB).
- **Your receiver chooses its URL.** Document the address, port, and path your receiver listens on;
  the producer's operator enters that whole destination. For a new receiver, use the path `/omd`
  and the port `43339` unless you have a reason not to; matching the defaults saves the operator a
  step. Producers must let the operator change the path, not only the address and port.
- **Other computers.** To accept sends from another computer, listen on the network interface (not
  only `127.0.0.1`) and allow the port through the firewall. See
  [Where the receiver can run](#where-the-receiver-can-run).

Request:

```text
POST /omd HTTP/1.1
Host: 127.0.0.1:43339
Origin: https://manager.2timer.app
Content-Type: application/json

{ ...one OMD message... }
```

- The body is UTF-8 JSON. Names may contain non-ASCII characters.
- No authentication header and no cookies are sent. See [Security](#security).
- Reply as soon as the message is parsed and accepted, and render afterward. 2Timer waits 5
  seconds for a reply before telling the operator the send failed.

### Required endpoint behavior

When the producer is a web page, such as `https://manager.2timer.app`, the browser applies
**CORS**, because your receiver is a different origin. A receiver that does not answer CORS
correctly looks "unreachable" to the operator, even though the request reached your machine.

#### 1. Answer `OPTIONS` (preflight)

The browser sends an `OPTIONS` request before a `POST` because the POST carries
`Content-Type: application/json`. 2Timer's **Test connection** button also sends its own `OPTIONS`.
Answer any `OPTIONS` on your path with:

```text
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://manager.2timer.app
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Content-Type
Access-Control-Max-Age: 86400
```

- **`Access-Control-Allow-Origin` should name the producer origins your operator allows**, such as
  `https://manager.2timer.app`. Echo back the request's `Origin` when it is on your list. Use `*`
  only for testing. See [Security](#security) for why.
- `OPTIONS` must be listed in `Access-Control-Allow-Methods`, or **Test connection** fails.
- **Test connection** treats any `2xx` or `405` reply to its `OPTIONS` as reachable.

#### 2. Accept: reply `200`

For every message you accept, including ones you deliberately ignore under the
[Ignore-unknown rules](#ignore-unknown-rules), reply:

```text
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://manager.2timer.app
Content-Type: application/json

{ "ok": true }
```

- **The CORS header must be on the `POST` response too.** Without it the browser hides the reply
  from the producer, and the operator sees a failed send even though you received the message.
- Producers treat any `2xx` status as success and anything else as a failure. 2Timer does not read
  the response body today, but `{ "ok": true }` is part of this contract so future versions can.

#### 3. Reject: reply `400`

For a body that is not JSON, a `format` other than `meet.display.v1`, or anything else the
Ignore-unknown rules say to reject, reply `400` with a short reason (and the CORS header):

```json
{ "ok": false, "error": "Expected a meet.display.v1 payload." }
```

The producer shows the operator the status, and the board keeps its previous content. Reject only
for real problems the operator can fix, never for a kind you have not implemented yet.

### Where the receiver can run

When the producer runs in a browser, where your receiver runs decides whether the browser lets the
request out.

| Receiver runs on                      | Destination the operator enters                   | Works from                          |
| ------------------------------------- | ------------------------------------------------- | ----------------------------------- |
| The same computer as the producer     | `http://127.0.0.1:<port>/<path>` (or `localhost`) | Chrome                              |
| Another computer on the local network | `http://<that computer's IP>:<port>/<path>`       | Chrome, after one permission prompt |

**Chrome asks once.** A request from a public website to a device on the local network is something
Chrome makes the operator allow. The first time the producer sends to your receiver, Chrome shows a
local-network permission prompt. The operator clicks **Allow** once on that computer, and Chrome
remembers it for that site in that browser profile. If the operator clicks **Block**, sends fail
until they allow local network access again in the address bar's site controls.

**Receivers need nothing extra.** The CORS headers above are enough. No HTTPS certificate and no
special local-network header are needed.

Tested 2026-10-15 in Chrome 155 from `https://manager.2timer.app`: a receiver at the computer's own
network address showed the prompt and answered `200` after **Allow**, and `127.0.0.1` then answered
`200` with no second prompt. Not yet tested: a receiver on a physically separate computer (where its
firewall also matters).

> **Not supported yet: Safari, including iPad, and Firefox (untested).** Safari is expected to
> block requests from an HTTPS page to a plain `http://` address, and it has no local-network
> permission to grant. A small forwarding program on the producer's computer is being considered
> for these browsers. Until then, run browser-based producers in Chrome.

When you test, tell us the browser and version and exactly what happened (prompt shown, blocked,
worked). Messages in the browser developer console (F12 → Console) are the most useful evidence.

### Planned transports

Not defined yet. Each will carry the same Part 1 messages unchanged.

- **WebSocket.** The receiver runs a WebSocket server, and the producer keeps one connection open
  and sends each message as one text frame. The receiver could also answer, for example to
  confirm a message or to ask for what should be showing now. Browsers apply the same
  local-network rules as for HTTP.
- **File.** The producer writes each message to a file in a folder the receiver watches, one file
  per display target. Useful for products that already watch folders.
- **Pull.** The receiver asks for the current message whenever it wants. A browser cannot answer
  requests, so this needs a helper program on the producer's computer.

A receiver that talks back names the producer connection it means with
[`source.connection_id`](#the-envelope).

## Security

OMD over HTTP has no authentication. Plan for that:

- **Any web page can try to post to your receiver.** A browser on the operator's computer, or on
  any computer on the network, can be made to send a request to your receiver by any web page it
  has open. If your receiver answers CORS with `*`, such a page could put its own text on a live
  board or broadcast. Answer CORS only for the producer origins your operator allows, and let the
  operator edit that list.
- **Anyone on the network can send directly.** Tools like `curl` ignore CORS. On a shared venue
  network, consider letting the operator limit which computers may send.
- **`source.connection_id` identifies, it does not authenticate.** It is sent in the clear. Do not
  treat a message as trusted because of it.
- Show what you received. A receiver that logs each message, with its `source` and `sent_at`, makes
  a wrong board easy to explain.

## Versioning

- This document is **Draft 0.2**. Drafts may change anything. 1.0 will be the first version with a
  compatibility promise.
- The wire value `meet.display.v1` stays the same through compatible changes: new optional fields,
  new kinds, and new values in open lists such as `status` or `sport`. Receivers stay compatible by
  following the [Ignore-unknown rules](#ignore-unknown-rules).
- An incompatible change will use a new wire value, such as `meet.display.v2`, and a receiver
  rejects a `format` it does not know.

### Changes in Draft 0.2

- `team_scores.scope` can be `event`: standings for one race, with that race's `event` block. Cross
  country standings use it. `division_name` is the division's own name.
- A `results` message holds only rows with an outcome. An athlete still competing is left out until
  they finish or are given a status.
- `status` can be `FS`: a false start in a timed track race. It is a non-finisher like `DQ`, and
  `place` holds `FS`.
- Cross country results rows carry `team_points` for every runner who holds a scoring position,
  displacers included, and a new row field, `scoring_role`, says `scorer` or `displacer`.

## Testing and conformance

### 1. With `curl`, no producer needed

Save any example from [/omd/examples/](/omd/examples) as `start-list.json`, then imitate the
browser.

Preflight (must return `204` with the CORS headers):

```bash
curl -i -X OPTIONS http://127.0.0.1:43339/omd -H "Origin: https://manager.2timer.app" -H "Access-Control-Request-Method: POST" -H "Access-Control-Request-Headers: content-type"
```

Send (must return `200 {"ok":true}` with `Access-Control-Allow-Origin`):

```bash
curl -i -X POST http://127.0.0.1:43339/omd -H "Origin: https://manager.2timer.app" -H "Content-Type: application/json" --data-binary @start-list.json
```

Also try a made-up kind, and confirm you still answer `200` and leave the board unchanged:

```bash
curl -i -X POST http://127.0.0.1:43339/omd -H "Origin: https://manager.2timer.app" -H "Content-Type: application/json" --data-binary '{"format":"meet.display.v1","sent_at":"2026-11-14T07:45:00.000Z","source":{"name":"Test"},"meet":{"name":"Test","date_start":"2026-11-14","date_end":"2026-11-14","sport":"tf-outdoor"},"display":{"target":"main","mode":"replace","kind":"schedule"}}'
```

### 2. The reference receiver

[`omd-test-receiver.mjs`](/omd/0.2/tools/omd-test-receiver.mjs) is a reference receiver: one Node.js
file with no dependencies (Node 18 or newer). It implements the required endpoint behavior and
records what it receives, so you can compare its replies with yours or capture real messages as
test fixtures. It accepts every kind and renders nothing.

```bash
node omd-test-receiver.mjs --verbose
```

- Listens on `127.0.0.1:43339` and accepts messages at `/omd` and `/api/2timer`.
- Options: `--port 43340`, `--host 0.0.0.0` (accept requests from other computers), `--path /scores`
  (accept only that path), `--origin https://manager.2timer.app` (answer CORS for that origin only
  instead of `*`), and `--json` (print each full message).
- `GET /last` returns the latest message, `GET /requests` the full history, `DELETE /requests`
  clears it, and `GET /health` returns `{ "ok": true }`.

### 3. Check messages against the schema

Validate what you receive, or your own producer's output, against
[`meet-display-v1.schema.json`](/omd/0.2/schemas/meet-display-v1.schema.json) (JSON Schema 2020-12)
with any validator. The schema allows unknown fields and kinds, as receivers must.

### Conformance checklist

Messages (Part 1):

- [ ] Treats each message as a full replacement for its `display.target`, and never merges rows.
- [ ] Accepts and ignores unsupported kinds and modes without changing the board.
- [ ] Ignores unknown fields, and shows unknown `status`, `sport`, and `entry_type` values as text
      or ignores them.
- [ ] Renders rows in the order received and displays string values as sent.
- [ ] Treats `null` and missing optional fields the same way.
- [ ] Handles UTF-8 names.

HTTP POST (Part 2):

- [ ] Documents its address, port, and path (path `/omd` and port `43339` suggested).
- [ ] Answers every `OPTIONS` with `204` and `Access-Control-Allow-Origin`,
      `Access-Control-Allow-Methods: POST, OPTIONS`, and `Access-Control-Allow-Headers: Content-Type`.
- [ ] Answers CORS only for producer origins the operator allows (`*` only while testing).
- [ ] Includes `Access-Control-Allow-Origin` on every `POST` response, success or error.
- [ ] Replies `200 { "ok": true }` within 5 seconds to every accepted message, including ignored
      ones.
- [ ] Replies `400 { "ok": false, "error": "..." }` for non-JSON bodies or another `format`.
- [ ] Reachable from another computer when it is meant to be: listens on the network interface and
      the port is open in the firewall.
- [ ] Tested from a browser-based producer, not only with `curl`, with the browser and result
      reported back.

## Appendix: Sending from 2Timer

How [2Timer](https://2timer.app), the first producer, is set up to send to your receiver.

### The scoreboard connection

In 2Timer the operator opens a meet, goes to **Connections**, and adds **Open Meet Display** under
**Scoreboard**. Tell your users to choose **Open Meet Display**: it sends every kind. The settings
are:

- **Receiver IP address**, **Port**, and **Path** of your receiver. 2Timer sends to
  `http://<IP address>:<Port><Path>`. The IP address is `127.0.0.1` when the receiver runs on the
  same computer. The connection starts with port `43339` and path `/omd`, so a receiver that listens
  there needs only the IP address. If yours listens elsewhere, tell your users your port and path.
- **Display target**: the `display.target` value sent with every message (default `main`). Each
  connection sends to one target, so a venue with two boards sets up two connections.
- **Screens to send**: _Start lists_, _Results_, _Team scores_, and _Announcements_, all on to start.
  The operator can turn some off for a board, and at least one stays on. `clear` is always sent when
  the operator clears the board. Every kind your receiver does not support must still be accepted
  and ignored (see [Ignore-unknown rules](#ignore-unknown-rules)).
- **Name shown on board**: the `participant_name` format for start lists and results: _Last name_,
  _First initial + last name_, _Last name, first initial_, _First name + last initial_,
  _First name + last name_, or _Last name, first name_. It starts at _First name + last name_, which
  keeps names that are not written first-name/surname, such as many Malay and Chinese names, whole.
  Tell your users to choose _Last name_ only for narrow boards. Relay rows always send the relay team
  name, and each `relay_legs` name uses the same format.
- **Rows per page** (default 8, maximum 10) and **When there are more rows than fit**: _Show only the
  top rows_, or _Cycle through all rows_ every N seconds (default 5). So a `start_list` or `results`
  message from 2Timer holds **at most 10 rows**.

### What 2Timer puts in the envelope

- `source.name` is `2Timer`. `source.version` is not sent yet.
- `meet.id` is the meet's stable identity, a UUID that stays the same across days and restores.
- `source.connection_id` is `<meet.id>_<the connection's number in that meet>`. One known limit:
  two computers restored from the same meet file share the meet's id, so a connection each adds
  afterward can end up with the same `connection_id`.

### When a send fails

2Timer tells the operator one of three things: Chrome blocked the request (local network access not
allowed), the receiver could not be reached (not running, wrong address, firewall, or CORS; the
browser does not say which), or `Display receiver responded with <status>.` The board keeps whatever
it showed before.

### End to end

1. Start your receiver (or the reference receiver) on the computer running the browser.
2. In 2Timer, add **Open Meet Display** under **Scoreboard** with IP address `127.0.0.1`, your port
   and path, and a **Display target** your product understands.
3. Press **Test connection**. It sends `OPTIONS` and passes on any `2xx` or `405` reply.
4. Send a start list and then results from the **Scoreboard** page, and check they render. Then
   try team scores, an announcement, and **Clear board** if your receiver supports them.
5. Repeat with the receiver on another computer: enter that computer's IP address, send from
   Chrome, and click **Allow** on the local-network prompt.

## Feedback

This is a draft, and receivers shape it. Write to jason@2timer.app, especially about:

1. Which kinds you will support first, and in what order.
2. Which `display.target` names your product uses (boards, layers, scenes).
3. What your product needs that is not here, for example country or state codes, athlete photos,
   age group, records, the athlete now up in a field event, or per-attempt field marks.
4. Whether you need more than 10 rows per page.
