Skip to main content

Discovery

sync.discovery finds the other bedrock-core nodes in the world and keeps a live list of them.

Script events are ephemeral: a pack that loads after another has already announced never hears that announce. Discovery solves that two ways — every node re-announces on a heartbeat, and a freshly started node broadcasts a whois that prompts existing peers to announce straight back. A TTL sweep drops peers that go quiet.

Who is present is a value, not a stream: peers is a ReadonlyObservable list you read with .get(), watch with .subscribe(), or derive from with computed(). It republishes only when the world actually changes — a heartbeat repeating what a peer already said notifies nobody, so a listener can sit on the list without waking every five seconds per peer.

Import​

import type { PeerInfo, CollisionInfo, IncompatiblePeer, PeerListener, CollisionListener, IncompatibleListener, DiscoveryOptions } from '@bedrock-core/sync';

negotiateProtocol and capsFor are the standalone functions behind protocol negotiation below; SELF_CAPS is what this build broadcasts:

import { capsFor, negotiateProtocol, SELF_CAPS } from '@bedrock-core/sync';

Usage​

sync.discovery.peers.subscribe(peers => console.warn(`${peers.length} peers`));

sync.discovery.onPeerUp(peer => console.warn(`${peer.id} v${peer.version} joined`));
sync.discovery.onPeerDown(peer => console.warn(`${peer.id} left`));

const peers = sync.discovery.peers.get();
const economy = sync.discovery.getPeer('drav0011_economy');

Subscribe to peers to react to who is here; use onPeerUp / onPeerDown to react to a single arrival or departure.

From a runtime, the same information arrives pre-interpreted through core.registry, which turns each peer's meta into an addon manifest. Reach for core.node.discovery only when you want the raw peer records.

PeerInfo​

interface PeerInfo {
id: string;
version: string;
schemaVersion: number;
protocol: number;
caps: readonly string[];
meta?: Record<string, unknown>;
}
FieldMeaning
idThe peer's node id — its transport address.
versionWhatever it passed as version, or '0.0.0'.
schemaVersionWhatever it passed as schemaVersion, or 0.
protocolThe wire version this node and that peer settled on.
capsOptional behaviors the peer can read, narrowed to what protocol allows.
metaThe opaque blob it attached to its announce. server-runtime puts the addon manifest plus runtimeVersion here.

Liveness is deliberately not a field here. A tick that moves on every heartbeat inside an observable value would make the list republish every five seconds per peer; ask for it by id with lastSeen instead.

Protocol negotiation​

Discovery is also where two nodes agree on what to speak. Every announce carries the range its sender supports, and hearing one settles the pair on the newest version both know, through the standalone function the bus calls for every peer:

function negotiateProtocol(theirMin: number | undefined, theirMax: number | undefined): number | undefined

undefined when the ranges do not overlap at all. Otherwise:

agreed = min(PROTOCOL_MAX, theirMax) // valid while agreed >= max(PROTOCOL_MIN, theirMin)

Nothing is exchanged to arrive at this. Both sides apply the same rule to the same two advertised ranges, so they reach the same answer from one message — the same reasoning the runtime's host election uses. The result is pushed to the bus, which encodes traffic for that peer at it.

An announce with no range at all comes from a node built before the field existed, which pins it to PROTOCOL_MIN exactly.

Capabilities​

caps carries optional behaviors a node can read, so a sender consults the receiver's set before using one. Versions move in lockstep for everyone; capabilities let a single behavior appear or degrade on its own, which is what keeps the next addition from needing a version bump.

Cap.Batch // 'batch' — reads several envelopes packed into one message

SELF_CAPS: readonly Cap[] // everything this build can read; broadcast in every announce

A capability is always narrowed by the negotiated version, through capsFor:

function capsFor(protocol: number, advertised: readonly string[] | undefined): readonly string[]

One advertised by a node that also speaks something newer is still out of reach at the version actually in use — capsFor is what applies that narrowing when a peer's announce carries no caps at all, on any protocol that implies Cap.Batch by version alone.

API​

peers​

sync.discovery.peers: ReadonlyObservable<readonly PeerInfo[]>

Known live peers, as an observable list. Excludes self. Each notification carries a fresh array; the PeerInfo objects inside it are never mutated.

lastSeen​

sync.discovery.lastSeen(id: string): number | undefined

The tick a node was last heard from, or undefined if it has never been heard. Covers unreachable nodes as well as peers.

getPeer​

sync.discovery.getPeer(id: string): PeerInfo | undefined

onPeerUp​

sync.discovery.onPeerUp(listener: PeerListener): Unsubscribe

Fires when a peer is seen for the first time. Subsequent heartbeats from the same peer refresh its liveness without re-firing, and without republishing peers unless the peer said something new (a version bump, a changed meta).

onPeerDown​

sync.discovery.onPeerDown(listener: PeerListener): Unsubscribe

Fires when a peer expires — it was last heard outside the TTL window when a sweep ran. peers is republished once for the whole sweep, before any listener runs, so a handler reading the list never sees a half-swept world. Note that this is a timeout, not a graceful goodbye: there is no "leaving" message, so a peer that stops answering takes up to the TTL to disappear.

onCollision​

sync.discovery.onCollision(listener: CollisionListener): Unsubscribe

interface CollisionInfo {
id: string;
instanceId: string;
}

Fires when another instance is announcing this node's id — a namespace collision. Such an announce is surfaced here rather than being stored as a peer.

At the runtime layer this is core.registry.onNamespaceCollision.

incompatiblePeers​

sync.discovery.incompatiblePeers: ReadonlyObservable<readonly IncompatiblePeer[]>

interface IncompatiblePeer {
id: string;
pmin: number;
pmax: number;
}

Nodes heard on the bus whose protocol range does not overlap this build's. They are present in the world and cannot be addressed, so they are listed here rather than stored as peers — an addon that cannot be talked to should be named, not missing.

onIncompatible​

sync.discovery.onIncompatible(listener: IncompatibleListener): Unsubscribe

Fires the first time such a node is heard. At the runtime layer this is core.registry.onIncompatible, which also logs a warning naming both ranges.

announce​

sync.discovery.announce(): void

Broadcast this node's presence immediately. start() does this for you and the heartbeat repeats it; call it by hand only if you changed something peers need to see sooner than the next beat.

The announce and the whois are the two messages pinned to PROTOCOL_MIN: they are what establishes which version everything else may use, so they cannot themselves assume one. That costs them the packing a negotiated message gets, which at one broadcast per 5 s is affordable.

whois​

sync.discovery.whois(): void

Ask every peer to announce itself. Sent once at start(). Peers reply directly to the asker rather than broadcasting, so a late loader joining a busy world does not spam everyone.

Timing​

ConstantValueMeaning
ANNOUNCE_INTERVAL_TICKS100Heartbeat period — every node re-announces every 5 s.
PEER_TTL_TICKS320A peer unheard from for 16 s is considered gone.
Sweep interval40 ticksHow often the TTL check runs.

So a peer that disappears is detected somewhere between 16 and 18 seconds later, and a peer that appears is normally visible within a tick or two of its whois reply — but never assume a peer exists on tick 0. React to peers through events (onPeerUp, onDependenciesSatisfied, a feature condition) rather than by checking once at startup.

DiscoveryOptions lets a test override announceIntervalTicks and peerTtlTicks.