Tracker reference
Every tracker setting, endpoint and wire message, as the service itself defines them.
Mirrored from docs/tracker/REFERENCE.md.Source last changed Edit docs/tracker/REFERENCE.md
Tracker reference
Category: tracker · Applies to: the nodera-tracker binary and the Java TrackerClient.
A tracker answers three questions and nothing else: which worlds exist, who is announcing one, and where can I dial them. It never stores a world, transfers a piece, verifies content, or relays traffic. It holds no authority — a lying tracker can hide peers or list unreachable ones; it cannot forge a world (hash-verified), an identity (Ed25519-signed against the bytes it received), or a vote. Losing every tracker degrades discovery and never correctness.
For installing and running one, see SELF-HOSTING.md. This page is the contract.
Surfaces
| Surface | Framing | Default |
|---|---|---|
| TCP | u32 big-endian length prefix, then one NDR2 frame; one task per connection |
0.0.0.0:25600 |
| UDP | the datagram is the frame — no length prefix, one request and one reply per datagram | same address, udp_enabled = true |
Both surfaces decode into the same handler, so a world announced over one is queryable over the other. There is no HTTP surface and no bencode: one frozen canonical encoding for the whole network is what keeps the Java↔Rust conformance tests meaningful.
Endpoint routes parse as host:port, tcp://host:port or udp://host:port. A bare route is TCP,
so UDP is always an explicit opt-in.
UDP is bounded three ways, because a UDP source address is forgeable and an unbounded answer would make the service a reflection amplifier: a request-size cap, a reply-size cap, and a reply-to-request amplification ratio. An answer that exceeds a cap is not sent and not truncated — a truncated canonical frame is undecodable, so silence is the honest outcome and the client retries the same endpoint over TCP, where the handshake already proved the source address. This replaces the classic connect/announce cookie exchange: the cookie proves an address, and a signed announce proves the identity, which is the stronger of the two.
Verbs
Kinds are the frozen wire kinds of ../network/REFERENCE.md. Anything
else is answered as unsupported.
| In | Out | What it does |
|---|---|---|
TrackerAnnounce 33 |
TrackerAnnounceAck 34 |
Register, refresh or remove this peer's record for one world. The ack carries accepted, next_announce_after_seconds, and a refusal code when refused |
TrackerQuery 27 |
TrackerResponse 28 |
One world: a sampled peer list, per-manifest seeders, live player count, distinct stored pieces, mean reliability, health, retention deadline |
TrackerCatalogQuery 44 |
TrackerCatalogResponse 45 |
Every tracked world, sorted by name then hash. Page defaults to 64 and is capped at 256 whatever the query asks |
TrackerRoutesQuery 49 |
TrackerRoutesResponse 50 |
Every live peer's full claimed route list, verbatim — this is where a joiner finds the mc/host:port game endpoint |
WorldDeletionGossip 66 |
— | Remember an owner-signed deletion (see Deleted worlds) |
WorldRevivalGossip 76 |
— | Undo one, on the same evidence rules |
ServiceAnnounce 67 |
ServiceAnnounceAck 68 |
A tracker or relay lists itself. The ack carries the announcer's siblings, so a draining service gets its replacement list in the round trip it is least able to make twice |
ServiceDirectoryQuery 69 |
ServiceDirectoryResponse 70 |
The service directory, filtered by kind and network |
ServiceScoreReport 71 |
— | A peer's measurement of a service it used |
InventoryAdvertisement is refused deliberately: holdings arrive only inside a signed announce, and
accepting a third party's claim about somebody else's content is a way to lie about durability.
ServiceDrainNotice 72 is likewise refused inbound — a tracker holds no service control channels,
so receiving one means somebody is talking to the wrong port.
Announce admission
Every cheap refusal happens before every expensive operation. In order:
- Tombstone. A world its owner deleted is not re-listed, whoever announces it. The announcer gets the owner's signed record back so it can verify the refusal itself.
- Per-IP quota. A fixed window of
per_ip_announce_quotaannounces per announce interval. Before signature verification, so flooding cannot buy CPU. - Signature. Ed25519 over the signed byte range as it arrived, never over a re-encoding — verifying a re-encoding checks this implementation against itself.
- Freshness. The announce's own timestamp must be within
announce_clock_skew_secondsof the tracker's clock, in both directions, so a captured announce cannot resurrect a departed peer. - Identity binding. Trust-on-first-use: the first public key to claim a
NodeIdkeeps it for as long as this tracker remembers it. A gracefulSTOPPEDreleases the binding, so a peer that rotates its key after leaving is not locked out of its own id. - Quotas. World ceiling, then per-world peer ceiling.
| Refusal code | Meaning |
|---|---|
bad-signature |
Signature did not verify, or the key was malformed |
stale-announce |
Timestamp outside the skew window — stale or replayed |
identity-mismatch |
This NodeId is already bound to a different key here |
quota |
The source address exceeded its announce quota |
too-large |
The record is larger than max_frame_bytes |
world-limit |
At max_worlds, and this world is not already tracked |
world-full |
At max_peers_per_world for this world |
undecodable-announce |
An announce body this build cannot read |
The last one is a refusal rather than a hang-up on purpose. The kind is legible even when the body is not, so the tracker can always say I did not register you — the one thing the announcer cannot work out for itself. Every other undecodable frame is dropped.
Display metadata is honoured only from the world's FULL_ARCHIVE host. Taking a name from any
announcer would let a passer-by rename someone else's world in every player's server list.
Records, sampling and expiry
One record per (world, peer identity) — keyed by identity, never by address. A re-announce
replaces the previous record rather than merging it, so a peer that changed address or state is
never a blend of its past and present.
| Field | Note |
|---|---|
routes |
The peer's own claimed dial routes, in preference order |
observed_route |
Where the announce came from, appended last and never substituted for a claim. A signed route list may name a forwarded port the tracker cannot observe |
capabilities |
Declared roles; a seeding role is what makes the peer a seeder |
holdings |
Which pieces of which manifests this peer holds for this world |
reliability_bps |
Self-reported, in basis points |
world_player_count |
Players this peer can see, or -1 for "I cannot see". Per peer, because that is how it arrives and how it expires |
last_seen_millis |
Tracker clock at the last accepted announce |
Expiry is last_seen_millis + peer_ttl_seconds, applied lazily on read and by a sweep, so an
answer never depends on sweep timing. STOPPED is an optimisation, not the mechanism: clients
crash, suspend and lose connectivity, so peer_ttl_seconds must stay comfortably above
announce_interval_seconds — the configuration refuses to start otherwise.
Sampling. A query returns at most sample_size peers, with seeder_floor seeders taken first.
The world_player_count in the response is computed from the whole swarm rather than the page,
so a busy world is not under-reported by its own page size.
Shedding. At max_worlds, the tracker drops the least recently active world that holds no live
peers before it refuses a new one. If no such world exists the announce is refused with
world-limit.
State is ephemeral by design. A restart loses nothing that matters — every live peer
re-announces within one interval. Only world display names and the deleted-world cache are
persisted, and only when persist_dir is set.
World health
| Verdict | Condition |
|---|---|
HEALTHY |
seeders ≥ healthy_seeder_floor |
DEAD |
zero seeders and a retention deadline that has passed |
DEGRADED |
everything else, including zero seeders inside the window and zero seeders with no deadline at all |
A world with no seeders is not instantly dead — that would paint a host's world gray the moment they reboot. The tracker only surfaces the retention countdown; the peers' own retention policy owns the actual drop, which is what keeps the tracker a directory rather than a decision-maker. A seeder returning cancels the verdict even past the deadline.
Note that healthy_seeder_floor defaults to 5, so a freshly shared world with one or two seeders
reads DEGRADED in the directory. That is the classifier working, not a fault.
Deleted worlds
A deletion is remembered for 120 days, keyed by world hash. Removing the world from the registry is not enough on its own: peers that were offline when the owner deleted it keep announcing, and a tracker with no memory would re-list the world from the first such announce. Keeping the record turns that around — an announce for a deleted world is answered with the owner's signed tombstone, which the announcer verifies for itself exactly as the tracker did.
The window is a judgement, not a proof: a peer reappearing after it, with the bytes still on disk,
will find nobody left to contradict it. Without persist_dir the cache is memory-only and the
window really means "until this process restarts".
Service directory
A tracker also lists the network's infrastructure. Services announce themselves with a signed record, peers query the directory and report back what they measured, and a service that is draining says so, so peers migrate before its circuits break.
max_servicesdefaults to 256 — deliberately far belowmax_worlds. A network has a handful of infrastructure hosts and thousands of worlds, so a directory grown to world scale is evidence of abuse rather than of success.- A score is the median of at most
service_report_max_reportersdistinct reporters, each contribution counting forservice_report_max_age_seconds. That number is the width of the evidence, and therefore how many identities an attacker needs before the median moves. - Score reports carry their own per-IP quota,
per_ip_report_quota, separate from announces.
A ServiceAnnounce is admitted on rules close to a peer announce's, but not in the same order,
and the order is part of the contract: shape (malformed-record), then freshness (stale-record),
then the signature over the bytes as they arrived (bad-signature), then trust-on-first-use identity
binding (identity-mismatch), then the directory ceiling (directory-full). The per-IP announce
quota (quota) is charged before any of it. Note the consequence, because it differs from the peer
path, which verifies the signature first: a service record is answered malformed-record or
stale-record before its signature is checked, so an unauthenticated sender can probe
announce_clock_skew_seconds with junk in the signature field. That is a deliberate
cheapest-check-first ordering, not an oversight, and it is stated here so it cannot be changed by
accident.
Three of these codes also answer a ServiceScoreReport (tag 71) rather than an announce: quota
against per_ip_report_quota, plus stale-record and bad-signature. The codes are stable and
machine-readable rather than prose:
| Ack code | accepted |
Meaning |
|---|---|---|
quota |
false |
The source address exceeded per_ip_announce_quota |
bad-signature |
false |
The signature did not verify against the record's own key |
stale-record |
false |
The record's issue time is outside announce_clock_skew_seconds |
identity-mismatch |
false |
This service NodeId is already bound to a different key here |
directory-full |
false |
At max_services, with no expired row to reuse |
malformed-record |
false |
Draining with no drain deadline — unplannable, and the shape a truncated record has |
not-listed |
false |
A Stopped record for a service this tracker was not listing |
| (empty) | true |
Registered, refreshed, or delisted |
not-listed is the only one that is not a refusal to do something. The tracker did exactly what was
asked — there was nothing to remove — and says so because it is the one fact the announcer cannot
work out for itself: it means this service's listing had been expiring between announces, so peers
querying this tracker were being answered without it. The draining service logs the code and the
endpoint; a successful-looking round trip would have hidden a discovery outage.
Settings
Every setting is a key in nodera-tracker.toml and an environment variable: NODERA_TRACKER_
plus the key, uppercased. Precedence is defaults → config file → environment → command line.
An unrecognised NODERA_TRACKER_* variable refuses the start rather than leaving an operator
believing a bound is in force. NODERA_TRACKER_ENDPOINTS is the exception: it belongs to the Java
peer, and is ignored here so one shell can start both.
| Key | Default | Unit / meaning |
|---|---|---|
bind_addr |
0.0.0.0:25600 |
Listen address for both surfaces |
advertised_routes |
empty | Public routes this tracker publishes about itself. Empty falls back to bind_addr, which is wrong behind NAT |
identity_file |
nodera-tracker-identity.bin |
Service signing key, created on first start. Preserve it: a regenerated one is a brand-new unmeasured host to every peer |
persist_dir |
unset | World display names and the deleted-world cache. Peer state is never persisted |
announce_interval_seconds |
120 |
The cadence handed back in every ack — the tracker paces announce traffic, not the peer |
peer_ttl_seconds |
300 |
How long a record survives without a refresh. Must be ≥ the interval |
announce_clock_skew_seconds |
300 |
Accepted deviation of an announce's own timestamp, both directions |
max_worlds |
10000 |
Worlds tracked at once; the idlest peerless world is shed beyond it |
max_peers_per_world |
5000 |
Records retained per world |
sample_size |
50 |
Peers returned in one query response |
seeder_floor |
10 |
Seeders taken before the rest of a sample is filled |
healthy_seeder_floor |
5 |
Seeders a world needs to read HEALTHY |
per_ip_announce_quota |
60 |
Announces accepted per source IP per interval |
max_frame_bytes |
262144 |
Largest accepted frame; may not exceed the protocol cap |
udp_enabled |
true |
Serve the same requests over UDP on bind_addr |
udp_max_request_bytes |
8192 |
Largest accepted request datagram; may not exceed max_frame_bytes |
udp_max_reply_bytes |
32768 |
Largest reply emitted; a bigger answer is dropped, not truncated |
udp_max_amplification |
4 |
Reply-to-request size ratio ceiling |
max_services |
256 |
Size of the service directory |
service_directory_page_limit |
32 |
Rows in one directory answer |
service_report_max_age_seconds |
900 |
How long one peer's measurement still counts |
service_report_max_reporters |
32 |
Distinct reporters retained per service |
per_ip_report_quota |
30 |
Score reports accepted per source IP per interval |
peer_tracker_endpoints |
empty | Other trackers to announce this one to. Empty is normal for a single-tracker deployment |
telemetry_endpoint |
empty — off | Where to report this service's own counters |
telemetry_interval_seconds |
300 |
Reporting window; floored at 30 |
update_channel |
empty — off | Release channel for self-update |
update_feed_base_url |
the project's releases | Must be https:// when a channel is set |
update_check_interval_seconds |
3600 |
Must be positive when a channel is set |
update_release_public_key |
this build's key | The Ed25519 key a release manifest must be signed with, 64 hex characters |
drain_grace_seconds |
30 |
How long in-flight work may hold up a drain |
Refused at load, rather than discovered under traffic: a zero announce interval; a
peer_ttl_seconds below it (which would expire every peer between its own announces, so the world
list flickers empty however healthy the swarm is); a zero max_worlds, max_peers_per_world,
sample_size, healthy_seeder_floor or max_frame_bytes; a max_frame_bytes above the protocol
cap; a zero UDP bound while UDP is enabled; a udp_max_request_bytes above max_frame_bytes; a
zero service-directory bound; and, when update_channel is set, a zero check interval or a
non-https:// feed.
Command line
nodera-tracker [--config <file>] [--bind <addr>] [--healthcheck <addr>] [--print-env] [--version]
--print-env lists every environment variable this build understands — ask the binary rather than
trusting a document. --healthcheck speaks the real protocol against a running instance and exits
non-zero if it does not answer; a port scan proves a socket is open, which is a different claim.
Metrics
Off unless telemetry_endpoint is set. What leaves is windowed counters and 'static labels; a
value derived from a request is not representable in the event type, so a peer, a world hash or an
address cannot be reported even by accident.
| Event | Numbers |
|---|---|
service.start |
once at boot, labelled with OS and architecture only |
tracker.window |
announces, announces_rejected, queries, worlds, window_seconds |
tracker.world_health |
healthy, degraded, dead |
tracker.services |
listed, draining_rendezvous, draining_trackers |
The first three are window deltas over cumulative counters, because a dashboard wants rates.
Failure modes
| Symptom | Usual cause | Lever |
|---|---|---|
| The tracker is down | — | Existing swarms keep transferring; only new peers with no other route in are blocked. The hazard is the reconnection storm on recovery, not the outage |
| The world list flickers empty | peer_ttl_seconds too close to announce_interval_seconds |
Keep the TTL at roughly twice the interval |
| A busy world answers "0 peers" over UDP | The reply exceeded udp_max_reply_bytes or the amplification ratio |
Nothing, on this side: it is the designed behaviour and the client must fall back to TCP. A third-party client that skips the fallback reports busy worlds as empty |
| Every peer in every world shares one address | The service is behind a proxy that does not preserve the client address | Fix the proxy; the observed route is a hint, but a hint identical for everyone is noise |
| Memory grows without bound | max_worlds or max_peers_per_world too high for the host |
Both ceilings are attacker-influenced; lower them and confirm the shed path under load |
| Announce rate collapses after a config change | An interval change only takes effect as each client comes back | Treat interval changes as staged rollouts; they cannot be reverted faster than one interval |
A world reads DEGRADED that an operator expects healthy |
Fewer seeders than healthy_seeder_floor |
Expected for a freshly shared world |
What this service deliberately does not do
- No scrape and no full enumeration by identifier. The catalog verb covers the product need — a player browses worlds — and is bounded by a page cap.
- No
min interval. The ack carries onlynext_announce_after_seconds; there is no floor a misbehaving client can be held to. Callers clamp their own cadence, which a third-party peer is under no obligation to copy. - No replication between trackers. Instances are independent and ephemeral. Peers query several and merge the answers, so a tracker that omits peers dilutes its own influence rather than censoring the world. Adding a second tracker without clients querying both partitions discovery rather than doubling it.
- No public/private distinction. A world's
listedflag lives in its identity record, not in anything a tracker enforces; any peer may announce any world to any tracker. "Unlisted" is not an access control. - No reachability testing. The tracker never dials. A peer behind a NAT with no mapping looks
exactly like a reachable one here; that is what the relay in
../rendezvous/REFERENCE.mdis for. - No content on the data path. The tracker's bandwidth is announces and peer lists, independent of how much data a swarm moves. A design that quietly puts a discovery service on the data path has converted a cheap component into an expensive one.