Repository navigation
client server dialog
What a boat and the server say to each other, and the rules that keep them able to talk after they
have drifted apart in version. Code cites this page by section (§8.2), so the section numbers are
stable.
The conversation is the optional half of the system. Nothing in it is on the path from a GPS fix to a crossing — the boat detects and times its own crossings with no network (crossing-detection) — so every part of it may be absent and the boat sails regardless. Where a club runs a race, it adds the committee's side: starts, flags, course changes, a channel and a fleet feed.
dialog.js is the boat's half, Dialog.java and DialogServlet.java the server's, and
client/www/schemas/*.v1.json is shared by both.
Every position and every instant comes from the boat, and nothing checks whether it told the truth. The server has no independent position for any boat; a crossing's instant is the boat's own, interpolated from its own fixes and stamped by its own clock. The quality-control gates defend against a bad receiver reporting in good faith, never against a dishonest sailor. This system produces records, not verdicts; a club that needs more than a boat's word has the ordinary remedies — a mark boat, a finish line, witnesses.
A clock offset cancels in an elapsed time, which is why this costs less than it looks. Elapsed time is the difference between two instants from the same clock, so a boat whose clock is a minute fast starts and finishes a minute late and its elapsed time is exact. What does not survive is comparison in absolute time — against a gun, or between two boats at one mark — which needs clocks that agree, and this system neither provides nor checks that. Drift within one race is far too small to matter on a device with GPS.
The server will not call OCS, or infer any outcome. It could compare a published start with a reported crossing, and it does not: a boat that says it started fairly is recorded as having started fairly. Judging a competitor belongs to the club's people.
- The boat decides whether a line was crossed, which way, and when. The server is told; it never sends a crossing to a boat.
- The server keeps no racing clock. It publishes an absolute start time; every countdown, elapsed time and crossing instant is run by the boat.
- A boat that has its course can sail all of it with the server switched off.
- The server does not score. Places, OCS, penalties and series results belong to the club's software.
For the formats that need one, the server acts in part as the race committee: start sequences,
postponement and abandonment, divisions, and a live fleet feed. Many formats need none of it. What it
never takes over is the recording: a committee that fires a gun still does not know when you
crossed. A start is published as an absolute instant (2026-09-17T13:05:00Z); the countdown is the
boat's own, and the boat's elapsed time runs from its own crossing.
One conversation, whatever the pipe. The design is a WebSocket, falling back to polling where a socket cannot be had. Polling is what is built, carrying exactly the envelopes and ordering a socket would:
| Polling | |
|---|---|
| Before a session |
POST /api/dialog with hello and join
|
| Client → server |
POST /api/dialog/{session} with an array of envelopes |
| Server → client | that POST's response carries whatever is queued |
| Idle | the client polls at the interval hello.ok gave |
Dialog.exchange(session, envelopes) is the whole contract, written so a socket could use it unchanged.
Crossing detection, timing, the approach screen, the course overview and the race clock all carry on. The fleet feed, messages, timers, flags and course updates stop; the Place screen ages and says so. The boat queues what it was going to say — crossings, its record, its messages — and sends it oldest first on reconnection. Fixes are the exception: only the latest is kept, because a fix is about now. The screen says when the channel is down.
{ "v": 1, "type": "crossing", "id": "b7f3c1a2", "at": "2026-09-17T08:31:12.250Z",
"tags": ["division:1"], "body": { } }| Field | Meaning |
|---|---|
v |
the protocol major version this message is framed in |
type |
what the message is; decides how body is read |
id |
unique to the sender; what an acknowledgement names, and what makes a resend idempotent |
at |
when the sender says it sent it — used for ordering a late queue and de-duplicating, never for timing |
tags |
labels for addressing; see §6 |
body |
the message's own fields, schema'd per type
|
Order comes from the transport. A socket delivers in order and a polled batch is a list. State is the
messages applied as they arrive — a start is published, an AP voids it, a new start replaces the AP — and
every party (server, race screen, boat) runs the same small state machine over the same sequence. There
is no sequence number: reconnection re-states rather than replays (§4.1), and id already makes a
resend recognisable.
A boat that was away wants what is true now, not what it missed. So on join the server re-states the
current standing of every tag the boat belongs to as ordinary course, timer and flag messages.
The channel is the exception, because a channel is a history: the boat sends channel.since naming
the last entry it holds and gets the original envelopes back, ids and all. A re-stated course or flag
writes an ordinary channel entry. There is no "clear the AP" message: publishing a start clears it.
Fixes are never replayed.
A phone in a bracket is not refreshed between races, so the boat and server will be different versions on the same afternoon. The rules:
- Fields are only ever added — never removed, renamed or repurposed.
- Unknown fields are ignored on both sides.
- Unknown message types are ignored, and counted.
-
A breaking change is a new
typeor a newv. The server speaks everyvit has shipped until nothing in the fleet uses the old one. -
additionalPropertiesis never written in a schema, so it is always true — rule 2 where a validator can enforce it.
The client opens with hello, naming every v it can speak; the server answers hello.ok with the one it
chose and the polling interval, or rejected. The client proposes, the server disposes.
Tags are namespace:value strings. A division is a tag, division:<id>, where <id> is the key of the
race's division map. A message with no tags is for everybody; one with tags is for boats carrying at least
one of them. The server groups by tags without knowing what they mean.
A boat is identified by sail number and name, and by a session issued when it joins, which later
messages carry so a reconnection is recognised. The boatId is the sail number, or the session id where
none was given.
Authenticate authority, trust data. Officers sign in with OpenID Connect, because publishing a course
or abandoning a race is a decision imposed on a fleet and who did this matters. Boats are never
authenticated: their data is trusted by design, so a login would only put a name to a claim nothing
checks. The whole of /api/dialog stays open (deployment).
That leaves two risks §1.1 does not cover — it trusts a boat about itself, not a third party about a boat: impersonation (any device can claim any sail number) and the open channel (anyone who can reach the server can broadcast). Both are deferred on purpose (§13).
Direction is C→S or S→C. The schemas in client/www/schemas/ are the exact bodies.
type |
Dir | Body |
|---|---|---|
hello |
C→S |
versions[], client
|
hello.ok |
S→C |
v, poll (ms), features[] — no time: the server is not a clock to set yours by |
rejected |
S→C |
code, text
|
rejected carries a code a client can branch on (version, course, variant, unpublished,
handicap, start, schema, record, internal) and a sentence a sailor can read; an unknown code
falls back to the sentence. Queries for clubs, series, races and courses stay on the cacheable REST
endpoints (/api/public, /api/races/…, /api/courses/{revision}).
type |
Dir | Body |
|---|---|---|
join |
C→S |
sailNo, name, club, series, course, variant, race, division, tcf, lengthM, allocatedStart, revision
|
joined |
S→C |
session, boatId, race, raceName, tags[], fixSeconds, revision, course or waiting
|
leave / left
|
C→S / S→C | session |
- A boat joins a race, and the course follows from its division: a boat does not choose the geometry it was entered for. A join naming no race is matched to a race today whose division sails that course.
-
joinedcarries the whole published snapshot, never a reference to it, so a boat on a flaky connection never has to fetch before it can sail. It is always what was published, never what the editor holds. -
A public race can be joined before its course is published. Then
joinedcarrieswaiting— a sentence and the start line as the programme has it — and when the club publishes, every waiting boat in that division is sent acoursemessage with the snapshot and starts on it. -
A distance-handicapped course needs a TCF inside the snapshot's
tcfMin–tcfMax, or the join isrejectedwith codehandicapgiving the range. -
An allocated start needs the boat's own start time, between the division's first and last, or the
join is
rejectedwith codestart. -
joinedalso arrives unasked, when a boat is entered for the next race of the day (§12.6). -
With no conversation possible, the boat sails anyway, falling back to
POST /api/join/{club}/{series}/{course}, which hands over the published snapshot and nothing else.
Practice and record attempts have nobody to talk to, so they get a session for crossings and the record, but no channel, no fleet feed, no timers or flags, and no fixes — a phone in a bracket for four hours reporting to nobody is battery and data spent on nobody. The Chat and Place screens are not offered.
type |
Dir | Body | Notes |
|---|---|---|---|
fix |
C→S |
session, position, cogDeg, sogKn, revision, at
|
every fixSeconds (2 s), accepted fixes only |
crossing |
C→S |
session, line, step, lap, letter, instant, revision, confirmedFixes, finish
|
one per latch, with the interpolated instant |
record |
C→S |
session, record
|
the whole CourseRecord, sent when the course completes |
retire |
C→S |
session, reason
|
§8.6 |
A live crossing is advisory; the record is definitive. The server stamps the record with the session's race and division before filing it: everything else in a record is the boat's account of itself, but which race it is in is an entry the committee accepted.
type |
Dir | Body | Acknowledged? |
|---|---|---|---|
fleet |
S→C |
race, boats[] — position, COG, SOG, revision, step, lap, start, finish, elapsed, TCF, outcome |
no |
course |
S→C |
revision, course (the whole snapshot), reason, text
|
yes |
timer |
S→C |
kind, startAt, closesAt, openSeconds, warningSeconds, startSeconds, timeLimitSeconds, text
|
no |
window |
S→C |
opensAt, closesAt, text — superseded by a timer of kind open, still read |
no |
flag |
S→C |
flag (postponed, abandoned, …), reason, text
|
yes |
say |
both |
from/session, text
|
committee messages, by being read |
outcome |
S→C |
boatId, outcome, reason, text
|
yes |
ack |
C→S |
session, ackOf, what
|
|
channel.since |
C→S |
session, after
|
answered with the original envelopes |
fleet goes out every five seconds, not at the fix rate: on a screen showing a whole course, the
difference is invisible, and one message carries every boat.
timer is a division's start. Every start line is open for a period, and a crossing counts only
while it is; a boat over early sees nothing register and comes back.
kind |
The line is open | Elapsed runs from | The boat shows |
|---|---|---|---|
scratch |
from startAt for openSeconds (default 600) |
startAt |
a countdown to the start |
open |
from startAt to closesAt
|
the boat's own crossing | when the line opens, then when it closes |
allocated |
from the boat's own start time for openSeconds
|
that time | a countdown to its own start |
warningSeconds and startSeconds put the warning and preparatory signals before the line opens;
timeLimitSeconds closes the finish to a boat that long after its elapsed time began. Every countdown is
run by the boat against its own clock; the server sends a timer once and does not tick.
One start per division, and no rolling sequence. Postponing one division does not move another. AP suspends, it does not reschedule: there is no "now" on the server to count from. Publishing a start is what clears an AP, so the cycle is publish → AP → publish, and the last message for a tag is its state.
The channel is open and relayed to everybody — a radio, with no private conversations. A joining boat is sent the backlog for its race. A message over 400 characters is truncated rather than refused. There is no automatic rate limit, because there is no identity to throttle; the committee muting a sail number is the intended defence (not built).
Every state message carries text, which is what the channel shows and what an older client can still
display. Acknowledgements exist where "did they see it?" could decide a protest: a course change, a flag,
an outcome and a committee message.
| From | On | To |
|---|---|---|
| — | timer |
scheduled |
| scheduled | flag: postponed |
postponed |
| postponed | timer |
scheduled |
| scheduled | the start passing, on each reader's own clock | racing |
| racing | flag: abandoned |
abandoned |
| racing | every boat finished, retired or DNF | finished |
AP is the pre-start signal and abandonment the post-start one. The race screen enforces two rules against its own clock: no AP once a start has passed, and after an AP the next start is at least six minutes ahead (a minute, then a full sequence).
A boat retires because a sailor pressed retire (retire, C→S); a boat is DNF because the
committee decided so (outcome, S→C). The software never infers an outcome from a boat that stopped
reporting. Whether a boat completed the course is collation; what place it got is scoring, which is the
club's.
The boat's own screens are described in boat-client and can be tried on the
test client. The conversation adds three things, built in
screens.js; the rules they follow are these.
A ladder of every boat that joined: how far round, elapsed time, and corrected time where a TCF is known. It ages rather than blanks, saying how old it is, and it is a view, not a ranking anybody is held to.
Committee messages, chat, and receipts for state changes, newest last, with an unread count. Common replies are one tap — Retiring, Protesting, OK — and the safety three — Need assistance, Standing by to assist, Man overboard — are set apart in warning colour, because they are not racing messages.
A course change, flag or outcome opens an alert, and dismissing it is the acknowledgement. But nothing interrupts an approach: while the approach screen has the display, an alert shows as a banner and the modal waits until the approach ends. A plot covered at the moment of a crossing costs the crossing; an abandonment seen twenty seconds late costs nothing.
They appear in the channel, so a late joiner reads the afternoon as one story. But on the wire they are state, not events: the client holds the current course and flag explicitly, and the channel entry is a receipt. "Scroll up to find out whether you are racing" is not something to ask of somebody on the water.
The views are Course, Line, Chat, Place and Auto, on every screen; Chat and Place appear only where there is a channel. Auto shows, in priority: the approach screen while approaching a line; then a new channel entry; then the approach screen by distance or time, else the course. Place is never chosen automatically. The start countdown sits above every screen.
JSON Schema, one file per message type and major version, in client/www/schemas/, read by the server
off the classpath and by the boat over HTTP — one copy for both. Each side validates what it receives: the
server answers a bad message with rejected code schema; the boat drops and counts it.
The schemas are held to a subset a small hand-written validator covers — type, properties,
required, enum, const, items, minimum/maximum, pattern, format: date-time — because the
client has no build step and a full JSON Schema library would be paid for on every boat. A message that
needs more than the subset should be simpler.
The record names the course the boat was sailing when it finished. Whether that record still means anything after a mid-race change is for the club to judge.
Withdrawing a course does not reach the water. Un-publishing stops new boats joining; boats already
sailing finish on the snapshot they were handed. Stopping a race is flag: abandoned, and only that —
tidying next week's courses on race morning must never stop a fleet.
The committee's screens are for a club's desk or a committee boat, with a keyboard, and are behind the officers' login. See them on the live site: the course editor and the race screen.
A race's definition — its date, how it starts, each division's course, variant, start and time limit, and the race that follows — is configuration, edited on the editor's Races tab and saved in the series file. While a race is public the server hands each division the start its definition works out to. Running a race is a separate page, because the editor saves as you go and holds one undo, which is wrong for telling a fleet to stop: on the race screen an irreversible act asks twice.
On the race screen colour means division; in the editor it means a leg's role. Progress is texture: legs some boats have sailed are solid, legs no boat has sailed are dashed, and legs all have sailed are faint. The solid band is the fleet's spread, which is what a committee deciding whether to shorten needs to see.
The race screen shows every public race running today and acts on one. It can delay a start, AP it before the start or abandon after, republish a division's course, mark a boat DNF, and write in the channel. There is no GO button: the server keeps no clock, so a start is scheduled, never triggered.
One row per boat: last fix age, mark and lap, elapsed time, outcome, and whether it has acknowledged the latest course and flag. That column is why acknowledgements are in the protocol: have all boats seen the new course? is the question a committee actually has.
| Where | |
|---|---|
| Definition — date, divisions, starts, next race | the series YAML, under races:
|
| Conduct — who joined, the channel, flags, standings, acknowledgements | data/store/conduct/{club}/{series}/{race}.json |
The channel is kept with its race, so a day of several races is several separate conversations. After a server restart the channel and standings come back from the conduct file; sessions do not, so boats join again and are re-stated.
A race names the race that follows it (next), and a boat that retires from one is entered for the
next, provided it is the same day, with a joined sent unasked. Its division carries over where the next
race has a division of that name; where it does not, the chain stops and the boat joins by hand. A boat
declines with leave.
Two defences against bad actors are deferred on purpose, both costs of not authenticating boats (§7.1):
- A rate cap on the channel. There is no identity to throttle; the intended instrument is the committee muting a sail number.
- Impersonation. A join token from the notice of race would raise the cost without being authentication, and is the obvious first move.
Not open, and not to be reopened without a reason: the trust model (§1.1), the absence of a server clock (§1.2), the absence of a sequence number (§4), unauthenticated boats (§7.1), and that a withdrawal never reaches the water (§11).
client/www/dialog.js, screens.js, schema.js
|
the boat's half: joining, the queue, the held state, the channel, the validator |
dialog/Dialog.java, Envelope.java, Schemas.java
|
sessions, one room per race, standings, the chain, the validator |
server/DialogServlet.java |
the polling transport |
dialog-test.js, DialogTest.java
|
the state machine, the channel's idempotence, the alert rule, that both validators agree |
tools/editor-drive/drive-race.mjs, drive-client.mjs, drive-alert.mjs
|
a race end to end over the wire; a whole course sailed with the network removed; no alert over an approach |
Not built: the WebSocket; muting a sail number; the defences in §13; the next-race chain on finishing (only retiring fires it); the boat switching to a course change — it is received, shown and acknowledged, but the boat keeps sailing the geometry it joined with; a session for the race screen; stamping records posted over REST with their race; caching a joined race across a page reload; and re-posting the record with its full track.