Inter-BBS Packet & Node-List Formats (developer reference)¶
The sysop's side of inter-BBS ("interplanetary") play is docs/inter-bbs.md.
The clone is an independent reimplementation and does not use BRE's binary
packet format. It defines its own JSON packets. The node list reuses BRE's
plain-text BRNODES.DAT layout (under the clone's own filename ibnodes.dat).
Transport model¶
The game only reads and writes packet files in two directories, set per board in
the Configuration Editor and stored in bbs.cfg (not config.json — see "Board
config" below):
InboundDir— packets from other boards arrive here.OutboundDir— the game writes packets for other boards here.OutboundDirs— per-neighbour override ofOutboundDir, keyed by roster node number (Config.OutboundLink). Only a board that HOSTs others needs any.
Both are resolved against DataDir unless absolute (Config.Inbound() /
Config.Outbound()) — a door is launched from whatever working directory the
BBS chooses, so a CWD-relative path lands somewhere different on every call.
Moving files between boards is external to the game. RunPlanetary
(immortal-barons -planetary, also folded into -maint when IBBS is on) reads
and applies inbound packets, launches due group attacks, exports this board's
scores, and writes the outbox.
Generic file handoff contract (#191)¶
The final .brp name is the commit marker (the extension is matched without
regard to case). Its existence means the file is complete, closed, and ready
for its current owner to consume. A transport must never create a final .brp
and then fill it in place. That rule applies to a plain filebox copier, a sync
service, scp, and a shared or network filesystem just as it does to
barons-ftn.
The ownership states are:
| State | Owner | Contract |
|---|---|---|
Non-.brp temporary file in game outbound |
Game | Private work in progress; transports ignore it. |
Final .brp in game outbound |
Transport | The game has published complete bytes; a transport may claim them. |
Non-.brp claimed or staged file |
Transport | Private transport work; the game ignores it. |
Final .brp in game inbound |
Game | Published and immutable; the transport must not change or remove it. |
The game publishes outbound packets by creating a temporary file in the
destination directory, writing and closing it, then renaming it to .brp on
that same filesystem. A transport may therefore scan only final .brp names;
it does not need the game lock merely to avoid a partial read. If multiple
transport workers can consume the same outbound directory, they must serialize
with one another or atomically rename a source to a non-.brp claimed name in
that directory. Only the worker that acquired the claim may queue or remove
it. It must preserve the packet bytes exactly, and retain enough state to retry
or restore the claim after a delivery failure.
Inbound publication is the mirror image:
- Stage the complete packet under a non-
.brpname on the destination filesystem. Copying to a temporary file elsewhere and then falling back to a cross-filesystem copy into the final name does not satisfy the contract. - Finish and close the staged file. A transport promising crash-durable delivery should also sync the file before publication and the destination directory after the rename, where the platform supports it.
- Serialize publishers for this inbound directory and check the intended final name. If it already contains identical bytes, the arrival is a duplicate and the staged copy may be discarded. If it contains different bytes, preserve the staged copy, report a collision, and never overwrite either file.
- Atomically rename the staged file to its final
.brpname. From that point the game owns it.
No lock shared with the game is required for this inbound handoff. A rename
that lands before ReadInbound takes its directory snapshot is processed in
that run; one that lands afterwards waits intact for the next run. The atomic
name transition makes both outcomes safe. A transport still needs its own lock
or an equivalent no-replace publication primitive to keep two of its workers
from racing through the collision check.
This contract requires a filesystem whose same-directory rename has atomic,
consistent visibility to every participating process. If a remote mount cannot
provide that guarantee, use a receiver-side process to stage and rename on a
local filesystem, or schedule transport and -planetary so they cannot
overlap. A sidecar marker or manifest does not repair weak visibility by
itself: it adds a second file whose ordering, atomic publication, collision,
and cleanup would need another protocol.
ReadInbound defensively leaves an unparseable file younger than five minutes
in place and retries it later. That grace period limits damage from a transport
that writes directly to the final name; it is a heuristic, not an alternative
readiness signal. An older incomplete file is quarantined to bad/.
barons-ftn is the optional bidirectional FTN adapter. The game's directories
remain private. -out claims a fixed snapshot under game.lock, groups its
packets by next hop, and publishes one FTN handoff per hop. Attach and obox
bundles are immutable; BSO bundles may be rebuilt at the same path while the
peer's .bsy is held. -in
validates a received bundle, publishes local packets under the same game lock,
and immediately re-bundles transit. All helper processes serialize through
barons-ftn.lock; the lock order is always helper then game. Durable journals
under ftn-spool make the handoff resumable.
FTN transport bundle¶
The physical NNNNCCCC.BRP file is a ZIP archive. It is not a new game packet
and none of its metadata is covered by a board signature. It contains:
manifest.json
packets/000000/L100-2-000000000001z-3.brp
packets/000001/L100-2-0000000000020-3.brp
...
The version-1 manifest is:
{
"format": 1,
"delivery": "attach | direct",
"entries": [
{
"route": [2, 1],
"covered": [2, 1, 3, 4]
}
]
}
Every ZIP member CRC must pass before any entry is published. Readers reject
duplicate members, a packet/routing-entry count mismatch, unsafe paths,
unsupported versions, more than 10,000 entries, and more than 256 MiB expanded
data. Each manifest entry supplies transport state for the packet member at the
same ZIP order position; it does not repeat the member name. A member is decoded as
game.Packet to validate its shape, derive its canonical filename, and choose a
route. Its raw bytes are copied unchanged when delivered or forwarded.
delivery records only the distinction the receiver needs. An attach bundle
waits for a matching validated .msg; a direct obox or BSO bundle is processed
without one. It does not redundantly record which direct queue carried it.
route is the actual node trace: its last node is the
transmitting hop, and its length supplies the hop count. covered is present on
an unaddressed broadcast and contains every node for which a branch has already
been durably scheduled. A receiver fans out only to nodes in neither set. For a
legacy raw packet, the unchanged inner packet's existing Hops value is added
to the route length; it is not duplicated in the manifest. These fields are
loop controls, not authentication; the game still verifies the inner packet
against the league roster.
Coverage prevents ordinary sibling copies from cross-sending, but it is not a distributed exactly-once protocol. Independently scheduled branches in a simple cycle of four or more nodes can still meet and produce duplicates. Canonical filename and exact-byte comparison distinguish that case from a conflicting packet using the same identity.
The alias namespace is four base-36 characters for league*1000 + transmitting
node, followed by four characters from a persistent counter. Both numbers are
limited to 1..999. The counter is reserved before publication, advances once
per physical copy, and warns on wrap. A receiver discards this alias and
re-derives each member's canonical name from the decoded JSON; all game identity
remains in the packet.
A leading JSON object instead of ZIP is accepted as one legacy entry, allowing
receivers to be upgraded before senders. This is receive-only compatibility,
not an FTN wire format: new senders always publish ZIP, even for one packet.
New bundled output requires the receiving barons-ftn -in; the game itself
still reads JSON only.
Packet files (*.brp)¶
Each game packet is one JSON file. These names are private to the game and ZIP
members; FTN sees the independent 8.3 bundle alias above. Modern filenames are
[L<nnn>-]<from-node>-<sequence>-<to-node>.brp; the three identity numbers use
base 36. The sequence has a fixed width so a directory scan sees each sender's
packets in order. For example, L042-2-000000000001z-3.brp is league 42,
origin node 2, sequence 71, final destination node 3. A zero destination is a
broadcast. The L<nnn> prefix is present when the league number is set. A
modern packet at league 0 gets a short digest of its origin board before those
three numbers, preventing equal node numbers in two leagues sharing a directory
from colliding. A legacy packet without a stable origin node and sequence gets
a deterministic 128-bit content digest instead. Packet identity remains in the
JSON; neither this private name nor the transport alias is authoritative.
The extension is matched without regard to case (#179). FTN transport hands
files over in upper case routinely — 8.3-era software and several mailers do it,
and a league carried over FidoNet can meet one at any hop — so a .BRP delivered
into an inbound directory is read like any other packet. An exact match against
the lowercase name left the file sitting there unread, and unreported: not
applied, not refused, not counted as skipped.
The 8.3 bundle alias leaves room for FTN .msg transports, whose Subject must
carry the attachment pathname in at most 71 bytes. That byte budget is an FTN
constraint, not a restriction on the packet format; it comes from the
stored-message header described in
ftn-standards.md, which is where the FTN formats and the
standards defining them are written down.
The destination number is the packet's FINAL destination, not the board the
file is handed to. Where the file is written is the routing decision
(World.NextHop).
The JSON is game.Packet. Every field is optional; one packet carries whatever
the run had to send. game.Packet itself is the authority — this is a reader's
map of it, not a second definition.
{
"FromBoard": "AlphaBBS",
"ToBoard": "", // "" = broadcast to the whole league
"Date": "2026-07-04", // ISO game date the packet was written
"Scores": [ RemoteScore ], // score share (feeds IP scores / attack targets)
"Attacks": [ RemoteAttack ], // strikes landing on ToBoard
"Terrors": [ RemoteTerror ], // terrorist ops landing on ToBoard
"Results": [ AttackResult ], // outcomes returning to the origin
"Recon": [ ReconRequest ], // scouting asked of ToBoard (#61)
"ReconReports": [ SpyReport ], // answers coming back to the origin (#61)
"Annihilator": AnnihilatorStatus, // a doomsday weapon aimed at ToBoard (#63)
"TimeChecks": [ TimeCheck ], // round-trip probes, out and echoed back
"IPMessages": [ IPMessage ], // interplanetary mail for ToBoard's barons
"TradeBids": [ IPTradeBid ], // buy orders landing on ToBoard's market (#47)
"TradeFills": [ IPTradeFill ], // their answers coming home (#47)
"Market": [ RemoteListing ], // FromBoard's market, riding its scores (#47)
"Version": "0.0.5", // the sender's game version, for BBSINFO
"Ruleset": "51511b59", // fingerprint of the league rules the sender plays
// by (#264). A gate: a packet whose rules are not
// the league's is HELD. Outside the origin
// signature, like Protocol — see Packet.Ruleset
"LeagueConfig": LeagueConfig, // coordinator's ruleset (signed)
"LeagueNodes": [ LeagueNode ], // coordinator's roster (signed, #64)
"Reset": LeagueReset, // coordinator's new-season order (signed, #65)
"Seq": 7, // per-sender sequence, for replay detection (#53)
"Signature": "base64", // ed25519 over the coordinator-authored parts
"BoardSig": "base64", // ed25519 by the SENDING board over the whole packet, so
// FromBoard is proven rather than claimed (#118).
// boardSigningBytes zeroes the fields that must not be
// signed first: BoardSig itself, Hops (which every hub
// increments), Protocol, Ruleset and Battles.
// Signature IS covered, so a coordinator order cannot be
// lifted out of one packet and grafted into another.
"League": 42, // league number; a board in two leagues ignores the other's.
// ReadInbound skips a packet only when the reader's own number
// and the packet's are BOTH non-zero and differ, so 0 on either
// side (an unnumbered league, or a packet predating the field)
// is accepted. Two leagues sharing an inbound directory
// therefore both need a number.
"Hops": 0, // boards that have forwarded this; capped by MaxPacketHops
"Epoch": 3, // sender's World.Epoch, so a packet a reset has outlived is
// recognised as stale rather than applied (#104). 0 = sender
// predates the field, trusted rather than rejected.
"FromNode": 2, // sender's roster node number, preferred over FromBoard for
"ToNode": 1 // identity (auth, addressing, the Coordinator check, #105).
// 0 = unaddressed (ToNode) or no roster yet (FromNode);
// FromBoard/ToBoard are the fallback either way.
}
Identity lives in the file, and BRE's lived in the name¶
The original derives a packet's origin from its filename. Its inbound
scanner (BRE.OVR 0x03bd64) is a wildcard directory walk, not a loop over the
roster: FindFirst on the mask, while DosError = 0 (0x1b68) with FindNext
at the bottom, and inside it copies five characters starting at position 2 of
the name (0x1b7d), converts them to a number, bounds-checks that against the
node count at [0x1264], indexes the node table by node x 0x3e, and deletes
the file when that entry is empty. The routine has a message for removing data
from an unknown node, which a roster loop could never reach and a name-keyed
scan reaches routinely.
IB reads origin and destination from the JSON instead (FromNode/ToNode
above), so a renamed packet still applies correctly and the name is free to be a
transport convenience. This is a deliberate divergence and the stronger design;
it is also what makes an 8.3 transport alias possible at all (#178).
The original picks no ingestion ORDER. FindFirst/FindNext return DOS
FAT directory-entry order — the slot each file happened to be written into, with
freed slots reused after deletions. Not alphabetical, not by node, not arrival
order. So IB's current sorted order is not fidelity: os.ReadDir sorts where DOS
did not, and the order #178 settles on (below) diverges from nothing the
original decided. The fixed-width sequence above is no longer justified by
scan order — #178 removed that dependence — but keeps its other job: a
short, collision-free name.
Applying an inbound batch (#178)¶
ReadInbound stages every packet file in the directory before applying any
of them, rather than applying each one as os.ReadDir returns it. Base-36
encodes a packet's origin node near the front of its filename, so
alphabetical order gave the same origin first place in every batch for as
long as the roster stood — a fixed, permanent advantage on anything two
origins contest in the same run, such as a trade bid or a land claim.
Staged packets are grouped by originKey (FromBoard first, FromNode
as a fallback for a packet old enough to carry no board name at all — it
has to key the same way replay detection does, or one origin's own
packets can be split across two groups and reordered against each other).
Each origin's own packets stay in their existing Seq order within their
group: only the order between origins was ever the problem.
The Coordinator's group is identified by comparing a group's key against
the roster's actual Coordinator board — never by asking an individual
packet whether it claims to be from the Coordinator — so a forged
FromNode: 1 buys an origin nothing. (A board with no roster loaded yet
falls back to trusting a self-declared FromNode: 1, the same trust
level fromCoordinator already uses to bootstrap — this narrows to a
one-time window before any roster exists and closes for good once one
does.)
Only the packets in that group that actually carry something only the
Coordinator may send (LeagueConfig, LeagueNodes, Reset, or
Bulletins — the same CarriesCoordinatorOrders check
SignAsCoordinator/VerifyCoordinatorOrders use, so there is one
definition of "league-wide state" instead of two) and verify against
this board's Coordinator public key are applied ahead of the rest of the
batch — not the whole group. ExportNodeList rebroadcasts the roster on
every planetary run of the Coordinator's board, so gating on the whole
group rather than the individual packets let an ordinary gameplay packet
(a trade bid, a land claim, a strike) riding in the same batch as that
rebroadcast inherit its priority for free on essentially every run — the
exact fixed advantage this feature exists to remove, just re-anchored
from filename order to "is the Coordinator's board". The split lands
after the last qualifying packet in the group's own Seq order, not
the first: cutting at the first would leave a later verified packet in
the group waiting for its shuffled turn, letting the rest of the batch
run one check behind whatever it just changed — the same failure the
carve-out exists to prevent, and cutting at the last also keeps the
whole applied-first prefix in the origin's own ascending Seq order, so
nothing in the deferred remainder can ever be mistaken for a replay of
what already applied. The deferred remainder, if any, takes its chances
in the shuffle exactly like any other group's packets, Coordinator's
board included when it has nothing signed and verified to offer at all.
The verification half matters because staging happens before any
signature is examined: without it, an origin could buy first-mover
priority simply by setting LeagueNodes on an unsigned packet, no
forged FromNode/FromBoard required.
This only does what it is meant to when the verified-orders packets
actually carry the lowest Seq in their group. ExportNodeList,
ExportLeagueConfig, and ExportBulletins all prepend their packet
to Outbox rather than appending: StampOutbox assigns Seq in
Outbox slice order, and the Coordinator's own player actions from
earlier in the day are already queued there by the time a scheduled
planetary run gets to these exports. Appending would give them the
highest Seq of the batch instead of the lowest, which would make the
split land after everything — the entire group, ordinary gameplay
included, exactly the bug this section starts by describing.
Every other group is applied in an order reshuffled every run, read from
crypto/rand and nothing derived from packet content — so no origin can
grind for a favorable position by crafting what it sends. The order
actually applied is written to the sysop's planetary log (not the
in-session report an interactive door caller sees) whenever a batch held
an actual choice between more than one origin, for auditability.
A packet that fails to parse as JSON is moved aside into bad/
(BadDir) instead of aborting the run — see "Quarantined packets" in
docs/inter-bbs-troubleshooting.md for the sysop-facing behavior (the grace period for
an in-flight transfer, the cap on same-named copies, and why nothing
empties the directory automatically).
Interplanetary trading (#47): the compatibility rule¶
The three trading fields are new in v0.0.5 and are the first change to this format since boards began signing packets, so the compatibility rule matters.
They are omitempty, and that is load-bearing. The origin signature is taken
over the marshalled packet (boardSigningBytes), so a board too old to know a
field drops it on unmarshal and then computes a different signature. If these
fields were always emitted, EVERY packet would fail verification on an older
board. Omitting them when empty keeps every packet that carries no trading byte
identical across versions — which is every packet an old board could act on
anyway. TestTradingFieldsAreOmittedWhenEmpty holds that line.
What does break, unavoidably: a packet that actually carries trading will not verify on a board older than v0.0.5. That is the honest cost of the feature, and it degrades sensibly — the old board rejects the packet rather than misinterpreting it, and its barons simply never see the Trading menu.
The Coordinator payload: accepted shapes¶
The Coordinator's Signature covers a payload of its own, not the whole packet:
FromBoard, Seq, LeagueConfig, LeagueNodes, Reset, Bulletins, in that
order. omitempty cannot do for it what it does for the packet — the payload is
assembled from named fields, so adding one changes the bytes of every packet, and
a field left nil marshals as null rather than vanishing. Adding Bulletins did
exactly that: a Coordinator on the older build signed five fields while every
board built since verified six, and each refused the other's league orders
silently.
A receiver therefore verifies against every payload shape a released build signed,
newest first — today the six fields above, then the five without Bulletins. A
shorter shape is accepted only when the packet leaves every field beyond it empty:
a signature taken before Bulletins existed cannot have covered a bulletin set,
so accepting one for a packet that carries bulletins would apply content nothing
signed. Adding a field to the payload means appending its old length to
payloadShapes in internal/game/ibbs_auth.go.
A refused packet is HELD, not destroyed (#185). ApplyPacket returns on an
origin refusal before it applies anything or records the packet as seen, so the
file is moved to the held/ directory rather than removed, and every later
planetary run puts it back through the ordinary inbound path — league check,
duplicate check, addressing, both signatures. A board that is given its roster
key afterwards therefore applies its own backlog with nobody doing anything.
That mattered most for a ruleset broadcast, back when the ruleset was the
one Coordinator-authored thing that did not repeat: a ruleset refused once was
gone permanently, and a re-send would then be discarded as a duplicate. The
Coordinator now re-sends its ruleset alongside its roster and its bulletins on
every planetary run (#264), so all three recover by themselves; -league-config
remains the way to push a change without waiting for the next run. The incident that produced this had six boards and a
signed-payload mismatch (above), with one member frozen at four roster entries
against the league's six and not waiting on anything.
The wait is bounded by HeldMaxAge (30 days, internal/store/held.go), measured
from when the file was set aside: a board that is genuinely forging never starts
verifying, and its packets would otherwise accumulate for the life of the league.
Upgrading a league across a protocol bump¶
A league does not roll a protocol change through board by board. It closes the game, lets every board finish sending what it has queued, and only then switches to the new release together. A held packet is released only when the READER comes to speak the number the packet already carries, so at a staggered upgrade the board that moves first holds everything from the boards still behind it and nothing ever releases it.
The Coordinator's version requirement¶
LeagueConfig.MinBoardVersion lets the Coordinator require a game version of
every board ("" = no requirement). A packet from a board below it is refused
whole, with a news line naming the board and the version it runs; BBSINFO.LST
marks the same board (below vX.Y.Z) so a Coordinator can see who is holding
the league up without waiting for a bounce.
A board that states NO version fails a set requirement. That is deliberate: it predates boards saying so at all, which puts it below any version worth requiring, and a board that cannot state its version cannot prove it meets the bar.
UNVERIFIED — how the original behaves. It is said to stop the Coordinator processing outbound traffic at all until the laggard upgrades. That comes from recollection, not from the binary or the docs, and holding a whole league hostage to one stale board is destructive enough that IB does not copy it on a maybe: IB refuses only the offending board. Worth settling if anyone can read the original's inter-BBS path.
Component types:
IPTradeBid { "ID": 12, "FromBoard": "AlphaBBS", "FromOwner": "khan",
"FromEmpire": "Ironhold", "Seller": "Redlands", "Good": "Tank",
"Qty": 40, "Price": 500 }
// A BID, not a purchase. The buyer's gold left their hands when
// this was queued; the receiving board fills it only if Seller
// still offers Good at exactly Price, and refuses otherwise.
// The ALLIANCE is judged on arrival too, not as the buyer saw it
// — one broken while the packet was in transit closes the market
// and the gold goes home, the same arrival-time rule an incoming
// strike's New Realm Protection check follows.
IPTradeFill { "ID": 12, "Filled": true, "Good": "Tank", "Qty": 40,
"Gold": 0, "Reason": "" }
// The answer. Gold is the refund for whatever did not fill (the
// whole bid when Filled is false, the remainder on a partial
// fill). Reason is the seller-side wording, so the buyer is told
// why rather than just handed their money back.
RemoteListing { "Realm": "Redlands", "Good": "Tank", "Qty": 100, "Price": 500 }
// One row of the sender's market. A SNAPSHOT: by the time a bid
// against it lands, a packet round trip has passed and the
// listing may be gone, smaller or repriced. That staleness is
// the whole reason bids exist.
RemoteScore { "Empire": "Asgard", "NetWorth": 1281, "Land": 100, "Score": 940,
"Protected": true, "OwnerHash": "3f6a1c09b2d84e57",
"FormerName": "Vanaheim" }
// Protected = still under New Realm Protection, so the boards
// that read this leave it off their target lists. Absent in a
// packet written before the field existed, which reads as
// unprotected. Advisory: the target board still refuses an
// arriving strike on its own authority.
//
// OwnerHash feeds duplicate-user checking: the first 16 hex
// characters of the SHA-256 of the caller's normalized BBS
// handle. A HASH, not the handle — a scores packet lands on
// every board in the league and is kept there, and no sysop
// needs another board's user list to answer "is this the same
// person". Present only while the sending board has Dupe
// Checking on; absent otherwise and in older packets, and a
// board that sends none releases the locks it had asserted.
//
// FormerName is the name this realm carried before its one
// rename, so the receiving board can say the two rows are one
// realm rather than a departure and an arrival. It rides in
// every export for the rest of the realm's life, so the
// RECEIVER bounds the news: it posts the rename only while the
// snapshot it is replacing still held the old name. Introducing
// it moved Protocol to 2 — it is signed, and once a realm has
// renamed it is in every scores packet, so an older board would
// re-marshal without it and fail the origin signature.
RemoteAttack { "ID": 1, "FromBoard": "AlphaBBS", "TargetEmpire": "Victim",
"Offense": 150000, "Contributors": [ Contribution ] }
// TargetEmpire "" = whole planet (strongest defender)
Contribution { "Owner": "andy", "Troopers": 90000, "Jets": 0, "Tanks": 1000,
"Bombers": 0, "Tech": 12800 }
// One baron's detachment. Tech is their Technology military
// factor when they committed it, in 1/10000ths (10000 = x1,
// 14000 = the 1.4 ceiling); the target board weighs the slot
// by it, as the original's force record carries the same
// value. Absent in a packet written before it existed, which
// reads as x1.
AttackResult { "ID": 1, "TargetBoard": "BravoBBS", "TargetEmpire": "Victim",
"LandTaken": 12, "Won": true, "Kind": "Normal Attack",
"Survivors": [ Contribution ],
"Outcome": "success",
"Enemy": { "Troopers": 900, "Turrets": 150, "Tanks": 40, "Jets": 0 } }
// Outcome is the verdict the origin reports to the baron:
// "success" / "failure" / "notfound" / "protected". Absent in a
// packet written before it existed, which reads as Won deciding
// between success and failure — how that packet was resolved.
// Enemy is what the strike destroyed, by unit type; absent
// likewise, and an absent one reports nothing destroyed rather
// than guessing.
TimeCheck { "From": "AlphaBBS", "To": "BravoBBS",
"Sent": "2026-07-04T18:02:11+10:00" }
// From is the only board that reads the elapsed time; To echoes
// the record back UNCHANGED. RFC3339, so the two clocks may sit
// in different zones.
IPMessage { "FromBoard": "AlphaBBS", "FromEmpire": "Asgard",
"ToCoordinator": false, "ToEmpire": "",
"When": "07/04/2026 18:02:11", "Body": "..." }
// neither To* set = every realm on ToBoard reads it
A packet addressed to a specific board (ToBoard/ToNode) is matched by
ToNode first when both this board's own roster number and the packet's are
known, falling back to ToBoard otherwise (World.AddressedToMe) — the same
preference VerifyBoardOrigin and the Coordinator check give FromNode over
FromBoard (#105). A packet whose Epoch is older than this board's current
World.Epoch is discarded before anything else runs: it was written for a
game this board has since wiped by resetting (#104).
Processing (World.ApplyPacket). A packet addressed to this board, or a
broadcast, is applied payload by payload:
- Scores import into
RemoteBoards. - Attacks and terror ops resolve, producing results returned to the origin.
- Incoming results give each contributor their survivors, a private report and their share of the captured land, and post a line to the planetary bulletin. If nothing is waiting on that result's ID the whole result is discarded rather than paid out — the lost-forces timer has already returned the army, or this is a duplicate.
- Recon requests are answered from live figures.
- IP messages are delivered to the mailboxes they name.
- Time checks naming this board are echoed back untouched; one of this
board's own coming home is folded into
World.TravelTimes.
A packet addressed to a different board depends on the league's shape:
- Routed (
World.Routed— the roster carries HOST lines, or this board has route rules): the packet is taken from the inbound directory and queued onWorld.Transit, to be written out again on the link for its next hop (#106). It is forwarded byte for byte apart fromHops, because itsSeqandSignaturebelong to the board that wrote it — a hub that re-stamped one would be vouching for another board's orders. - Unrouted: the packet is left alone. The transport there copies every packet to every board, so the addressee already has it.
A packet carrying a different league's number is left alone.
A reply packet is written whenever it carries anything at all
(Packet.HasPayload) — an answer that is only an echoed probe, or only recon
reports, still has to go out.
Node list: ibnodes.dat¶
Plain text, one board per six-line block, blank line between blocks (BRE's
BRNODES.DAT layout), plus an optional seventh line IB adds. Loaded at startup
into World.LeagueNodes.
1 node number, 1 to 999 (1 = League Coordinator), optionally "1 HOST 2 4"
Avalon board / planet name
363/277 network address
Orlando city
FL state / province
USA country
4e1b…8d3 OPTIONAL: that board's packet-signing public key (#118)
The seventh line is read by index, so a roster written without it parses
unchanged and one written with it is ignored by an older board. BoardPublicKey
hex-decodes the whole trimmed line and requires 32 bytes, so anything else
on it — a board name, a label — makes the entry decode to no key at all. That
reads as "this board has no key published", which applies its packets unchecked
rather than raising an error, so a malformed key line silently disables the
check it was meant to turn on. -gen-board-key prints the key alone for this
reason.
The first line may carry BRE's HOST routing: the node's own number, HOST, then
the numbers it forwards for. The roster is the league's routing table, and it is
signed and broadcast by the Coordinator (#64), so every board gets the tree
without any sysop editing anything.
Routing applies only once a roster carries a HOST line. Until then a league is a mesh and the transport fans packets out, which is what every existing board does.
BRE also let a board override the roster with its own ROUTE.CFG. IB read that
file until v0.0.7 and no longer does: BRE's own sample says a league whose
Coordinator keeps routing in the roster needs no such file, and three of the
file's four keywords (CRASH, HOLD, NORMAL) set a FidoNet mailer's send
priority, which is the transport's business rather than the game's. One routing
table, held by the Coordinator, is what remains.
Board config: bbs.cfg¶
The per-board settings — BoardID, LeagueNumber, InboundDir, OutboundDir,
OutboundDirs, Lottery, and PirateNews — live here rather than in
config.json, and are marked
json:"-" on game.Config so they cannot land in both. config.json is
rewritten by a Coordinator's ruleset broadcast, which is no place for settings
that describe one board's own machine.
Keyword per line, store.LoadBoardConfig / SaveBoardConfig, comments with #
or ;, keywords matched case-insensitively, unknown keywords ignored:
BoardID Avalon
LeagueNumber 900
Inbound /home/bbs/ftn/in
Outbound /home/bbs/filebox/uplink
Link 3 /home/bbs/filebox/node3
Lottery yes
PirateNews yes
Lottery and PirateNews are the only rules in the file, and the two
exceptions TestEveryGameRuleIsBroadcast names: BRE keeps both questions in the
per-install RESOURCE.DAT, so they are each sysop's, not the league's. They take
yes/no, on/off or true/false, and an unreadable value leaves the default (on)
alone. PirateNews no suppresses the news line a pirate raid posts and nothing
else — the raid, its loot, its losses and the raider's own report are unchanged.
Not BRE's positional seven lines (sysop, planet, address, inbound, netmail dir,
league, mailer). Positional cannot express Link at all, and a blank field
shifts every field after it — which is what most of BRE's own InterBBS
troubleshooting section is about. The game stores no mailer name or netmail
directory here. FTN addresses are already roster data in ibnodes.dat; the
optional barons-ftn adapter keeps its netmail directory and Binkley-mode
switch in the separate ftn.cfg.
store.ParseBoardConfig reads BRE's own positional format, wired to
-ibbs-reset -import-bbs-cfg PATH for a sysop converting a league they already
run. It takes the planet name, the incoming-files directory and the league
number. The sysop name, FTN address, netmail directory, and mailer are not
imported: the roster and optional ftn.cfg own those values, and BRE's netmail
directory must not become OutboundDir — BRE puts .MSG files there, while
IB's outbound holds the packets themselves.
The path is explicit rather than a scan of the data directory: BBS.CFG and
bbs.cfg are the same filename on macOS and Windows, so a scan would find the
board's own config and parse it positionally.
Migration. A board set up before the split has these in config.json and
nowhere else, so LoadConfig reads them back from the raw JSON before applying
bbs.cfg over the top; the next SaveConfig writes bbs.cfg and drops them
from config.json. A sysop who opens neither file sees nothing happen.
How BRE tells another board something: NEWS_DATA¶
The original has one channel for "put this line in that planet's news", and it
is a packet type of its own. append_news_record (BRE.OVR 0x048a79) builds a
258-byte record — our board number, the destination board number, and a
255-byte line — looks up the NEWS_DATA type code and writes it out. Seven
routines use it: create_group_attack, fund_gooie_kablooie,
launch_gooie_kablooie, dismantle_gooie_kablooie, estimate_attack_arrival,
show_gooie_arrival_time and report_suspected_cheating. So every SpyGuy
report, and the warning that a weapon is on its way, reaches the far planet as
planet news, not as mail and not as a private notice.
IB carries the same thing as Packet.News []string, posted to NewsToday on
arrival. The watcher himself rides as Packet.SpyGuys []SpyGuyDispatch
({FromBoard, Days}), which is BRE's three-byte SPY_GUY record — from board,
to board, days — in IB's own shape.
Code map¶
internal/game/ibbs.go— the packet itself: what it carries, how it is addressed, how an arriving one is applied. The mechanics that ride in it sit beside it asibbs_attack.go,ibbs_terror.go,ibbs_league.go,ibbs_spy.go,ibbs_annihilator.goand the rest of theibbs_*.gofamily.internal/store/ibbs.go—WriteOutbox,ReadInbound,RunPlanetary.internal/game/ibbs_route.go—NextHop,ForwardPacket, the HOST tree.internal/store/league.go—ParseNodeList,ParseBoardConfig.scripts/ibbs-smoke.sh— end-to-end 3-board exchange with the real binary.
Verified against real BRE (2026-07-22)¶
The clone's inter-BBS model was cross-checked by running a two-board BRE 0.988 InterBBS league (coordinator + member) locally under dosemu2 and comparing its exchange against IB's Option A packet. Findings and the remaining gaps are tracked in #60.
Local two-board setup (no mailer). BRE's own "Local InterBBS Setup" runs
several boards on one machine with no front-end mailer: each board's inbound
directory points directly at the other board's \OUTBOUND, and a ROUTE.CFG
forms a circle (ROUTE * 2 on board 1, ROUTE * 1 on board 2). BBS.CFG line
4 is the inbound-file dir, line 5 the outbound/netmail dir. This works because
the boards share a disk. The clone's InboundDir/OutboundDir are the direct
analogue.
Exchange commands. BRE runs maintenance from the command line:
BRE PLANETARY (read inbound, then write outbound — the equivalent of
immortal-barons -planetary), split into BRE INBOUND (read + route) and
BRE OUTBOUND (write). A league-wide reset by the coordinator propagates to
members: a member's next PLANETARY wipes and rebuilds its world from the
coordinator's reset packet.
Transport format (what IB deliberately does not copy). BRE moves files as
FidoNet FTS-0001 netmail (N.msg, from/to user "BRE System", subject = the
attached file's absolute path, file-attach attribute, INTL kludge) carrying a
compressed binary data packet named <league>b<from><to>.<seq> (BRE's own
fd-escape packer, ~90% ratio) plus a broadcast brnodes.<league> node list.
IB uses plain JSON .brp instead — an intentional clean-room simplification.
The transport differs; the contents are what fidelity is judged on.
Packet contents (BRE's PLANETARY stages). Local recon info; global recon
requests; routing data; node list; group attacks; individual IP-attack info;
Gooie Kablooie status; scores/news; coordinator config + reset. IB currently carries scores, group attacks, terror ops, results, and the
LeagueConfig ruleset broadcast; the recon exchange, individual interplanetary
attacks, cross-board Gooie Kablooie status, node-list broadcast, and
league-wide reset are the open gaps under #60.
Config field set. BRE's coordinator config editor (the LeagueConfig
analogue) marks league-wide fields with * = "InterBBS Setting Only": Attack /
Terrorist Costs, Individual / Group / Terrorist Attacks per day, Bombings per
day, Days for Lost Attacks, Gooie Kablooies, Bombing / Missile Operations, Local
Attacks, Local Attack Scoring, Dupe Checking — alongside the non-* general
settings (turns/day, protection, land, interest, tax, region caps, maintenance /
trade-deal / region costs, attack damage / rewards).