# Open Meet Display

**Version 1.0.** From 1.0 a minor version only adds and only a major breaks (see
[Versioning](#versioning)). Feedback from anyone building display or meet software is welcome at
[jason@2timer.app](mailto:jason@2timer.app?subject=Open%20Meet%20Display).

## Overview

Open Meet Display (OMD) is a small JSON message format for live meet displays: scoreboards, LED
and video boards, broadcast and live-stream graphics, and announcer screens. Meet software sends
one message that says _this is what should be on the board now_, and the board shows it. One
receiver works with every meet program that sends OMD, so neither side writes a connector per
product:

```text
  Producer ──────── messages ────────▶ Receiver
  (meet software)                      (a board, a graphics system, an announcer screen)
```

- **The producer** is meet-management software. It sends a start list, results, team scores, an
  announcement, or a blank board, whenever the operator sends one or a board follows the meet.
- **The receiver** is your display product. It shows each message on the display target it names.

Three design choices shape the rest:

- **Every message is the whole board.** A message replaces what its target shows. There are no row
  patches, no deltas, and nothing to keep in step.
- **Every value arrives ready to print.** Places, marks, names, and wind come as display strings,
  so a receiver needs no sport-specific formatting.
- **Start with one kind.** A receiver that only shows `results` is a complete receiver, as long as
  it accepts and ignores the other kinds.

OMD is a sibling of [Open Meet Exchange](/omx) (OMX), the format for moving a whole meet from
registration to timing to a results website. The two share names for the same facts, the
`generator` block, version numbers, the `accepts` list, and replies. OMD's shape differs: flat,
display-ready rows, because a board shows what it is sent and keeps no database.

## What OMD is not

- **Not the race clock.** The running clock comes from the timing system on its own channel. OMD
  carries what surrounds it: start lists, results, team scores, and messages.
- **Not a way to hand results to a website.** A message is what one board shows now, not a meet.
  To publish results, use [Open Meet Exchange](/omx).
- **Not a remote control.** A receiver cannot ask the producer to show something or run anything.
  The producer decides what is sent; the receiver decides how it looks.

## Terms

| Term            | Meaning                                                                                                |
| --------------- | ------------------------------------------------------------------------------------------------------ |
| **message**     | One JSON object: a complete snapshot of what one display target should show                            |
| **kind**        | `omd.kind`: `start_list`, `results`, `team_scores`, `announcement`, or `clear`                         |
| **producer**    | The meet software that sends messages                                                                  |
| **receiver**    | The display product that accepts messages and shows them                                               |
| **target**      | `display.target`: which board, layer, or scene a message is for, such as `main` or `field`             |
| **snapshot**    | Every message replaces what its target shows; nothing is merged                                        |
| **row**         | One line on the board: an athlete or relay in a start list or results, or a team in team scores        |
| **page**        | The rows that fit on a board at once. The producer pages; a receiver never does                        |
| **probe**       | A `GET` on the receiver's URL that says which versions it reads and who it is                          |
| **`generator`** | The product that wrote a message or a probe reply: its `id`, `name`, and optional `version`, as in OMX |

## Roles

| Role         | What you do                                                                                               | Typical products                                                                |
| ------------ | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| **Receiver** | Run a small HTTP server, answer the probe, accept messages, and show the kinds your product shows         | Scoreboard and LED controllers, video and broadcast graphics, announcer screens |
| **Producer** | Probe the receiver, send each message to the URL the operator entered, and page the rows to fit the board | Meet-management and timing software                                             |

## Quick start

1. Run an HTTP server on port `43339` that accepts requests at `/omd`.
2. Answer `OPTIONS` with the CORS headers in [Required endpoint behavior](#required-endpoint-behavior).
3. Answer `GET /omd` with the [probe reply](#the-probe):
   `{ "accepts": ["1.0"], "generator": { "id": "yourboard", "name": "YourBoard" } }`.
4. Reply `200 { "ok": true }` to every `POST`. Read `omd.kind`. For `results` (or `start_list`),
   draw `results.rows` (or `start_list.rows`) top to bottom: `place`, `participant_name`,
   `team.short_name`, `result`. For any kind you do not show yet, change nothing.
5. Send it the [examples](/omd/examples) with `curl`, then [send from 2Timer](#end-to-end) at a
   real meet.

## Part 1: Messages

### The envelope

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

```json
{
	"omd": {
		"version": "1.0",
		"kind": "clear",
		"generator": {
			"id": "2t",
			"name": "2Timer",
			"version": "1.0.62"
		},
		"sent_at": "2026-11-14T07:45:00.000Z"
	},
	"meet": {
		"id": "6f1c2a9e-4b7d-4e2a-9c51-0d8e3f7a12b4",
		"name": "District Schools Championship",
		"start_date": "2026-11-14",
		"end_date": "2026-11-15",
		"sport": "tf-outdoor"
	},
	"display": {
		"target": "main",
		"mode": "replace",
		"connection_id": "6f1c2a9e-4b7d-4e2a-9c51-0d8e3f7a12b4_3"
	}
}
```

A kind's content sits under its own name: every message except `clear` carries one block named
exactly as `omd.kind` (`start_list`, `results`, `team_scores`, or `announcement`), and a list of
board lines is always that block's `rows`. Read `message[message.omd.kind]`. `event`, when a message
has one, sits beside the block at the top level, so a race's start list, results, and team scores carry the same `event`.

| Field                         | Required | Notes                                                                                                                                                  |
| ----------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `omd.version`                 | yes      | `MAJOR.MINOR`, such as `1.0`. A 1.0 receiver reads every `1.x` message. See [Versioning](#versioning).                                                 |
| `omd.kind`                    | yes      | `start_list`, `results`, `team_scores`, `announcement`, or `clear`. Switch on this.                                                                    |
| `omd.generator`               | yes      | The product that wrote the message: `id`, a short name it picks for itself and never changes; `name`; and optional `version`. The same shape as OMX's. |
| `omd.sent_at`                 | yes      | When the producer built the message, ISO 8601 UTC with milliseconds.                                                                                   |
| `meet.id`                     | no       | The meet's stable identity. The same meet keeps it across messages and days. Opaque.                                                                   |
| `meet.name`                   | yes      | Display-ready meet name.                                                                                                                               |
| `meet.start_date`, `end_date` | yes      | `YYYY-MM-DD`. Equal for a one-day meet.                                                                                                                |
| `meet.sport`                  | yes      | OMX's sports: `tf-outdoor`, `tf-indoor`, `xc` (cross country), `run` (road race). Open: treat another value as context, never a reason to reject.      |
| `display.target`              | yes      | Which board or surface this snapshot is for. See below.                                                                                                |
| `display.mode`                | yes      | Always `replace` in 1.x.                                                                                                                               |
| `display.connection_id`       | no       | The producer connection that sent the message. Opaque. It names the sender; it is not a credential.                                                    |

**`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. List
them in your [probe reply](#the-probe) so the producer can offer them, and tell your users.

### 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
  disappears 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 re-renders it.
- **`clear` blanks the target.** See [`clear`](#clear).
- **Ordering.** Messages normally arrive in the order they were sent, but delivery order is not
  guaranteed (a manual send can overlap an automatic page flip). If that matters to your product,
  drop a message whose `omd.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 a minor version add fields, kinds, and values without breaking
receivers. _Accept_ and _refuse_ map to the replies in
[Part 2](#required-endpoint-behavior).

| You receive                                                                                                                                                                      | Do this                                                                                                                      |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| A field you do not recognize, at any level                                                                                                                                       | Ignore it.                                                                                                                   |
| An `omd.kind` you do not show                                                                                                                                                    | 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.gender`, `event.entry_type`, `results.rows[].status`, `results.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 recognize                                                                                                                                          | Your choice: show it on a default surface, or refuse it with an error naming the target so the operator can fix the setting. |
| An optional field that is missing, or `null`                                                                                                                                     | Treat both the same: "not applicable". Producers leave such fields out; keep accepting `null` too.                           |
| An `omd.version` whose major you do not read, or no `omd` block                                                                                                                  | Refuse with `422`, naming the versions you read in `accepts`.                                                                |

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 refused start lists would show the operator an
error on every event.

### Message kinds

The examples are invented: the track and field ones share one meet, the cross country ones another.
Each is also a file under [/omd/examples/](/omd/examples).

#### `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 a board that follows the meet reaches a seeded event.

```json
{
	"omd": {
		"version": "1.0",
		"kind": "start_list",
		"generator": {
			"id": "2t",
			"name": "2Timer",
			"version": "1.0.62"
		},
		"sent_at": "2026-11-14T01:58:40.502Z"
	},
	"meet": {
		"id": "6f1c2a9e-4b7d-4e2a-9c51-0d8e3f7a12b4",
		"name": "District Schools Championship",
		"start_date": "2026-11-14",
		"end_date": "2026-11-15",
		"sport": "tf-outdoor"
	},
	"display": {
		"target": "main",
		"mode": "replace",
		"connection_id": "6f1c2a9e-4b7d-4e2a-9c51-0d8e3f7a12b4_3"
	},
	"event": {
		"id": "42",
		"number": "3",
		"name": "Girls 100 Meter Dash",
		"round": "1",
		"round_label": "Prelims",
		"section_name": "Heat 2",
		"gender": "F",
		"code": "100",
		"entry_type": "individual"
	},
	"start_list": {
		"rows": [
			{
				"entry_id": "884",
				"start_assignment": "2",
				"bib": "318",
				"participant_name": "Tan Mei Ling",
				"team": {
					"name": "Riverside High School",
					"short_name": "RHS"
				},
				"seed": "13.02"
			},
			{
				"entry_id": "885",
				"start_assignment": "3",
				"bib": "205",
				"participant_name": "Nur Aisyah Binti Rahman",
				"team": {
					"name": "Hillcrest Secondary",
					"short_name": "HCS"
				},
				"seed": "12.71"
			},
			{
				"entry_id": "886",
				"start_assignment": "4",
				"bib": "412",
				"participant_name": "Priya Devi Subramaniam",
				"team": {
					"name": "Lakeview Academy",
					"short_name": "LVA"
				},
				"seed": "12.48"
			},
			{
				"entry_id": "887",
				"start_assignment": "5",
				"bib": "127",
				"participant_name": "Siti Hajar Binti Osman",
				"team": {
					"name": "Eastgate College",
					"short_name": "EGC"
				},
				"seed": "12.39"
			},
			{
				"entry_id": "888",
				"start_assignment": "6",
				"bib": "331",
				"participant_name": "Wong Jia Xin",
				"team": {
					"name": "Riverside High School",
					"short_name": "RHS"
				},
				"seed": "12.55"
			},
			{
				"entry_id": "889",
				"start_assignment": "7",
				"bib": "219",
				"participant_name": "Kavitha Raj",
				"team": {
					"name": "Hillcrest Secondary",
					"short_name": "HCS"
				},
				"seed": "12.84"
			},
			{
				"entry_id": "890",
				"start_assignment": "8",
				"bib": "140",
				"participant_name": "Lim Hui Min",
				"team": {
					"name": "Eastgate College",
					"short_name": "EGC"
				}
			}
		]
	}
}
```

Notes:

- Rows arrive in start order (lane, or flight position for field events).
- `start_assignment` is the display-ready lane or position, left out when the
  athlete has none yet (common in distance races). There is no row for an empty lane (lanes 1 and 9
  above).
- `seed` is the entry mark, display-ready, and is left out when the athlete has none (Lim Hui Min
  above).
- A start list has no `result`, `status`, `place`, `wind`, or points.

#### `results`

Places and results for one heat or flight, one round, or a whole event. Sent when the operator shows
results, or automatically as results are recorded on a board that follows the meet.

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
{
	"omd": {
		"version": "1.0",
		"kind": "results",
		"generator": {
			"id": "2t",
			"name": "2Timer",
			"version": "1.0.62"
		},
		"sent_at": "2026-11-14T02:09:13.884Z"
	},
	"meet": {
		"id": "6f1c2a9e-4b7d-4e2a-9c51-0d8e3f7a12b4",
		"name": "District Schools Championship",
		"start_date": "2026-11-14",
		"end_date": "2026-11-15",
		"sport": "tf-outdoor"
	},
	"display": {
		"target": "main",
		"mode": "replace",
		"connection_id": "6f1c2a9e-4b7d-4e2a-9c51-0d8e3f7a12b4_3"
	},
	"event": {
		"id": "42",
		"number": "3",
		"name": "Girls 100 Meter Dash",
		"round": "1",
		"round_label": "Prelims",
		"section_name": "Heat 2",
		"gender": "F",
		"code": "100",
		"entry_type": "individual",
		"wind": "+1.4"
	},
	"results": {
		"rows": [
			{
				"entry_id": "887",
				"start_assignment": "5",
				"bib": "127",
				"participant_name": "Siti Hajar Binti Osman",
				"team": {
					"name": "Eastgate College",
					"short_name": "EGC"
				},
				"result": "12.31",
				"place": "1",
				"wind": "+1.4"
			},
			{
				"entry_id": "886",
				"start_assignment": "4",
				"bib": "412",
				"participant_name": "Priya Devi Subramaniam",
				"team": {
					"name": "Lakeview Academy",
					"short_name": "LVA"
				},
				"result": "12.44",
				"place": "2",
				"wind": "+1.4"
			},
			{
				"entry_id": "885",
				"start_assignment": "3",
				"bib": "205",
				"participant_name": "Nur Aisyah Binti Rahman",
				"team": {
					"name": "Hillcrest Secondary",
					"short_name": "HCS"
				},
				"result": "12.69",
				"place": "3",
				"wind": "+1.4"
			},
			{
				"entry_id": "888",
				"start_assignment": "6",
				"bib": "331",
				"participant_name": "Wong Jia Xin",
				"team": {
					"name": "Riverside High School",
					"short_name": "RHS"
				},
				"result": "12.69",
				"place": "3",
				"wind": "+1.4"
			},
			{
				"entry_id": "884",
				"start_assignment": "2",
				"bib": "318",
				"participant_name": "Tan Mei Ling",
				"team": {
					"name": "Riverside High School",
					"short_name": "RHS"
				},
				"result": "13.10",
				"place": "5",
				"wind": "+1.4"
			},
			{
				"entry_id": "889",
				"start_assignment": "7",
				"bib": "219",
				"participant_name": "Kavitha Raj",
				"team": {
					"name": "Hillcrest Secondary",
					"short_name": "HCS"
				},
				"status": "dnf",
				"place": "DNF",
				"wind": "+1.4"
			},
			{
				"entry_id": "890",
				"start_assignment": "8",
				"bib": "140",
				"participant_name": "Lim Hui Min",
				"team": {
					"name": "Eastgate College",
					"short_name": "EGC"
				},
				"status": "dq",
				"place": "DQ",
				"wind": "+1.4"
			}
		]
	}
}
```

A field event looks the same. This long jump final covers the whole round, so there is no `section_name`; each row carries the wind of that athlete's best jump, and scored
places carry `points`:

```json
{
	"omd": {
		"version": "1.0",
		"kind": "results",
		"generator": {
			"id": "2t",
			"name": "2Timer",
			"version": "1.0.62"
		},
		"sent_at": "2026-11-14T05:31:47.020Z"
	},
	"meet": {
		"id": "6f1c2a9e-4b7d-4e2a-9c51-0d8e3f7a12b4",
		"name": "District Schools Championship",
		"start_date": "2026-11-14",
		"end_date": "2026-11-15",
		"sport": "tf-outdoor"
	},
	"display": {
		"target": "field",
		"mode": "replace",
		"connection_id": "6f1c2a9e-4b7d-4e2a-9c51-0d8e3f7a12b4_3"
	},
	"event": {
		"id": "57",
		"number": "18",
		"name": "Boys Long Jump",
		"round": "1",
		"round_label": "Finals",
		"gender": "M",
		"code": "LJ",
		"entry_type": "individual"
	},
	"results": {
		"rows": [
			{
				"entry_id": "1203",
				"start_assignment": "6",
				"bib": "512",
				"participant_name": "Muhammad Hafiz Bin Yusof",
				"team": {
					"name": "Lakeview Academy",
					"short_name": "LVA"
				},
				"result": "6.92",
				"place": "1",
				"wind": "+1.8",
				"points": "10"
			},
			{
				"entry_id": "1204",
				"start_assignment": "2",
				"bib": "236",
				"participant_name": "Daniel Chong",
				"team": {
					"name": "Hillcrest Secondary",
					"short_name": "HCS"
				},
				"result": "6.71",
				"place": "2",
				"wind": "+0.6",
				"points": "8"
			},
			{
				"entry_id": "1205",
				"start_assignment": "4",
				"bib": "355",
				"participant_name": "Arjun Nair",
				"team": {
					"name": "Riverside High School",
					"short_name": "RHS"
				},
				"result": "6.58",
				"place": "3",
				"wind": "-0.3",
				"points": "6"
			},
			{
				"entry_id": "1206",
				"start_assignment": "1",
				"bib": "149",
				"participant_name": "Haziq Bin Kamal",
				"team": {
					"name": "Eastgate College",
					"short_name": "EGC"
				},
				"status": "dns",
				"place": "DNS"
			}
		]
	}
}
```

A cross country or road race has no lanes, rounds, or wind, so those fields are left out. 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 `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.
Positions here already run one behind places, because a runner earlier in the race holds none
(Hakim Bin Rosli, 19th, holds position 18). Daniel Teo runs unattached and holds none, so 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
{
	"omd": {
		"version": "1.0",
		"kind": "results",
		"generator": {
			"id": "2t",
			"name": "2Timer",
			"version": "1.0.62"
		},
		"sent_at": "2026-10-24T03:52:07.615Z"
	},
	"meet": {
		"id": "b83e5d17-2c9a-4f60-8e14-7a5c0f9d3b62",
		"name": "District Cross Country Championship",
		"start_date": "2026-10-24",
		"end_date": "2026-10-24",
		"sport": "xc"
	},
	"display": {
		"target": "main",
		"mode": "replace",
		"connection_id": "b83e5d17-2c9a-4f60-8e14-7a5c0f9d3b62_1"
	},
	"event": {
		"id": "2",
		"number": "2",
		"name": "Varsity Boys 5K",
		"gender": "M",
		"code": "5000",
		"entry_type": "individual"
	},
	"results": {
		"rows": [
			{
				"entry_id": "231",
				"bib": "1067",
				"participant_name": "Faris Bin Hamdan",
				"team": {
					"name": "Riverside High School",
					"short_name": "RHS"
				},
				"result": "17:41.2",
				"place": "17",
				"points": "16",
				"scoring_role": "displacer"
			},
			{
				"entry_id": "268",
				"bib": "1270",
				"participant_name": "Vikram Pillai",
				"team": {
					"name": "Lakeview Academy",
					"short_name": "LVA"
				},
				"result": "17:44.9",
				"place": "18",
				"points": "17",
				"scoring_role": "scorer"
			},
			{
				"entry_id": "255",
				"bib": "1219",
				"participant_name": "Hakim Bin Rosli",
				"team": {
					"name": "Hillcrest Secondary",
					"short_name": "HCS"
				},
				"result": "17:47.5",
				"place": "19",
				"points": "18",
				"scoring_role": "scorer"
			},
			{
				"entry_id": "277",
				"bib": "1301",
				"participant_name": "Daniel Teo",
				"result": "17:52.0",
				"place": "20"
			},
			{
				"entry_id": "246",
				"bib": "1196",
				"participant_name": "Ong Kai Xuan",
				"team": {
					"name": "Eastgate College",
					"short_name": "EGC"
				},
				"result": "17:55.3",
				"place": "21",
				"points": "19",
				"scoring_role": "displacer"
			},
			{
				"entry_id": "233",
				"bib": "1072",
				"participant_name": "Lee Wei Jie",
				"team": {
					"name": "Riverside High School",
					"short_name": "RHS"
				},
				"result": "18:01.8",
				"place": "22",
				"points": "20",
				"scoring_role": "displacer"
			},
			{
				"entry_id": "236",
				"bib": "1078",
				"participant_name": "Arif Bin Salleh",
				"team": {
					"name": "Riverside High School",
					"short_name": "RHS"
				},
				"result": "18:06.4",
				"place": "23"
			},
			{
				"entry_id": "271",
				"bib": "1275",
				"participant_name": "Suresh Kumar",
				"team": {
					"name": "Lakeview Academy",
					"short_name": "LVA"
				},
				"result": "18:09.1",
				"place": "24",
				"points": "21",
				"scoring_role": "displacer"
			}
		]
	}
}
```

Notes:

- Rows arrive in display order: finishers by place, then non-finishers.
- Every row has an outcome: a `place`, or a `status` (`dnf`, `dns`, `dq`, `fs`, `scr`, or `nr`). A
  row with a place carries no status. 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. Style rows by `status`.
- `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 a row with a status, `place` holds what a results sheet prints for it
  (`DNF`, `DQ`, `DNS`, and `NT`, `NM`, or `NH` for `nr`), so a simple board can print the `place`
  column as it is.

- `result` is display-ready: `12.31`, `1:02.47`, `6.92`, or feet and inches when the meet uses
  imperial units. Do not parse it.

- 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, in `team_scores.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, without round, section,
  or wind. Match `event.id` to show the standings beside the race's results, or title the board with
  `event.name`.

Meet standings for one division and gender:

```json
{
	"omd": {
		"version": "1.0",
		"kind": "team_scores",
		"generator": {
			"id": "2t",
			"name": "2Timer",
			"version": "1.0.62"
		},
		"sent_at": "2026-11-15T09:12:30.415Z"
	},
	"meet": {
		"id": "6f1c2a9e-4b7d-4e2a-9c51-0d8e3f7a12b4",
		"name": "District Schools Championship",
		"start_date": "2026-11-14",
		"end_date": "2026-11-15",
		"sport": "tf-outdoor"
	},
	"display": {
		"target": "main",
		"mode": "replace",
		"connection_id": "6f1c2a9e-4b7d-4e2a-9c51-0d8e3f7a12b4_3"
	},
	"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"
				},
				"points": "18"
			}
		]
	}
}
```

Standings for one cross country race:

```json
{
	"omd": {
		"version": "1.0",
		"kind": "team_scores",
		"generator": {
			"id": "2t",
			"name": "2Timer",
			"version": "1.0.62"
		},
		"sent_at": "2026-10-24T03:41:18.206Z"
	},
	"meet": {
		"id": "b83e5d17-2c9a-4f60-8e14-7a5c0f9d3b62",
		"name": "District Cross Country Championship",
		"start_date": "2026-10-24",
		"end_date": "2026-10-24",
		"sport": "xc"
	},
	"display": {
		"target": "main",
		"mode": "replace",
		"connection_id": "b83e5d17-2c9a-4f60-8e14-7a5c0f9d3b62_1"
	},
	"event": {
		"id": "2",
		"number": "2",
		"name": "Varsity Boys 5K",
		"gender": "M",
		"code": "5000",
		"entry_type": "individual"
	},
	"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). Open.                                               |
| `team_scores.division_name`                   | The division's own name, such as `Varsity` or `Under 18`. Left out when there is no division.                         |
| `team_scores.gender`                          | Whose standings these are, always present: `M`, `F`, or `X` for one gender, `C` for combined.                         |
| `team_scores.rows[].place`                    | Display-ready place; ties repeat.                                                                                     |
| `team_scores.rows[].team.name` / `short_name` | Full name and what fits on a narrow board. `short_name` may be missing.                                               |
| `team_scores.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
{
	"omd": {
		"version": "1.0",
		"kind": "announcement",
		"generator": {
			"id": "2t",
			"name": "2Timer",
			"version": "1.0.62"
		},
		"sent_at": "2026-11-14T07:20:02.733Z"
	},
	"meet": {
		"id": "6f1c2a9e-4b7d-4e2a-9c51-0d8e3f7a12b4",
		"name": "District Schools Championship",
		"start_date": "2026-11-14",
		"end_date": "2026-11-15",
		"sport": "tf-outdoor"
	},
	"display": {
		"target": "main",
		"mode": "replace",
		"connection_id": "6f1c2a9e-4b7d-4e2a-9c51-0d8e3f7a12b4_3"
	},
	"announcement": {
		"title": "Weather delay",
		"message": "Competition is paused for lightning. Next update at 3:30 PM.",
		"priority": "normal"
	}
}
```

| Field                     | Notes                                                                                    |
| ------------------------- | ---------------------------------------------------------------------------------------- |
| `announcement.title`      | Short heading. Optional.                                                                 |
| `announcement.message`    | Always present, never empty. Plain text; wrap it to fit.                                 |
| `announcement.priority`   | `normal` or `high`. Open.                                                                |
| `announcement.expires_at` | ISO 8601 timestamp after which to stop showing it. Missing means show it until replaced. |

#### `clear`

Blank the target. Envelope only, no content fields; the example is under
[The envelope](#the-envelope). Show your product's blank, idle, or logo state for that target. A
receiver with no blank state may accept `clear` and ignore it.

### Event row reference

`start_list.rows` and `results.rows` share one row shape. Every value is a string (numbers are never sent as JSON
numbers), except `team`, an object, and `relay_legs`, an array of strings. A field with no value is
left out. `participant_name` is in every row, and `entry_id` is in every start list row.

Where OMX carries a mark as `{ value, unit, display }`, OMD sends only the `display` string, under
the same name (`seed`, `result`; `wind` for OMX's `wind_mps`).

In the Start list and Results columns: _always_ means every row has it; _yes_ and _usually_ mean it
is there when it has a value; _no_ means it is never sent in that kind; anything else names the rows
that carry it.

| Field              | Start list | Results       | Notes                                                                                                                                                                                                                                                                        |
| ------------------ | ---------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `participant_name` | always     | always        | The label to display, already formatted.                                                                                                                                                                                                                                     |
| `bib`              | usually    | usually       | Bib or hip number.                                                                                                                                                                                                                                                           |
| `team`             | usually    | usually       | `{ "name", "short_name" }`; missing for an unattached athlete. `short_name` is what fits on a narrow board, such as the team's code, and may be missing. Choose by available width.                                                                                          |
| `start_assignment` | yes        | yes           | Lane or flight position, display-ready; missing when none is assigned.                                                                                                                                                                                                       |
| `seed`             | yes        | usually not   | Entry mark.                                                                                                                                                                                                                                                                  |
| `result`           | no         | yes           | Time, distance, or height, display-ready. A disqualified athlete keeps the result they made; other non-finishers have none.                                                                                                                                                  |
| `status`           | no         | yes           | Left out when the row has a place. Otherwise one of the [statuses](#statuses). Open: show an unknown value as text.                                                                                                                                                          |
| `place`            | no         | yes           | Printed place, or what a results sheet prints for a row with a status.                                                                                                                                                                                                       |
| `wind`             | no         | 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.                                                                                                                                                          |
| `points`           | no         | scored places | Team points this place carries, as a number string such as `10` or `4.5`. In cross country, `scoring_role` says whose team they count for. Never add rows into a team score (a page shows only some rows); use `team_scores`.                                                |
| `scoring_role`     | no         | 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. Open.                                                                                                                          |
| `multi_points`     | no         | multi-events  | Combined-event points total.                                                                                                                                                                                                                                                 |
| `relay_legs`       | no         | relays        | Leg runners' names in running order, formatted like `participant_name`. Legs with no runner yet are left out, and so is the field when no legs are recorded.                                                                                                                 |
| `entry_id`         | always     | usually       | Opaque entry identifier. The same athlete or relay 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. Missing for rows that are not one entry. |

`event` fields:

| Field                   | Notes                                                                                                                                                                                                                                             |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                    | Opaque event identifier, unique within the meet.                                                                                                                                                                                                  |
| `number`                | Display event number, such as `3`.                                                                                                                                                                                                                |
| `name`                  | Display-ready, such as `Girls 100 Meter Dash`.                                                                                                                                                                                                    |
| `code`                  | The event's code in [OMX's event table](/omx/spec#sports-and-event-codes), such as `100`, `110H`, `4X400`, `LJ`, `HJ`, or `SP`. Left out when OMX has no code for the event. Useful for choosing a layout (track or field) without parsing names. |
| `round` / `round_label` | Such as `1` / `Prelims`, `2` / `Finals`. Both missing 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, or flight; missing for a whole round or event.                                                                                                                        |
| `gender`                | `M`, `F`, `X` (non-binary), or `C` (everyone, such as a mixed relay), as in OMX; missing when the producer does not know it. Open.                                                                                                                |
| `entry_type`            | `individual`, `relay`, or `multi`. Open.                                                                                                                                                                                                          |
| `wind`                  | In results for one heat of a wind-measured track race, that heat's reading (same format as row `wind`). Otherwise missing, including field events, where each row carries its own. Reading wind from the rows always works.                       |

#### Statuses

A row's `status` uses OMX's codes and meanings. A row with a place has none.

| `status` | Meaning                                                     | OMX status | `place` prints         |
| -------- | ----------------------------------------------------------- | ---------- | ---------------------- |
| `dnf`    | Did not finish                                              | `dnf`      | `DNF`                  |
| `dns`    | Did not start                                               | `dns`      | `DNS`                  |
| `dq`     | Disqualified; keeps the result it was recorded with         | `dq`       | `DQ`                   |
| `fs`     | False start in a timed track race; a non-finisher like `dq` | `fs`       | `FS`                   |
| `scr`    | Scratched before competing                                  | `scr`      | `SCR`                  |
| `nr`     | No result: competed with no valid time, distance, or height | `nr`       | `NT`, `NM`, or `NH`    |
| `exh`    | Reserved for exhibition                                     | (none)     | as the producer prints |

### 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.

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 is the one defined in 1.0. See
[Planned transports](#planned-transports) for what may come 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)
 ───────────────────────────────────────────          ──────────────────────────────────────
 asks what you read                         ──GET───▶  http://<address>:<port>/omd
                                            ◀─200───   { "accepts": ["1.0"], "generator": … }
 operator sends, or a board follows the     ──POST──▶  http://<address>:<port>/omd
 meet                                                   body = one Part 1 message
                                            ◀─200───   { "ok": true }
```

Why JSON over HTTP, and not gRPC, protobuf, or a socket protocol:

- **Producers run in browsers.** A web page cannot open raw TCP or UDP sockets, talk to serial
  ports, or listen for incoming connections. It cannot speak native gRPC either: gRPC-Web needs a
  proxy beside the receiver, one more thing to install and keep running at a venue. A web page can
  make HTTP requests.
- **Size is not the limit.** A message is a few KB, and a busy meet sends a few a minute, so a
  binary encoding saves nothing a board would notice.
- **Anyone can read it.** A message can be sent with `curl`, read in a log, and checked against the
  JSON Schema. The [TypeScript and Python types](#files) give typed code without a code generator.
- **The transport can change without the message.** A receiver whose system takes gRPC or another
  protocol can run a small bridge that accepts OMD over HTTP and passes each message on. A later
  transport carries the same Part 1 messages unchanged (see
  [Planned transports](#planned-transports)).

How HTTP delivery works:

- **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 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 within 5 seconds, as soon as the message is parsed and accepted,
  and render afterward. Producers wait a few seconds before telling the operator the send failed;
  2Timer waits 5.

### The probe

Before it sends, a producer asks the receiver what it reads. The receiver answers `GET` on the same
URL it takes messages on, with `200` and:

```json
{
	"accepts": ["1.0"],
	"generator": {
		"id": "exampleboard",
		"name": "ExampleBoard",
		"version": "2.4"
	},
	"targets": ["main", "field", "stream"]
}
```

| Field       | Required | Meaning                                                                                                                                         |
| ----------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `accepts`   | yes      | The versions you read, `MAJOR.MINOR`, as in OMX. `["1.0"]` reads 1.0 and every later `1.x`.                                                     |
| `generator` | yes      | Who answered: your product's `id` (a short name you pick once and never change), `name`, and optional `version`. The same shape as a message's. |
| `targets`   | no       | The `display.target` names you understand, so the producer can offer them instead of the operator typing one. You may take others too.          |

- **Every 1.0 receiver answers the probe.** That is how a producer tells a 1.0 receiver from one
  built against a draft of this format: a receiver that answers is 1.0 or later.
- **The status code says whether you are ready.** `200` with the reply above when you are ready to
  show messages; `503` with `Retry-After` when you are running but cannot show anything yet.
- **A probe changes nothing on the board.** It is never a message.
- **CORS as for `POST`.** The reply carries `Access-Control-Allow-Origin`. A plain `GET` needs no
  preflight, and Chrome's local-network prompt covers it as it does a send.
- **Later versions only add fields to the reply.**
- **A producer sends the newest version both of you read.** A receiver that does not answer, or
  answers with something that is not a probe reply (a `404`, an HTML page, JSON with no `accepts`),
  was built before 1.0; a producer may send it the draft message it was built against, or 1.0.

### 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`. A producer's connection test may send its own `OPTIONS` too.
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: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type
Access-Control-Max-Age: 86400
Vary: Origin
```

- **`Access-Control-Allow-Origin` names the producer origins your operator allows**, such as
  `https://manager.2timer.app`. Echo back the request's `Origin` when it is on your list, and send
  `Vary: Origin` whenever you echo it, so a cache never serves one site's answer to another. Use `*`
  only for testing. See [Security](#security) for why.
- `OPTIONS` and `GET` are listed in `Access-Control-Allow-Methods`.

#### 2. Answer `GET` (the probe)

Reply `200` with the [probe reply](#the-probe) and the CORS header:

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

{ "accepts": ["1.0"], "generator": { "id": "yourboard", "name": "YourBoard" } }
```

#### 3. Accept: reply `200` or `204`

For every message you accept, including ones you deliberately ignore under the
[Ignore-unknown rules](#ignore-unknown-rules), reply `200` or `204`, with the CORS header:

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

{ "ok": true }
```

- **The CORS header is 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 read the status code: any `2xx` is success. A body is optional, and `{ "ok": true }` is
  a good one.

#### 4. Refuse: reply `400` or `422`

Refuse only for problems the operator can fix, never for a kind you do not show yet. Reply with the CORS header and an optional body naming what was wrong:

| Status | When                                                                           |
| ------ | ------------------------------------------------------------------------------ |
| `400`  | The body is not JSON                                                           |
| `422`  | No `omd` block, an `omd.version` major you do not read, or a target you refuse |

```json
{ "accepts": ["1.0"], "errors": [{ "path": "/omd/version", "message": "2.0 is not read here" }] }
```

The producer shows the operator the status (and the message, when there is one), and the board keeps
its previous content.

### 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.** Chrome asks the operator before a public website talks to a device on the
local network. The first time the producer reaches 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**, requests 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.

**Use Chrome for browser-based producers.** Safari (including iPad) does not let an HTTPS page reach
a plain `http://` address, and Firefox is untested. Nothing about your receiver would change if a helper for those browsers comes later.

If something does not connect, tell us the browser and version and what happened. The browser
developer console (F12 → Console) usually says why.

### Planned transports

Not defined in 1.0. Each would 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.
- **File.** The producer writes each message to a file in a folder the receiver watches, one file
  per display target.

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

## Security

OMD over HTTP is meant for a venue's own network and has no login or token, like most scoreboard
protocols, so a receiver names the producer sites it allows instead of answering any origin (`*`).
These habits keep a live board showing only what the operator sent:

- **Allow your producers by origin.** Answer CORS for the producer sites your operator uses, such
  as `https://manager.2timer.app`, rather than `*`, and let the operator edit that list. Then other
  web pages open in a browser on the network cannot post to your board.
- **Optionally limit senders.** Tools like `curl` ignore CORS, so on a shared venue network you may
  let the operator choose which computers can send.
- **Treat `display.connection_id` as a label.** It tells you which connection sent a message, not
  that the sender is trusted.
- **Log what you receive.** Keeping each message with its `omd.generator` and `omd.sent_at` makes a
  surprising board easy to explain.

## Not in this version

Each of these is a known gap, left out until a receiver needs it. Each can be added in a minor
version without changing what anything already in the format means:

- **Per-attempt field marks**, and the athlete now up in a field event.
- **Photos, records, country codes, and age group** on a row.
- **A results status** (Unofficial, Under Review, Official) on a results message.
- **A page number and page count.** The producer pages; a receiver sees each page as a snapshot.
- **An event's adaptive form**, such as seated or wheelchair, which OMX carries as
  `adaptive.mode`. The event's `name` says it; its `code` is the ordinary event's.
- **Which kinds a board shows**, in the probe reply. A receiver accepts and ignores kinds it does not
  show.
- **WebSocket and File transports** (see [Planned transports](#planned-transports)).
- **A helper for Safari and iPad**, so a browser that cannot reach a plain `http://` address can
  still send.

## Versioning

- `omd.version` is `MAJOR.MINOR`.
- Minor versions only add: optional fields, kinds, and values in open lists. A receiver reads any
  minor within a major it implements and ignores what it does not know.
- Major versions may break. A receiver that meets a major it does not read refuses the message with
  `422` and `accepts`, rather than showing what it half-understands.
- One schema URL per major, republished in place with each minor.
- A receiver that `accepts` `1.0` is sent any `1.x`.

### Open and closed lists

Every list of values is either closed or open, and an open one says what a receiver does with a
value it does not know.

**Closed lists** change only in a major version: `display.mode` (`replace`).

**Open lists** gain values in minor versions:

| Open list                     | A value the receiver does not know                  |
| ----------------------------- | --------------------------------------------------- |
| `omd.kind`                    | Accept the message; change nothing on the board     |
| `meet.sport`                  | Show it as text, or ignore it                       |
| `event.gender`                | Show it as text, or ignore it                       |
| `event.entry_type`            | Show it as text, or ignore it                       |
| `event.code`                  | Choose a layout by `name`; never refuse the message |
| `results.rows[].status`       | Show it as text; treat the row as having no place   |
| `results.rows[].scoring_role` | Show it as text, or ignore it                       |
| `team_scores.scope`           | Show the standings without labeling what they cover |
| `announcement.priority`       | Treat as `normal`                                   |

`display.target` names are the receiver's own, not a list of the format's (see
[The envelope](#the-envelope)).

### Draft receivers

Receivers built against Draft 0.1 to 0.3 read messages with `"format": "meet.display.v1"` and no
`omd` block, and do not answer the probe. A producer that still serves them sends the draft message
to a receiver that gives no valid probe answer. The drafts' raw files stay published at
[/omd/0.2/](/omd/0.2/spec.md) and [/omd/0.3/](/omd/0.3/spec.md).

### Pinned versions

- Every version's raw files stay at `/omd/<version>/`, for example
  [`/omd/1.0/spec.md`](/omd/1.0/spec.md), so an implementation can cite and re-check exactly what
  it was built against. The unversioned URLs always serve the newest version.

### Changelog

**1.0.** The first published version. What changed from Draft 0.3:

- **The envelope is an `omd` block, like OMX's `omx` block.** `omd.version`, `omd.kind`,
  `omd.generator`, and `omd.sent_at` replace `format`, `display.kind`, `source`, and `sent_at`.
  `source.connection_id` is now `display.connection_id`.
- **OMX's names for the same facts:**

  | Draft 0.3                                  | 1.0                             |
  | ------------------------------------------ | ------------------------------- |
  | `format: "meet.display.v1"`                | `omd.version: "1.0"`            |
  | `display.kind`                             | `omd.kind`                      |
  | `source.name`, `source.version`            | `omd.generator.name`, `version` |
  | `sent_at`                                  | `omd.sent_at`                   |
  | `source.connection_id`                     | `display.connection_id`         |
  | `meet.date_start`, `date_end`              | `meet.start_date`, `end_date`   |
  | `event_number`, `event_name`, `event_code` | `event.number`, `name`, `code`  |
  | `round_number`, `round_name`               | `event.round`, `round_label`    |
  | `bib_number`                               | `bib`                           |
  | `affiliation`                              | `team`                          |
  | `mark`                                     | `result`                        |
  | `team_points`                              | `points`                        |

- **Statuses are OMX's lowercase codes**, and a row with a place has none: `OK` is gone.
- **One letter per gender, as in OMX**: `M`, `F`, `X` (non-binary), and `C` for a mixed event or
  combined standings; `X` now means non-binary. `team_scores.gender` is always present. Changed in
  1.0 in place on 2026-11-10, before any receiver read 1.0 (it was `mixed`, and left out for
  combined standings).
- **`event.code` is OMX's event code**, left out when OMX has none.
- **The probe**: a receiver answers `GET` with `accepts` and `generator`.
- **Replies are OMX's**: accept with `200` or `204`; refuse with `400` or `422` and an optional
  `accepts` and `errors` body; another major or no `omd` block is `422`.
- **`Vary: Origin`** when a receiver echoes the request's origin.
- **A kind's content sits under its own name.** A start list's and results' rows are
  `start_list.rows` and `results.rows`, as team standings' were already `team_scores.rows`; Draft
  0.3 sent them as top-level `rows`. Changed in 1.0 in place on 2026-11-09, the day 1.0 was
  published and before any receiver read it.

**Draft 0.3.** A field with no value is left out instead of sent as `null`. A Quick start, and
plainer wording.

**Draft 0.2.** `team_scores.scope` can be `event`, with that race's `event` block, for cross country
standings. A `results` message holds only rows with an outcome. `status` can be `FS`. Cross country
rows carry their scoring position's points, and `scoring_role` says `scorer` or `displacer`.

**Draft 0.1.** The first draft.

## Testing and conformance

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

Download [`start-list.json`](/omd/examples/start-list.json) (or any example), then send what a
browser sends.

Probe (must return `200` with `accepts` and `generator`, and `Access-Control-Allow-Origin`):

```bash
curl -i http://127.0.0.1:43339/omd -H "Origin: https://manager.2timer.app"
```

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` or `204` 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 '{"omd":{"version":"1.0","kind":"schedule","generator":{"id":"test","name":"Test"},"sent_at":"2026-11-14T07:45:00.000Z"},"meet":{"name":"Test","start_date":"2026-11-14","end_date":"2026-11-14","sport":"tf-outdoor"},"display":{"target":"main","mode":"replace"}}'
```

### 2. The reference receiver

[`omd-test-receiver.mjs`](/omd/tools/omd-test-receiver.mjs) is a reference receiver: one Node.js
file with no dependencies (Node 18 or newer). It answers the probe, 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 at `/api/2timer`, the path 2Timer's older scoreboard connection uses.
- 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 `*`), `--targets main,field` (list those targets in the probe reply), `--draft` (behave
  like a receiver built against Draft 0.3: no probe answer, and messages carry
  `"format": "meet.display.v1"`), 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/schemas/meet-display-v1.schema.json) (JSON Schema 2020-12)
with any validator, or run [`omd-check.mjs`](/omd/tools/omd-check.mjs)
(`node omd-check.mjs message.json`, or `--probe reply.json`), which also names the conformance test
each problem fails. Check your probe reply against
[`probe-reply-v1.schema.json`](/omd/schemas/probe-reply-v1.schema.json). The schemas allow unknown
fields and kinds, as receivers must, so a misspelled key passes too: check names against the
[field reference](/omd/reference). The [test page](/omd/test) sends every conformance fixture to
your receiver from your browser.

### Checklist

Work through this before you ship. You do not need all of it to start: the
[Quick start](#quick-start) is enough to show results. The [conformance tests](/omd/conformance)
give each line a check and a pass condition.

A receiver:

- [ ] Answers the probe with `200`, `accepts`, and `generator`, and the CORS header (OMD-C20,
      OMD-C21)
- [ ] Answers every `OPTIONS` with `204`, `Access-Control-Allow-Origin`,
      `Access-Control-Allow-Methods: GET, POST, OPTIONS`, and `Access-Control-Allow-Headers: Content-Type`
      (OMD-C12)
- [ ] Answers CORS only for producer origins the operator allows (`*` only while testing), with
      `Vary: Origin` when it echoes the origin (OMD-C14)
- [ ] Includes `Access-Control-Allow-Origin` on every response, success or error (OMD-C13)
- [ ] Replies `200` or `204` within 5 seconds to every accepted message, including ignored ones
      (OMD-C1, OMD-C15)
- [ ] Replies `400` to a body that is not JSON, and `422` with `accepts` to a message with no `omd`
      block or a major it does not read (OMD-C16 to OMD-C18)
- [ ] Treats each message as a full replacement for its `display.target`, and never merges rows
      (OMD-C2)
- [ ] Accepts and ignores kinds and modes it does not show, without changing the board (OMD-C3,
      OMD-C4)
- [ ] Ignores unknown fields, and shows unknown values in open lists as text or ignores them
      (OMD-C5, OMD-C7, OMD-C10)
- [ ] Renders rows in the order received and shows string values as sent (OMD-C9)
- [ ] Treats a missing optional field and a `null` one the same way (OMD-C6)
- [ ] Handles UTF-8 names (OMD-C8)
- [ ] Documents its address, port, and path (path `/omd` and port `43339` suggested), and its targets
      (OMD-C11, OMD-C23)
- [ ] Is reachable from another computer when it is meant to be: listens on the network interface,
      and the port is open in the firewall (OMD-C19)
- [ ] Is tested from a browser-based producer, such as 2Timer or the [test page](/omd/test), not only with `curl`

A producer:

- [ ] Sends messages that pass `omd-check.mjs` (OMD-C25)
- [ ] Probes the receiver and sends the newest version both read, and still sends to a receiver
      that does not answer (OMD-C26, OMD-C27)
- [ ] Sends every message whole, with rows in display order and paged to the board (OMD-C29)
- [ ] Leaves out a field with no value (OMD-C28)
- [ ] Lets the operator set the address, port, path, and display target, and test the connection
      (OMD-C30)

## Files

Rendered pages:

- [Specification](/omd/spec): this document
- [Worked examples](/omd/examples): every example message and the probe reply
- [Conformance tests](/omd/conformance): every rule above with a pass condition
- [Field reference](/omd/reference): every field in the schema, as tables
- [Test page](/omd/test): probe your receiver and send it the conformance fixtures from your browser
- [Integration prompt](/omd/prompt): ready to paste into a coding assistant

Raw files, for tools and coding assistants. [`/omd/llms.txt`](/omd/llms.txt) lists them all.

| File                                 | URL                                                                                                |
| ------------------------------------ | -------------------------------------------------------------------------------------------------- |
| Message schema (JSON Schema 2020-12) | [/omd/schemas/meet-display-v1.schema.json](/omd/schemas/meet-display-v1.schema.json)               |
| Probe reply schema                   | [/omd/schemas/probe-reply-v1.schema.json](/omd/schemas/probe-reply-v1.schema.json)                 |
| Reference receiver (Node 18+)        | [/omd/tools/omd-test-receiver.mjs](/omd/tools/omd-test-receiver.mjs)                               |
| Checker (Node 18+, no dependencies)  | [/omd/tools/omd-check.mjs](/omd/tools/omd-check.mjs)                                               |
| Fixtures for the conformance tests   | [/omd/fixtures/index.json](/omd/fixtures/index.json)                                               |
| TypeScript types                     | [/omd/types/omd.d.ts](/omd/types/omd.d.ts)                                                         |
| Python types (3.11+)                 | [/omd/types/omd.py](/omd/types/omd.py)                                                             |
| This specification                   | [/omd/spec.md](/omd/spec.md)                                                                       |
| Conformance tests                    | [/omd/conformance.md](/omd/conformance.md)                                                         |
| Integration prompt                   | [/omd/integration-prompt.md](/omd/integration-prompt.md)                                           |
| Examples index                       | [/omd/examples.md](/omd/examples.md), listing each message under `/omd/examples/`                  |
| This version, pinned                 | [/omd/1.0/](/omd/1.0/spec.md): the same raw files, never changed after a later version replaces it |

## Governance

OMD was started by 2Timer, which hosts this page, and is edited in the open with the products that
implement it.

- **Anyone may propose a change** by email. A proposal says what problem it solves for which
  receiver or producer.
- **A change is decided with the implementers it affects.** Nothing that changes what a receiver
  reads is made without asking the receivers who build on it, and every change is published here
  with its reason in the changelog.
- **The [versioning](#versioning) rules apply:** a minor only adds, and only a major breaks.

## License

The text of this specification is licensed under
[Creative Commons Attribution 4.0](https://creativecommons.org/licenses/by/4.0/) (CC BY 4.0): copy,
translate, and adapt it, crediting Open Meet Display. The schemas, the example messages, the
reference receiver, and the integration prompt are dedicated to the public domain under
[CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/): copy them into your product or your
test suite with no notice required.

## Feedback

Write to
[jason@2timer.app](mailto:jason@2timer.app?subject=Open%20Meet%20Display), 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 (see [Not in this version](#not-in-this-version)).
4. Whether you need more than 10 rows per page (2Timer's current maximum).

## Appendix: Sending from 2Timer

How [2Timer](https://2timer.app), the first producer, sends 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. When your
  probe reply lists `targets`, 2Timer offers them as choices, and the operator can still type
  another.
- **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 show 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 a message

- `omd.generator` is `{ "id": "2t", "name": "2Timer", "version": "<its version>" }`.
- `meet.id` is the meet's stable identity, a UUID that stays the same across days and restores.
- `display.connection_id` is `<meet.id>_<the connection's number in that meet>`. It can repeat across two computers restored from the same meet file.
- `announcement.priority` is `normal`, and `announcement.expires_at` is not sent yet.

### Which version 2Timer sends

2Timer probes your receiver when the operator presses **Test connection**, and before a board
connection's first send in a session, and keeps the answer for that session.

- **A valid probe reply that reads 1.0:** 2Timer sends 1.0.
- **No reply, or one that is not a probe reply:** 2Timer sends the Draft 0.3 message
  (`"format": "meet.display.v1"`), which every receiver built before 1.0 reads. This is temporary;
  a later 2Timer release sends 1.0 to every receiver.

### Test connection

**Test connection** sends `OPTIONS`, then the probe, and tells the operator in plain words what it
learned, for example _Reached ExampleBoard 2.4. It reads Open Meet Display 1.0._, or _Reached the
receiver. It did not say which version it reads, so 2Timer sends the earlier draft._

### When a send fails

2Timer tells the operator one of: 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 the status your receiver replied with, with your `errors` message when you send
one. 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**, and check it names your product and says it reads 1.0.
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.
