Features
The BBS is back. And it’s yours.
The Exchange isn’t a nostalgia project. It’s a real-time, federated communication platform that happens to wear a terminal aesthetic. Every feature below exists for a reason — to make the BBS a living, breathing community platform that a sysop can shape without recompiling, restarting, or asking permission.
1. Phosphor Language
Every BBS before The Exchange forced you into one of two hells: edit a static config file and hope the parser doesn’t choke on a stray tab, or crack open the Java source, recompile, and restart the whole system to move a menu item. Phosphor is a third option — a domain-specific language designed for BBS behavior. It’s not config, and it’s not Java. It’s executable scripting that the system hot-loads.
The old way: Java recompile
You want to add a “File Area” option to the main menu. In a Java-based BBS, that’s:
// MainMenuScreen.java — edit, recompile, restart
public class MainMenuScreen extends Screen {
@Override
public void render(TerminalContext ctx) {
ctx.println("[1] Message Boards");
ctx.println("[2] Chat Lounge");
// you add this line:
ctx.println("[3] File Area");
ctx.println("[Q] Quit");
}
@Override
public void onInput(String input, TerminalContext ctx) {
switch (input.toUpperCase()) {
case "1" -> ctx.navigate("boards");
case "2" -> ctx.navigate("chat");
// you add this case:
case "3" -> ctx.navigate("files");
case "Q" -> ctx.navigate("logout");
}
}
}
// then: mvn package, kill the JVM, restart, pray nothing else broke
That’s a 15-minute round-trip — edit, compile, deploy, restart — for a single menu entry. Multiply by every screen, every board, every game, every theme. Classic BBS software dies here.
The Phosphor way
Same change, one file, no restart:
menu "main" {
option "1" "Message Boards" -> boards
option "2" "Chat Lounge" -> chat
option "3" "File Area" -> files // new line
option "Q" "Quit" -> logout
}
Save the file. The BBS picks it up on the next render cycle. No compile, no restart, no downtime. This is the fundamental shift: the BBS is editable while it runs.
Phosphor covers the full surface area of the system — menus, boards, screens, themes, door games, mail filters, federation rules. It’s a single language for everything, not a patchwork of YAML, JSON, Lua, and shell scripts held together with duct tape.
// bbs.phos — the whole system, one file if you want
board "General" {
description = "General discussion — anything goes"
read = ["users"]
write = ["users"]
federated = true
max_msg_size = 4096
on post {
if msg.length > max_msg_size {
reject("Message exceeds {max_msg_size} bytes")
}
}
}
screen "welcome" {
render {
ansi_gradient("THE EXCHANGE", colors: ["red", "yellow", "cyan"])
println("")
println("Connected as {user.name} — last login {user.last_login}")
println("{stats.online_users} users online across {stats.federated_nodes} nodes")
}
}
2. Dual Terminal + Web
The Exchange runs two front-ends off one backend. SSH for the terminal purists. WebSocket for the browser users. Both connect to the same event bus, read the same database, and see the same users. There is no “terminal version” and “web version” — there’s one BBS with two doors.
┌─────────────────────────────┐
│ Reactive Event Bus │
│ (Postgres + WebSockets) │
└──────────┬─────────┬────────┘
│ │
┌────────────────┘ └────────────────┐
│ │
┌────────▼────────┐ ┌─────────▼─────────┐
│ SSH / Telnet │ │ WebSocket / Web │
│ (jterm) │ │ (browser client) │
│ ANSI terminal │ │ HTML + CSS render │
└─────────────────┘ └───────────────────┘
A chat message typed in an SSH session in New York hits the event bus, fans out to every connected client — terminal and web alike — and appears in a browser tab in Berlin within the same frame. The web client isn’t a separate app with a sync delay. It’s a render target on the same bus.
// This handler runs identically for both SSH and web clients
on chat_message(room: "lounge") {
broadcast(room, {
user: msg.user,
text: msg.text,
timestamp: now(),
// web clients get this as JSON over WebSocket
// terminal clients get this as ANSI-formatted text
})
}
No bridge. No translation layer. No “sync job” that runs every 30 seconds. The event bus is the source of truth, and both transports subscribe to it directly.
3. Federation
A BBS shouldn’t be an island. The Exchange federates — multiple independent nodes sync boards, mail, and events across the internet via jnet, the federation transport. A post on the Bronxville node shows up on the Ohio node in real-time. Go offline for a week? You get backfill on reconnect, not a gap.
Events, not entities
Most federated systems sync entity rows — they replicate the database table and hope timestamps sort everything out. The Exchange syncs events. An event is “user X posted message Y to board Z at time T.” The node replays events into its own state. This means:
- Backfill is trivial — missed events replay in order on reconnect.
- Conflict resolution is deterministic — last-write-wins per key, with vector clocks for causality. No merge dialogs, no “choose your version.”
- Nodes can diverge and reconverge — a node can go dark, process locally, and sync cleanly when it comes back.
Federated board declaration
board "Trade Post" {
description = "Buy, sell, trade — federated across all nodes"
read = ["users"]
write = ["users"]
federated = true
// which nodes replicate this board
peers = ["bronxville.exchange", "ohio.exchange", "pdx.exchange"]
// events propagate; entities stay local
sync_mode = "eventual"
conflict = "last_write_wins"
on post {
// fires on every node that replicates this board
emit("board.posted", {
board: "Trade Post",
msg_id: msg.id,
node: local_node()
})
}
}
// jnet node identity — declared once per instance
node "bronxville.exchange" {
bind = "0.0.0.0:9400"
peers = ["ohio.exchange", "pdx.exchange"]
// reconnect behavior
backfill = true // pull missed events on reconnect
backfill_window = "30d" // up to 30 days of history
}
When Bronxville loses its uplink, it queues events locally. When the link comes back, jnet replays the queue and pulls backfill from peers. Users on the disconnected node kept posting — their messages land on the network when connectivity returns. No data loss, no manual reconciliation.
4. Reactive Everything
Screens in The Exchange are live views, not snapshots. There’s no refresh key. There’s no polling loop. There’s no “log out and back in to see new mail.” The UI binds to reactive state, and when the state changes, the screen updates in the same frame.
Three primitives:
bind— attach a screen element to a data source. When the data changes, the element re-renders.computed— derive a value from other reactive values. Recomputes automatically when dependencies change.on— run a handler when a reactive value or event fires. Side effects go here.
Reactive inbox
screen "inbox" {
// bind the message list to the user's mail state
// this updates live — no refresh, no polling
bind messages = user.inbox
// derived value: unread count recalculates when messages change
computed unread = messages.filter(m -> !m.read).count()
// when new mail arrives, the list updates AND we flash a notice
on mail_received {
flash("New mail from {msg.from}")
// the bind above already re-rendered the list — nothing else to do
}
render {
ansi_gradient("INBOX", colors: ["cyan", "blue"])
println("{unread} unread / {messages.count()} total")
println("─────────────────────────────────")
for m in messages {
marker = m.read ? " " : "●"
println("{marker} [{m.date}] {m.from:20} {m.subject}")
}
}
}
The bind is the contract. When user.inbox mutates — because a mail event arrived from another node, another user, or the local system — the screen re-renders. The sysop didn’t write a poller. The user didn’t press F5. The framework handled it.
This extends everywhere: board feeds update when posts land, the user list refreshes when someone connects, the chat scrollback grows in real-time. Reactive is the default, not an opt-in.
5. User Scripting Sandbox
In the 90s, BBS users wrote their own door games, macros, and auto-responders. The Exchange brings that back — safely. Every user can write user_handler blocks in Phosphor. These run in a per-session sandbox: the user can read and modify their own state (mail, settings, session vars) but cannot touch other users, the filesystem, the system config, or other sessions.
Mail auto-filter
// written by a regular user, not a sysop
// runs only for this user's session
user_handler on mail_received {
// route mail from known senders into folders
if msg.from == "boss@work.exchange" {
mail_move(user.id, msg.id, "Work")
flash("Work mail filed")
return
}
// auto-delete spam by keyword
if msg.subject contains "viagra" or msg.subject contains "lottery" {
mail_delete(user.id, msg.id)
return
}
// auto-reply when away
if user.flag("away") and !msg.from.startswith("noreply") {
mail_send(
to: msg.from,
subject: "Re: {msg.subject}",
body: "I'm away until {user.flag('away_until')}. Will reply then."
)
}
}
What users CAN do
- Read and filter their own mail
- Set auto-replies and forwarding rules
- Define personal chat macros and keybindings
- Customize their own screen themes and ANSI preferences
- Write personal door game high-score hooks (read-only on global scoreboards)
- Store state in a per-user key-value sandbox (quota-limited)
What users CANNOT do
- No filesystem access — no reading or writing server files
- No network calls — no outbound HTTP, no sockets, no jnet
- No other users’ data — mail, state, sessions are isolated
- No system config — can’t touch boards, menus, federation, or sysop settings
- No escalation — can’t spawn processes, load modules, or import system libraries
- No infinite loops — the sandbox enforces instruction and time limits per handler
The sandbox is enforced at the interpreter level, not by convention. A user handler that tries mail_delete(other_user.id, ...) gets a runtime error, not a deleted message.
6. ANSI Art Pipeline
The terminal aesthetic isn’t a CSS theme — it’s generated art. The Exchange ships a full ANSI pipeline for programmatic art creation:
ansi_gen— FIGlet-style block text, box drawing, borders, panelsansi_gradient— multi-stop color gradients across text and regionsimage_to_ansi— convert PNG/JPEG to ANSI block art with 256-color or truecoloransi_scroll— animated marquee/ticker regions that update in place
Welcome banner with gradient
screen "login" {
render {
// 3-stop gradient across the banner text
ansi_gradient("THE EXCHANGE", colors: ["#ff0066", "#ffaa00", "#00ddff"], style: "block")
// a bordered info panel
panel(border: "double") {
println(" Node: {local_node()}")
println(" Users online: {stats.online_users}")
println(" Federation: {stats.federated_nodes} peers")
}
// convert a logo image to ANSI, center it
art = image_to_ansi("assets/logo.png", width: 60, colors: "256")
println(center(art))
// animated ticker at the bottom — updates every 2s
ansi_scroll(motd, speed: 2, style: "marquee")
}
}
Image-to-ANSI in a door game
door "dungeon" {
room "throne" {
on enter {
// render a PNG as ANSI art for the room scene
scene = image_to_ansi("scenes/throne.png", width: 70, dither: "floyd")
println(scene)
println("The throne room. Dusty. A crown glints on the seat.")
}
}
}
Themes can shift based on time of day, user preference, or game state. The art is generated at render time, not pre-baked — change the gradient colors or the source image and the next frame is different.
7. In-BBS Editor
Sysops shouldn’t need SSH to edit the BBS. The Exchange has a full-screen Phosphor editor inside the terminal — syntax highlighting, auto-complete, live parse feedback, and save-to-apply. You connect to your own BBS, hit the editor key, and change the system live.
What the editor does
- Syntax highlighting — keywords, strings, comments, board/screen/door names all color-coded
- Auto-complete — context-aware completion for Phosphor keywords, declared boards, screens, and built-in functions
- Live parse feedback — as you type, the editor checks syntax and marks errors inline. No saving to find out you missed a brace.
- Save-to-apply — when you save, the file hot-loads into the running BBS. No restart, no deploy step.
// The editor experience, conceptually:
// [F2] Save & Apply [F3] Open File [F4] Jump to Symbol [Ctrl-Q] Quit
//
// ┌─ bbs.phos ──────────────────────────────────── 12:04 ─┐
// │ 1 board "General" { │
// │ 2 description = "General discussion" │
// │ 3 read = ["users"] │
// │ 4 write = ["users"] │
// │ 5 federated = true │
// │ 6 on post { │
// │ 7 if msg.length > 4096 │ ← undeclared 'msg' │
// │ 8 reject("Too long") │
// │ 9 } │
// │10 } │
// └────────────────────────────────────────────────────────┘
// Status: 1 error (line 7) — 'msg' not in scope in 'on post'
The error on line 7 appears while you’re typing, not after you save. Fix it, save, and the board definition is live. The edit-deploy-verify loop collapses to “edit, save, done.”
No SSH. No SCP. No git push and CI build. No Docker rebuild. You edit the BBS from the BBS.
8. Door Games in Phosphor
Door games were the soul of 90s BBSes — Tradewars, Legend of the Red Dragon, Lunatix. The Exchange brings them back, written in the same language as the BBS itself. No separate runtime, no external process, no shared-memory hacks. A door game is just a door block in Phosphor.
Simple room declaration
door "dungeon" {
title "The Caverns of Bronxville"
max_players = 1
save_state = true // persists per-user between sessions
// global state — survives across rooms and sessions
state {
room = "entrance"
hp = 100
gold = 0
inventory = []
}
room "entrance" {
on enter {
ansi_gradient("CAVE ENTRANCE", colors: ["gray", "dark_gray"])
println("You stand at the mouth of a cave. Cold air.")
println("Exits: [n] north (dark tunnel), [s] south (back to town)")
}
on input "n" -> goto "tunnel"
on input "s" -> goto "town"
}
room "tunnel" {
on enter {
println("Dark tunnel. You hear dripping.")
if !state.inventory.contains("torch") {
println("You can't see. You should find a torch.")
return
}
println("Your torch reveals a chest to the [e]ast.")
}
on input "e" -> goto "chest_room"
on input "w" -> goto "entrance"
}
}
// Scoreboard — shared across all players on this node
scoreboard "dungeon" {
metric = "gold"
sort = "desc"
display_top = 10
federated = true // syncs across nodes
}
What door blocks give you
save_state— per-user state persists between sessions automatically. A player can quit on Tuesday and resume Thursday in the same room with the same inventory.scoreboard— declare a scoreboard and it tracks the metric, sorts, and displays. Federated scoreboards sync across nodes.room— rooms are first-class. Each hason enter,on input, and can transition to other rooms withgoto.- ANSI per room — use the full art pipeline inside any room render.
- Multiplayer sync — set
max_players > 1and players in the same room see each other’s actions in real-time.
Share a door game as a .phos file. Drop it in the modules directory, it’s playable. Like 90s door game packages, but open and hackable.
9. Modern Stack, Retro Soul
This isn’t a Python script running in a basement. The Exchange is an engineered system with a production-grade stack:
| Layer | Technology | Why |
|---|---|---|
| Runtime | Java 26 | Virtual threads, pattern matching, records. Real performance, real concurrency. |
| Storage | PostgreSQL | ACID, JSONB, row-level security. The event bus is backed by Postgres logical replication. |
| Real-time | WebSockets | Browser clients get push updates over a persistent WS connection. Same bus as terminal. |
| Event bus | Reactive event bus | In-process pub/sub backed by Postgres NOTIFY + jnet for cross-node propagation. |
| Terminal | jterm | A proper terminal framework — ANSI parsing, screen rendering, input handling, VT220 support. Not a raw socket hack. |
| Federation | jnet | Event-based sync transport with backfill, vector clocks, and deterministic conflict resolution. |
The reactive event bus is the spine. Terminal input, web input, mail delivery, board posts, federation events, and door game state changes all publish to the same bus. Subscribers — screens, handlers, jnet peers — react to events as they land. There’s no polling, no cron job scanning for changes, no “sync service” that runs every 60 seconds.
// The event bus is first-class in Phosphor
on "board.posted" {
// fires for local posts and federated posts alike
if event.board == "General" and event.user != local_user() {
flash("{event.user} posted to General")
}
}
// publish your own events
emit("custom.greeting", { from: user.name, room: "lounge" })
This stack scales. One node handles hundreds of concurrent terminal sessions. Federation lets you grow horizontally. Postgres gives you real durability — power loss doesn’t eat your message base.
10. Module Marketplace
The 90s BBS ecosystem thrived on shareable door games, ANSI packs, and mod scripts passed around on floppy disks and FidoNet. The Exchange brings that back with a module system and a community marketplace.
Install a module
phosphor install dungeon-game
phosphor install ansi-pack-retrowave
phosphor install mail-filters-pro
Modules are .phos files — boards, doors, screens, handlers, themes, art assets — packaged and shared. Install one and it drops into the live system. No recompile, no restart, no dependency hell.
Author and share
# package your module
phosphor pack my-door-game/ --name "tavern-quest" --version 1.0
# publish to the community registry
phosphor publish tavern-quest
// module.phos — module manifest
module "tavern-quest" {
version = "1.0"
author = "bronxville.exchange"
description = "A multi-room tavern adventure with a bar fight mini-game"
requires = ["phosphor >= 1.2"]
provides = [
door("tavern"),
board("tavern-quest-discussion"),
scoreboard("tavern-quest")
]
config {
difficulty = "medium"
max_players = 4
}
}
The marketplace is community-run. No corporate gatekeeper, no app store review board. Sysops publish, sysops install, sysops review. If a module is bad, the community says so. If it’s good, it propagates across the federation naturally.
11. Off the Grid + MeshCore LoRa
The Exchange runs on your server. Not AWS. Not Google Cloud. Not Big Tech infrastructure.
- No algorithms deciding what you see, in what order, with what engagement weighting
- No engagement metrics optimizing your attention for ad revenue
- No data harvesting — your conversations aren’t scraped, sold, or trained on
- No ads — ever, full stop
- No tracking — no analytics, no pixels, no fingerprinting, no telemetry
- No phone home — the binary doesn’t call a license server or a usage collector
- Federated — messages sync peer-to-peer across independent nodes, not through a central silo
- Self-hosted — your data lives on your hardware, under your control, subject to your deletion
This is counter-culture infrastructure. The internet was supposed to be decentralized, community-owned, and free. The BBS scene was that — a network of independent nodes run by independent sysops, federated through FidoNet, accountable to their communities. Then Big Tech paved it over with walled gardens and engagement-optimized feeds.
The Exchange brings the old model back with modern engineering. You run a node. Your friend runs a node. Your community runs a node. They federate. Messages, boards, and games sync across the network. No central server owns the conversation. No corporation sits in the middle.
The BBS doesn’t just look different from the modern web. It is different. It’s a place you go on purpose, not a feed that comes to you.
MeshCore: Federation Without Internet
Here’s what no other BBS system has: LoRa mesh networking via MeshCore. The Exchange doesn’t just federate over the internet — it federates over radio.
MeshCore is a LoRa-based mesh networking protocol integrated into the BBS via jnet. Nodes sync boards, mail, and messages over long-range radio links — no internet required. A BBS in Bronxville syncs with a BBS in Ohio over a 900MHz LoRa link. No ISP. No cell tower. No WiFi. Just radio.
bbs "Bronxville Node" {
node_name = "bronxville"
federate_with = ["ohio", "florida"]
// Internet federation (jnet over TCP)
peer "ohio" {
host = "ohio.boremaj.net"
port = 9777
}
// Radio federation (MeshCore over LoRa)
peer "florida" {
transport = meshcore
band = "915MHz"
// LoRa: long range, low bandwidth, no internet needed
}
board "General" {
federated = true // syncs over BOTH internet and radio
}
}
Why this matters:
- Off-grid communities — A BBS in a remote cabin with no internet can still federate with other nodes via LoRa. Messages, boards, and mail sync over radio. The BBS works anywhere a radio can reach.
- Disaster resilience — When the internet goes down (storm, earthquake, ISP outage), the BBS keeps running. Nodes with LoRa links stay connected. Messages get through.
- Privacy — Radio links don’t pass through any ISP. There’s no ISP to log traffic, no cloud to intercept, no middleman at all. Point-to-point radio is as private as it gets.
- Classic BBS spirit — The original BBSes used dial-up modems over phone lines. MeshCore is the modern equivalent — direct radio links between nodes, no network operator in the middle. The BBS spirit, reborn for the RF age.
MeshCore features:
- Long-range LoRa links (miles, not feet)
- Self-healing mesh — if a node goes down, routes around it
- Automatic backfill on reconnect — missed messages arrive when the link comes back
- Chunked message delivery for large payloads (board posts, mail)
- Deduplication — radio is unreliable, MeshCore handles retransmits automatically
- Works alongside TCP federation — same BBS can use both internet and radio peers
The BBS doesn’t just look different from the modern web. It is different. It’s a place you go on purpose, not a feed that comes to you. And with MeshCore, it works even when the internet doesn’t.
// sovereignty is a one-liner
node "your-town" {
bind = "0.0.0.0:9400"
peers = [] // start solo, add federation peers when you're ready
// your rules. your data. your community.
// add a LoRa peer and you don't even need the internet
}