Phosphor Language
A domain-specific language for BBS systems
Every classic BBS sysop knew the feeling. You’d tweak a menu, add a message board, wire up a door game, and customize the whole system from config files and scripts — no compiler, no restart, no asking permission. Then BBS software moved to Java, and that workflow died. Want to add a menu item? Write a class, compile with Maven, restart the server, rebuild the plugin JAR. Want to change a chat channel topic? Edit the database or write a migration. The edit-compile-restart-redeploy cycle is slow, error-prone, and inaccessible to anyone who doesn’t write Java.
Phosphor brings the old sysop workflow back to a modern platform. It’s a domain-specific language for BBS systems — a single language that describes the BBS structure declaratively, scripts dynamic behavior imperatively, hot-reloads at runtime, and reacts to data changes without polling. You write .phos files; the BBS reads them at startup and watches for changes. Save the file, see the change. No recompile. No restart. No JAR.
The design is built on six principles:
- Reactive by default. Screens bind to data sources and update live when the data changes. No polling. No refresh key. No logout/login. The runtime pushes changes to active screens via the existing Java event bus —
ChatBusListener,PostListener,SessionListener,MailService.SaveListener. - BBS-first. ANSI art, screen transitions, chat channels, message boards, and door games are language primitives, not library calls.
show "starfield.ans"displays art.say "..."prints a system line.mail from "SysOp" to user { ... }sends a message. - Dual runtime. Phosphor scripts and compiled Java coexist as first-class citizens. Prototype a screen in Phosphor; promote it to Java when you need performance or full API access. Same menu items, same navigation, same event bus. Both fire on the same events.
- Extensible by design. The language grows via compiled Java extensions — new functions, binding sources, event types, screen types, and widgets — dropped in as plugin JARs. No interpreter changes needed.
- Multi-user aware.
user,session,channel,board,msgare built-in context variables injected by the runtime. You don’t fetch the current user — it’s already there. - Declarative when possible, imperative when necessary. Structure is data; behavior is code. Don’t force sysops to write loops to seed a menu.
The declarative layer: describe your BBS as data
The declarative layer describes the BBS structure — menus, boards, chat channels, file areas, groups, themes, news sources — as data blocks. The runtime processes these at startup (and on hot-reload) to configure the database and runtime. No code, no logic, just structure.
Here’s a complete bbs.phos file for The Exchange:
// bbs.phos — main configuration for The Exchange BBS
bbs "The Exchange" {
tagline = "Terminal-based BBS powered by jterm"
sysop = "Joseph Bore"
max_sessions = 50
login_timeout = 120 // seconds
auto_approve = false
welcome_msg = "Welcome to The Exchange!"
latitude = 40.9312 // Bronxville, NY — for sunrise/sunset schedules
longitude = -73.8327
}
// ── Groups ──────────────────────────────────────────────
// Priority determines display order and permission precedence.
group "users" {
description = "Default group for all users"
priority = 1
}
group "vip" {
description = "VIP Members"
priority = 5
}
group "sysop" {
description = "System Operators"
priority = 100
}
// ── Menus ───────────────────────────────────────────────
// 'X' is the hotkey, "Label" is the display name, -> is the
// navigation target. `require group` restricts visibility.
// `sysop_only` is shorthand for `require group "sysop"`.
menu "main" {
'B' "Message Boards" -> boards
'C' "Live Chat" -> chat
'M' "Mail" -> mail
'N' "News" -> news
'F' "File Archive" -> files
'G' "Door Games" -> doors
'W' "Who's Online" -> whos_online
'X' "Stock & Finance" -> stock require group "vip"
'S' "SysOp Menu" -> sysop require group "sysop"
'L' "Lock Screen" -> lock
'Q' "Logout" -> disconnect
}
menu "sysop" {
'U' "User Admin" -> user_admin
'G' "Group Manager" -> group_manager
'C' "System Config" -> sys_config
'M' "Menu Editor" -> menu_editor
'N' "News Manager" -> news_manager
'J' "JNet Config" -> jnet_config
'B' "Board List" -> board_admin
'Q' "Back to Main" -> back
}
// ── Message Boards ─────────────────────────────────────
// `read` and `write` define group-based permissions.
board "General" {
description = "General discussion — anything goes"
read = ["users"]
write = ["users"]
}
board "VIP Lounge" {
description = "Private board for VIP members"
read = ["vip"]
write = ["vip"]
}
board "Announcements" {
description = "Official announcements (read-only)"
read = ["users"]
write = ["sysop"]
}
board "SysOp Only" {
description = "Internal sysop discussion"
read = ["sysop"]
write = ["sysop"]
}
// ── Chat Channels ──────────────────────────────────────
// DM channels (dm:userA:userB) are always available —
// not declared here. Only public channels need declarations.
chat "#lobby" {
topic = "The main lobby — say hi!"
}
chat "#vip" {
topic = "VIP lounge"
min_group = "vip"
}
chat "#sysop" {
topic = "SysOp coordination"
min_group = "sysop"
}
chat "#trading" {
topic = "Stock discussion and alerts"
min_group = "vip"
}
// ── File Areas ──────────────────────────────────────────
// Nested file_area blocks set parent_id for folder hierarchy.
// The `path` is a filesystem directory; files are listed automatically.
file_area "ANSI Art" {
description = "BBS ANSI art backgrounds and screens"
path = "data/files/ansi-art"
read = ["users"]
}
file_area "Text Files" {
description = "Text files, guides, and info"
path = "data/files/text"
read = ["users"]
}
file_area "SysOp Files" {
description = "SysOp-only file area"
path = "data/files/sysop"
read = ["sysop"]
}
file_area "Door Games" {
description = "Door game downloads and documentation"
path = "data/files/doors"
read = ["users"]
file_area "Dungeon" {
description = "Dungeon game files"
path = "data/files/doors/dungeon"
read = ["users"]
}
}
// ── Themes ──────────────────────────────────────────────
// Transition effects: fade, wipe, dissolve, slide, random(...), none.
// Colors: named ANSI (black, red, green, ..., bright_* variants) or hex.
theme "cyberpunk" {
ansi_background = "cyberpunk.ans"
transition forward = random(fade, wipe, dissolve, slide)
transition backward = random(fade, wipe, dissolve, slide)
color {
border = bright_cyan
text = bright_white
accent = neon_green
system = medium_gray
timestamp = white
username = bright_green
}
}
theme "classic" {
ansi_background = "starfield.ans"
transition forward = fade
transition backward = fade
color {
border = white
text = white
accent = bright_yellow
system = medium_gray
}
}
theme "ocean" {
ansi_background = "ocean.ans"
transition forward = dissolve
transition backward = wipe
color {
border = bright_blue
text = bright_white
accent = bright_cyan
}
}
// Active theme — changeable at runtime by SysOp
active_theme = "cyberpunk"
// ── News Sources ────────────────────────────────────────
news_source "RSS" {
url = "https://feeds.example.com/finance.rss"
interval = 30m
board = "Announcements" // auto-post new items to this board
}
news_source "Manual" {
// No URL — sysop posts manually via News Manager
}
Menu items are synced to the database on load. Items in the file but not in the DB are created; items in the DB but not in the file are left alone (to avoid accidental deletion). An explicit remove keyword handles deletions:
menu "main" {
remove 'Z' // remove a previously-defined item
}
On hot-reload, changed values are applied immediately. New groups are added but existing ones are not removed (users may be assigned to them). New boards, channels, and file areas are created if they don’t exist.
The imperative layer: define behavior with event handlers
The declarative layer describes structure. The imperative layer defines what happens — on login, on chat, on board posts, on schedules, on file transfers. Event handlers are the core of the imperative layer.
Login and logout
on login {
// First login welcome flow
if user.first_login {
show "welcome.ans"
mail from "SysOp" to user {
subject = "Welcome to The Exchange!"
body = """
Welcome aboard, ${user.display_name}!
Type /help in chat for commands.
Press ? on any screen for context help.
"""
}
}
// Returning user greeting
if !user.first_login {
show "starfield.ans"
say "Welcome back, ${user.username}!"
}
}
on logout {
log "${user.username} disconnected after ${session.duration}"
}
Board events
on board_post board "Announcements" {
// Notify all active users via mail
users = sql("SELECT username FROM bbs.users WHERE status = 'active'")
for u in users {
mail from "System" to u["username"] {
subject = "New announcement: ${msg.subject}"
body = msg.body
}
}
}
on board_post board "General" {
// Notify subscribers of new posts
subs = board_subscribers("General")
for sub_user in subs {
mail from "System" to sub_user {
subject = "New post on General: ${msg.subject}"
body = "${user.username} posted:\n\n${msg.body}"
}
}
}
on board_post {
// Global handler — logs all posts on all boards
log "Post by ${user.username} on ${board}: ${msg.subject}"
}
Chat events with the match expression
The match expression is Phosphor’s pattern dispatch. Multiple patterns can share a branch (comma-separated), and _ is the wildcard:
on chat_message channel "#lobby" {
match msg.text {
"!weather" => say "Weather: ${fetch("wttr.in/Bronxville?format=3")}"
"!time" => say "Server time: ${now()}"
"!help" => say "Commands: !weather, !time, !help, !stats"
"!stats" => {
count = sql("SELECT COUNT(*) FROM bbs.users WHERE status = 'active'")
say "Active users: ${count[0][0]}"
}
_ => {} // normal chat — no action
}
}
on chat_join channel "#vip" {
say "${user.display_name} joined the VIP lounge"
}
on chat_leave channel "#vip" {
say "${user.display_name} left ${channel}"
}
File events
on file_download area "ANSI Art" {
log "${user.username} downloaded ${msg.filename} from ${msg.area}"
}
on file_upload area "SysOp Files" {
// Reject executables
if msg.filename ends ".exe" {
reject "Executable files are not allowed"
}
}
Scheduled events: time as an event source
Time is just another event source. The scheduler fires events on a schedule — daily, hourly, every few seconds, first of the month, last Friday, weekdays only, sunrise, sunset. Every schedule trigger goes through the same event bus as everything else.
// Run once at a specific time every day
on daily 02:00 {
run "nightly-coverage-review.sh"
mail from "System" to "sysop" {
subject = "Nightly review complete"
body = script_output
}
}
// Fixed intervals
on every 30m { ... } // every 30 minutes
on every 1h { ... } // every hour
on every 5s { ... } // every 5 seconds (live data feeds)
on every 1d { ... } // every day from server start
// Standard cron expression (5-field)
on cron "0 9 * * 1-5" { ... } // weekdays at 9am
on cron "*/5 * * * *" { ... } // every 5 minutes
on cron "0 0 1 * *" { ... } // first of every month at midnight
Named schedules are reusable and can reference each other:
schedule "market_open" = cron "30 9 * * 1-5" // 9:30 AM weekdays
schedule "market_close" = cron "0 16 * * 1-5" // 4:00 PM weekdays
schedule "midnight" = daily 00:00
schedule "hourly" = every 1h
schedule "fivemin" = every 5m
on schedule "market_open" {
say "#trading: Market is open!"
fire("market_state", {state: "open"})
}
on schedule "market_close" {
say "#trading: Market is closed."
fire("market_state", {state: "closed"})
}
// Multiple schedules on one handler
on schedule "market_open", schedule "midnight", schedule "market_close" {
fire("pnl_snapshot", {time: now()})
}
Human-readable patterns compile to cron internally:
on "every weekday at 9:30am" { ... }
on "every Monday at 9am" { ... }
on "first of the month at midnight" { ... }
on "last day of the month at 23:59" { ... }
on "every 5 minutes" { ... }
on "every hour on the hour" { ... }
on "every day at sunrise" { ... }
on "weekdays at 9am, 12pm, 4pm" { ... }
Astronomical schedules (sunrise/sunset) require bbs.latitude and bbs.longitude in the bbs block — set to Bronxville, NY in the config above.
Schedule context variables are available inside scheduled handlers:
| Variable | Description |
|---|---|
schedule.name | Name of the schedule that fired (if named) |
schedule.expression | The cron expression or pattern |
schedule.fired_at | Timestamp when the schedule fired |
schedule.count | How many times this schedule has fired since server start |
Session events
on session_start {
log "Session started: ${session.id} from ${session.remote_address}"
}
on session_idle timeout 10m {
say "You've been idle. Press any key to continue."
}
on session_max_idle timeout 30m {
disconnect
}
Reactive bindings: screens that update themselves
This is the core of Phosphor’s reactivity model. Screens don’t poll — they bind to data sources and the runtime pushes updates to them automatically when the data changes. If a user is looking at a board and a new post arrives, the screen updates immediately. If someone joins a chat channel, the user count changes in real-time. If the sysop adds a menu item, it appears without logout/login.
The reactivity system has three layers:
- Source bindings (
bind) — Subscribe to a data source. The runtime monitors the source for changes. When it changes, the bound variable is updated andon updatefires. - Computed values (
computed) — Derived from one or more source bindings. They re-evaluate automatically when any dependency changes. - Update handlers (
on update) — Code that runs when any bound data source changes. Updates are debounced: if 5 sources change in quick succession,on updatefires once after all changes settle.
A live inbox screen
screen "Inbox" {
title = "Mail Inbox"
// Source bindings — the runtime watches these data sources
bind unread = mail_unread_count(user.id)
bind messages = mail_inbox(user.id, limit: 20)
// Computed — re-evaluates automatically when `unread` changes
computed status_line = "${unread} unread message${unread != 1 ? 's' : ''}"
on enter {
clear()
for m in messages {
marker = "●" if m.unread else " "
print "${marker} ${m.from} — ${m.subject}"
}
status(status_line)
}
// Fires when new mail arrives — from any source.
// No keypress needed. No polling. The runtime pushes the change.
on update {
clear()
for m in messages {
marker = "●" if m.unread else " "
print "${marker} ${m.from} — ${m.subject}"
}
status(status_line)
if unread > 0 {
flash("${unread} unread")
}
}
on key 'R' { refresh() }
on key 'Q' { back() }
}
A dashboard with computed values
screen "Dashboard" {
title = "Dashboard"
// Source bindings — pull from data
bind mail_unread = mail_unread_count(user.id)
bind posts_unread = board_unread_total(user.id)
bind news_unread = news_unread_count(user.id)
bind users_online = session_count()
// Computed — re-evaluates when ANY source binding changes
computed total_unread = mail_unread + posts_unread + news_unread
computed status_line = "Unread: ${total_unread} Online: ${users_online}"
on enter {
print "Mail: ${mail_unread}"
print "Posts: ${posts_unread}"
print "News: ${news_unread}"
print "Online: ${users_online}"
status(status_line)
}
// Fires when new mail arrives, a new post is created,
// news is posted, or someone logs in/out.
// All computed values re-evaluate automatically.
on update {
clear()
print "Mail: ${mail_unread}"
print "Posts: ${posts_unread}"
print "News: ${news_unread}"
print "Online: ${users_online}"
status(status_line)
}
on key 'Q' { back() }
}
Binding lifecycle
Bindings are created on on enter and destroyed on on exit. While the screen is active, all bindings are live. They do not persist across screen exits — each on enter creates fresh subscriptions. This prevents stale data and memory leaks from abandoned screens.
Available binding sources
| Source | Triggers update when |
|---|---|
query("SQL") | Result set changes (DB LISTEN/NOTIFY or row triggers) |
chat_count("#channel") | Someone joins or leaves the channel |
chat_channels() | A channel is created, deleted, or its topic changes |
board_posts("name", limit) | A new post is created on the board |
board_unread_count(uid, "name") | A new post arrives or the user reads one |
board_unread_total(uid) | Any board gets a new post |
mail_unread_count(uid) | New mail arrives or user reads mail |
news_unread_count(uid) | New news is posted or user reads it |
session_count() | A session starts or ends |
menu_items(groups) | The user’s group membership changes |
bbs_setting("key") | A system setting changes |
file_list("area") | A file is added or removed from the area |
user_state(user, key) | A game state or screen state is saved |
jvm_memory_used() | JVM memory changes (debounced) |
bbs_uptime() | Time-based (updates on tick) |
Implementation: event bus, not polling. The reactivity system is backed by the existing Java event infrastructure — ChatBus.ChatBusListener, BoardService.PostListener, MailService.SaveListener, SessionRegistry.SessionListener, and Postgres LISTEN/NOTIFY for SQL query result changes. Phosphor’s runtime wires these listeners to bind declarations. When a listener fires, the runtime re-fetches the bound data source, compares the new value to the old, and if changed, updates the variable, recomputes computed values, and calls on update.
Declarative UI: layout blocks with widgets
The imperative style (print, table, input inside on enter) works but is verbose for common layouts. Phosphor also supports a declarative UI — describe the layout as widgets, bind them to data sources, and only write callbacks for interactive behavior. The runtime handles rendering, re-rendering on data change, and input routing.
A declarative screen with the full widget set
screen "Portfolio" {
title = "Portfolio"
// Bind data — reactive
bind positions = sql("SELECT symbol, shares, market_value, gain_loss
FROM positions WHERE user_id = ?", user.id)
bind total = sql_one("SELECT SUM(market_value) FROM positions WHERE user_id = ?", user.id)
bind selected = 0
// Declarative layout — widgets, not print statements
layout {
banner {
text = "Portfolio — ${user.username}"
color = accent
align = center
}
spacer 1
table {
data = positions
columns = ["Symbol", "Shares", "Value", "P&L"]
row_color = { row ->
if row.gain_loss > 0 then bright_green else bright_red
}
selected = selected
on_select { row -> selected = row.index }
on_activate { row -> navigate "position_detail" with {symbol: row.symbol} }
page_size = 20
empty_message = "No positions"
}
spacer 1
panel {
border = true
title = "Summary"
color = accent
content {
label "Total Value: $${total[0]}"
label "Positions: ${len(positions)}"
}
}
statusbar {
left = "Total: $${total[0]}"
right = "[R] Refresh [D] Detail [Q] Quit"
}
}
// Callbacks — only for interactive behavior
on key 'R' { refresh() }
on key 'D' {
if len(positions) > 0 {
navigate "position_detail" with {symbol: positions[selected].symbol}
}
}
on key 'Q' { back() }
}
The layout block declares the widget tree. The runtime renders it on on enter and re-renders automatically when any bound data changes — no manual clear() + redraw in on update. Widgets bind to reactive sources via data = positions (referencing a bind variable). When the source changes, the widget updates itself. The runtime maps widgets to jterm components (Panel, Table, Label, TextBox) under the hood.
Widget reference
label — single line of text
label "text"
label "text" {
color = bright_green
align = left // left, center, right
blink = false
}
Supports string interpolation — updates when bound variables change.
banner — prominent heading
banner {
text = "Welcome to The Exchange"
color = accent
align = center
ansi = "welcome.ans" // optional: display ANSI art instead of text
}
table — data table with selection, coloring, pagination
table {
data = positions
columns = ["Symbol", "Shares", "Value", "P&L"]
row_color = { row -> if row.gain_loss > 0 then bright_green else bright_red }
selected = selected_idx
selectable = true
on_select { row -> selected_idx = row.index }
on_activate { row -> navigate "detail" with {symbol: row.symbol} }
page_size = 20
empty_message = "No positions"
}
data is a reactive binding (list of rows). The table re-renders when data changes. row_color returns a Color per row. on_select fires on arrow key navigation, on_activate fires on Enter.
list — selectable list with custom formatting
list {
data = board_list(user.id)
format = { item -> "${item.subscribed ? '✓' : ' '} ${item.name} (${item.unread} unread)" }
selected = selected_idx
on_select { item -> selected_idx = item.index }
on_activate { item -> navigate "board" with {name: item.name} }
filterable = true
page_size = 15
}
input — text input field
input {
label = "Symbol: "
value = symbol
placeholder = "AAPL"
max_length = 10
uppercase = true
on_change { val -> symbol = val }
on_submit { val -> submit_search() }
}
on_change fires on each keystroke, on_submit fires on Enter. The value is bidirectional — typing updates symbol, and setting symbol programmatically updates the field.
textarea — multi-line text input
textarea {
label = "Body:"
value = body
rows = 10
on_submit { val -> submit_post() }
}
panel — bordered container
panel {
border = true
title = "Details"
color = accent
content {
label "Symbol: ${positions[selected].symbol}"
label "Shares: ${positions[selected].shares}"
label "Value: $${positions[selected].market_value}"
}
}
tabs — tabbed interface
tabs {
tab "Inbox" {
list {
data = mail_inbox(user.id, limit: 20)
format = { m -> "${m.unread ? '●' : ' '} ${m.from} — ${m.subject}" }
on_activate { m -> navigate "mail_view" with {id: m.id} }
}
}
tab "Sent" {
list {
data = mail_sent(user.id, limit: 20)
format = { m -> "${m.to} — ${m.subject}" }
on_activate { m -> navigate "mail_view" with {id: m.id} }
}
}
tab "Drafts" {
list {
data = mail_drafts(user.id)
on_activate { m -> navigate "mail_edit" with {id: m.id} }
}
}
selected = 0
on_change { idx -> selected = idx }
}
Switching tabs is instant — the runtime renders only the active tab but keeps all tab states alive.
statusbar — bottom status line
statusbar {
left = "Unread: ${unread}"
center = "${user.username}"
right = "[Q] Quit [R] Refresh"
color = accent
}
Reactive — updates when bound variables change.
progress — progress bar
progress {
current = download_progress
total = download_size
label = "Downloading ${filename}..."
bar_char = "█"
empty_char = "░"
color = bright_green
}
chart — ASCII chart
chart {
type = "line" // "line", "bar", "sparkline"
data = sql("SELECT date, value FROM portfolio_history WHERE user_id = ?", user.id)
x = "date"
y = "value"
color = bright_green
height = 15
title = "Portfolio Value"
}
Live updating when the data source changes.
region — event-driven content area
region id = "chat_feed" {
max_lines = 50
scroll = auto
}
A region is an initially-empty area that handlers fill dynamically. It’s the key to mixing events and UI — live feeds, log viewers, activity streams, anything where events append content over time.
Widget IDs and targeted updates
Widgets can be given IDs so event handlers can target and update specific parts of the UI without re-rendering the whole screen. A mail event updates just the unread count label. A trade event updates just the status bar. A chat event updates just the message feed.
screen "Command Center" {
title = "Command Center"
bind mail_unread = mail_unread_count(user.id)
bind online = session_count()
bind recent_trades = sql("SELECT * FROM federated_trades ORDER BY timestamp DESC LIMIT 5")
layout {
banner { text = "Command Center — ${user.username}" }
spacer 1
panel {
title = "Status"
content {
label id = "mail_count" "Mail: ${mail_unread} unread"
label id = "online_count" "Online: ${online} users"
label id = "trade_status" "No recent trades"
}
}
spacer 1
table id = "trade_table" {
data = recent_trades
columns = ["Symbol", "Action", "Shares", "Price", "User"]
empty_message = "No trades yet"
}
spacer 1
panel {
title = "Live Chat"
content {
region id = "chat_feed" {
// starts empty — chat messages are appended by handlers
max_lines = 50
scroll = auto
}
}
}
statusbar {
left = id "mail_status" "${mail_unread} unread"
right = "[C] Clear [Q] Quit"
}
}
// Event handlers that target specific widgets — no full re-render
on mail_received {
update("mail_count", "Mail: ${mail_unread} unread")
update("mail_status", "${mail_unread} unread")
flash_widget("mail_count")
append("chat_feed", "📧 New mail from ${msg.from}: ${msg.subject}")
}
on trade_executed {
update("trade_status", "Last: ${event.action} ${event.shares} ${event.symbol} @ $${event.price}")
scroll_to_top("trade_table")
append("chat_feed", "📊 ${event.user} ${event.action} ${event.shares} ${event.symbol} @ $${event.price}")
}
on chat_message channel "#lobby" {
append("chat_feed", "<${msg.sender}> ${msg.text}")
}
on session_count_changed {
update("online_count", "Online: ${online} users")
}
on key 'C' { clear("chat_feed") }
on key 'Q' { back() }
}
Targeted update functions:
| Function | Description |
|---|---|
update("widget_id", "text") | Replace the content of a labeled widget |
update("widget_id", data) | Replace the data of a table/list widget |
append("widget_id", "text") | Append a line to a region or list |
prepend("widget_id", "text") | Prepend a line to a region or list |
clear("widget_id") | Clear the content of a widget or region |
show("widget_id") | Show a hidden widget |
hide("widget_id") | Hide a widget |
flash_widget("widget_id") | Flash a widget to draw attention |
scroll_to_top("widget_id") | Scroll a table/list to the top |
scroll_to_bottom("widget_id") | Scroll a table/list to the bottom |
set_color("widget_id", color) | Change a widget’s color |
set_focus("widget_id") | Set input focus to a specific widget |
Three ways a widget gets content, all coexisting on the same screen:
- Reactive binding → data source pushes, widget auto-updates (no handler needed)
- Event handler → handler calls
update/append/clearon widget by ID - Static → declared in the layout block
The pattern in one line: layout → widget id = "x" → bind data → on event → update("x", ...).
Mixing declarative and imperative
Declarative UI and imperative code mix freely. Use declarative for layout, imperative for complex logic:
screen "Stock Screener" {
title = "Stock Screener"
bind results = sql("SELECT * FROM stocks WHERE pe_ratio < ? ORDER BY pe_ratio", max_pe)
bind max_pe = 15
layout {
banner { text = "Stock Screener" }
spacer 1
panel {
title = "Filters"
content {
input {
label = "Max P/E: "
value = max_pe
on_submit { val -> max_pe = to_int(val) }
}
}
}
spacer 1
table {
data = results
columns = ["Symbol", "Name", "P/E", "Dividend", "Sector"]
on_activate { row -> navigate "stock_detail" with {symbol: row.symbol} }
}
statusbar {
left = "${len(results)} results"
right = "[S] Sort [Q] Quit"
}
}
// Imperative code for complex logic that doesn't fit a widget
on key 'S' {
sort_col = input("Sort by: ")
results = sql("SELECT * FROM stocks WHERE pe_ratio < ? ORDER BY ${sort_col}", max_pe)
}
on key 'Q' { back() }
}
Expressions and data types
Phosphor is dynamically typed. The built-in types:
| Type | Description | Literal example |
|---|---|---|
String | Text | "hello", 'hello' |
Int | Integer | 42 |
Float | Decimal | 3.14 |
Bool | Boolean | true, false |
List | Ordered collection | [1, 2, 3] |
Map | Key-value pairs | {name: "Joe", age: 18} |
Duration | Time span | 30s, 5m, 2h, 1d |
Time | Time of day (cron-like) | 02:00, 9:30 |
Color | ANSI color | bright_green, neon_green, #00FF00 |
ANSI | ANSI art reference | @starfield, "file.ans" |
Table | Tabular data (from SQL) | Result of sql("SELECT ...") |
User | BBS user context | user, sender |
Session | BBS session context | session |
Built-in context variables
These are injected by the runtime and available in all event handlers and screens:
| Variable | Type | Description |
|---|---|---|
user | User | Current user (id, username, groups, display_name) |
session | Session | Current session (id, remote_address, connect_time, terminal_size) |
bbs | BBS | The BBS instance (name, tagline, sysop, settings) |
channel | String | Current chat channel (in chat event context) |
board | String | Current board name (in board event context) |
msg | Message | Incoming message (in event context) |
Operators
| Category | Operators |
|---|---|
| Arithmetic | +, -, *, /, % |
| Comparison | ==, !=, <, >, <=, >= |
| Logical | and, or, not |
| String | ${expr} (interpolation), contains, starts, ends, match (regex) |
| List | in, [], [:] (slice) |
| Null | ?? (coalesce) |
String interpolation
name = "Joseph"
count = 42
text = "Hello ${name}, you have ${count} messages"
Match expression
Multiple patterns can share a branch (comma-separated). _ is the wildcard:
match input {
"y", "yes" => confirm()
"n", "no" => cancel()
"q", "quit" => back()
_ => say "Unknown: ${input}"
}
Conditional
if user.group == "vip" {
show "vip_welcome.ans"
} else if user.first_login {
show "welcome.ans"
} else {
say "Welcome back, ${user.username}"
}
Loops
// For-in loop
for item in list {
print item
}
// With index
for i, item in list {
print "${i}: ${item}"
}
// While loop
while hp > 0 {
hp -= 10
}
Built-in functions
Output
| Function | Description |
|---|---|
print "text" | Print text to the screen |
say "text" | Print system-style line (prefixed with --) |
show "file.ans" | Display ANSI art from file |
show_ansi(raw_ansi) | Display raw ANSI escape sequences |
table(data, columns) | Render a table from SQL result or list of maps |
clear() | Clear the screen |
popup("title", "body") | Show a modal popup |
help("text") | Show a help dialog |
menu_list("title", [items]) | Show a selectable list, return chosen index |
flash("text") | Flash a temporary message in the status bar |
refresh() | Re-run on enter |
status("text") | Set the status bar text |
progress_bar(current, total) | Render a progress bar |
Data and reactive bindings
These functions create live data subscriptions for use with bind. They return data AND register a change listener so on update fires when the data changes.
| Function | Description |
|---|---|
query("SQL", params...) | Live SQL query (DB LISTEN/NOTIFY or row trigger) |
sql("query", params...) | Execute SQL, return Table |
sql_one("query", params...) | Return first row or null |
sql_exec("query", params...) | Execute INSERT/UPDATE/DELETE |
chat_count("#channel") | Subscriber count for a channel |
chat_channels() | All channels with counts and topics |
board_posts("name", limit) | Recent posts on a board |
board_unread_count(uid, "name") | Unread count for user on board |
board_unread_total(uid) | Total unread across all boards |
mail_unread_count(uid) | Unread mail count |
news_unread_count(uid) | Unread news count |
session_count() | Active session count |
session_list() | List of active sessions |
menu_items(groups) | Menu items visible to groups |
bbs_setting("key") | A system setting value |
file_list("area") | Files in a file area |
user_state(user, key) | Saved user state |
jvm_memory_used() | JVM heap usage |
bbs_uptime() | Server uptime |
mail from "SysOp" to "joe" {
subject = "Subject line"
body = "Message body"
}
// Multiple recipients
mail from "System" to ["joe", "najee"] {
subject = "Family update"
body = "Dinner at 7pm"
}
| Function | Description |
|---|---|
mail from "X" to "Y" { ... } | Send mail (single recipient) |
mail from "X" to [list] { ... } | Send mail (multiple recipients) |
mail_inbox(uid, limit) | Inbox messages |
mail_sent(uid, limit) | Sent messages |
mail_drafts(uid) | Draft messages |
Board
| Function | Description |
|---|---|
board_post "name", { ... } | Post to a board |
board_list(uid) | List of boards with unread counts |
board_subscribers("name") | Users subscribed to a board |
Chat
| Function | Description |
|---|---|
say "text" | Print to current channel (in chat context) |
say "#channel: text" | Print to a specific channel |
System
| Function | Description |
|---|---|
now() | Current timestamp |
format_time(time, "fmt") | Format time |
log "message" | Write to BBS log |
run "script.sh" | Execute shell script |
script_output | Captured stdout from last run |
save_global(key, value) | Save a global variable |
load_global(key) | Load a global variable |
fire("event", payload) | Fire a custom event |
save_state(user, key, state) | Persist game/screen state |
load_state(user, key) | Load saved state |
scoreboard(game, score) | Submit score to bbs.door_scores |
show_scoreboard(game) | Display high scores |
Utility
| Function | Description |
|---|---|
random_int(min, max) | Random integer in [min, max] |
random_float() | Random float in [0, 1) |
random_choice(list) | Random element from a list |
hash("text") | SHA-256 hash |
base64_encode(bytes) | Encode to base64 |
base64_decode(string) | Decode from base64 |
len(x) | Length of string/list/map |
keys(map) | Map keys |
values(map) | Map values |
upper(string) / lower(string) | Case conversion |
trim(string) | Strip whitespace |
split(string, sep) | Split into list |
join(list, sep) | Join into string |
to_int(string) | Convert to integer |
fetch("url") | HTTP GET, return string |
fetch_json("url") | HTTP GET, return parsed JSON |
post("url", body) | HTTP POST |
dns_lookup("host") | DNS resolution |
Extending Phosphor with Java
Phosphor is extensible via compiled Java. Third-party JARs (plugins) register new functions, binding sources, event types, screen types, and widgets with the Phosphor runtime. This is how the language grows without changing the core interpreter. Extensions are discovered automatically from plugin JARs in lib/plugins/ — the runtime scans for annotations at startup, before .phos files are parsed.
Custom functions: @PhosphorFunction
A Java plugin can register functions callable from .phos files:
@PhosphorFunction(name = "weather", description = "Get weather for a location")
public static String getWeather(String location) {
return WeatherApi.fetch(location);
}
// Usage in .phos:
on chat_message channel "#lobby" {
if msg.text starts "!weather " {
loc = msg.text.substring("!weather ".length())
say "Weather: ${weather(loc)}"
}
}
Functions can take any Phosphor-compatible types (String, Int, Float, Bool, List, Map) and return any of them.
Custom binding sources: @PhosphorBinding
A plugin can register new reactive data sources for bind. The ReactiveBinding interface is the same one used internally by Phosphor’s built-in bindings — Java plugins get first-class reactivity:
@PhosphorBinding(name = "stock_price", description = "Live stock price")
public static class StockPriceBinding implements ReactiveBinding {
private final String symbol;
private final List<Runnable> listeners = new CopyOnWriteArrayList<>();
public StockPriceBinding(String symbol) {
this.symbol = symbol;
PriceFeed.subscribe(symbol, price -> notifyListeners());
}
@Override
public Object currentValue() {
return PriceFeed.getPrice(symbol);
}
@Override
public void addChangeListener(Runnable listener) { listeners.add(listener); }
@Override
public void removeChangeListener(Runnable listener) { listeners.remove(listener); }
@Override
public void close() { PriceFeed.unsubscribe(symbol); }
private void notifyListeners() {
for (var l : listeners) l.run();
}
}
// Usage in .phos:
screen "Stock Ticker" {
bind aapl = stock_price("AAPL")
bind goog = stock_price("GOOG")
on enter { print "AAPL: ${aapl} GOOG: ${goog}" }
// Fires automatically when the price feed updates
on update {
clear()
print "AAPL: ${aapl} GOOG: ${goog}"
}
}
Custom event types: @PhosphorEvent
A plugin can define new event types that Phosphor handlers can listen to. The event payload (a Java record or map) is exposed to Phosphor as a Map with field access via dot notation:
@PhosphorEvent(name = "trade_executed", description = "Fired when a trade is executed")
public record TradeEvent(String symbol, int shares, double price, String action) {}
// Firing the event from Java:
phosphorRuntime.fireEvent("trade_executed", new TradeEvent("AAPL", 100, 195.50, "BUY"));
// Usage in .phos:
on trade_executed {
mail from "Broker" to user {
subject = "${event.action} ${event.shares} ${event.symbol} @ $${event.price}"
body = "Trade executed at ${now()}"
}
say "Trade: ${event.action} ${event.shares} ${event.symbol} @ $${event.price}"
}
Manual registration: PhosphorExtension
For complex cases, implement PhosphorExtension to register multiple extensions at once:
public class FinanceExtension implements PhosphorExtension {
@Override
public void register(PhosphorRuntime runtime) {
runtime.registerFunction("portfolio_value", this::getPortfolioValue);
runtime.registerFunction("position_pnl", this::getPositionPnl);
runtime.registerBinding("portfolio_nav", PortfolioNavBinding::new);
runtime.registerEvent("dividend_received");
}
}
Custom widgets: @PhosphorWidget
Plugins can register custom widgets for the declarative UI:
@PhosphorWidget(name = "ticker", description = "Live stock ticker tape")
public class TickerWidget implements PhosphorWidget {
@Override
public void render(WidgetContext ctx) {
// Render an animated ticker tape using jterm primitives
// ctx.data() = the bound data
// ctx.term() = the terminal
// ctx.bounds() = the allocated rectangle
}
}
// Usage in .phos:
screen "Trading Floor" {
bind prices = stock_prices(["AAPL", "GOOG", "MSFT", "TSLA"])
layout {
banner { text = "Trading Floor" }
spacer 1
ticker {
data = prices
speed = 2
color = bright_green
}
spacer 1
statusbar { right = "[Q] Quit" }
}
on key 'Q' { back() }
}
Dual runtime: Phosphor and Java side by side
Phosphor does not replace the Java implementation — it runs alongside it. Java screens registered via BbsContext / ScreenFactory continue to work unchanged. Phosphor screens registered via .phos files are loaded by the Phosphor runtime and added to the screen factory alongside Java screens. Menu items can point to either — the navigation target is just a name, and the runtime resolves it:
1. Check Phosphor screen registry (from .phos files)
2. Check Java screen factory (from BbsContext / plugin menu entries)
3. Check built-in Java screens (MainMenu's defaultScreenFactory)
4. If not found: show "Unknown screen" error
A Phosphor screen can shadow (override) a Java screen with the same name. Prototype in Phosphor, promote to Java when you need performance — same menu item, same navigation, no user-visible change.
Event handlers from both sides fire on the same events. A board_post event triggers both the Java PostListener AND any on board_post handlers in .phos files. Java listeners fire first (fast, compiled), then Phosphor handlers (interpreted, sandboxed). A Java handler can cancel() an event to prevent Phosphor handlers from running.
Try it
The full language specification lives in the repository:
- Phosphor Language Spec — 17 sections covering the full grammar, runtime architecture, and extension API
Connect via SSH
ssh exchange.boremaj.com
Log in with your BBS account (or create one — auto_approve may be off, so the SysOp will need to approve new accounts). Once connected:
- Press
Sfrom the main menu to access the SysOp menu (requiressysopgroup) - Select
Mfor Menu Editor - Type
phosphorto open the in-BBS Phosphor editor
The in-BBS editor provides syntax highlighting, auto-completion, and a snippet library. Edit a .phos file, save, and the changes hot-reload immediately — menus update, boards appear, event handlers take over. No restart. No recompile. Save and see.
If you don’t have SysOp access, you can still read the spec and experiment locally. The Phosphor interpreter is unit-testable without a running BBS — tests create a PhosphorRuntime, register mock bindings, parse a .phos string, and evaluate handlers. No SSH, no database, no network required.