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:


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:

VariableDescription
schedule.nameName of the schedule that fired (if named)
schedule.expressionThe cron expression or pattern
schedule.fired_atTimestamp when the schedule fired
schedule.countHow 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:

  1. Source bindings (bind) — Subscribe to a data source. The runtime monitors the source for changes. When it changes, the bound variable is updated and on update fires.
  2. Computed values (computed) — Derived from one or more source bindings. They re-evaluate automatically when any dependency changes.
  3. Update handlers (on update) — Code that runs when any bound data source changes. Updates are debounced: if 5 sources change in quick succession, on update fires 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

SourceTriggers 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 {
    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:

FunctionDescription
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:

  1. Reactive binding → data source pushes, widget auto-updates (no handler needed)
  2. Event handler → handler calls update/append/clear on widget by ID
  3. 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:

TypeDescriptionLiteral example
StringText"hello", 'hello'
IntInteger42
FloatDecimal3.14
BoolBooleantrue, false
ListOrdered collection[1, 2, 3]
MapKey-value pairs{name: "Joe", age: 18}
DurationTime span30s, 5m, 2h, 1d
TimeTime of day (cron-like)02:00, 9:30
ColorANSI colorbright_green, neon_green, #00FF00
ANSIANSI art reference@starfield, "file.ans"
TableTabular data (from SQL)Result of sql("SELECT ...")
UserBBS user contextuser, sender
SessionBBS session contextsession

Built-in context variables

These are injected by the runtime and available in all event handlers and screens:

VariableTypeDescription
userUserCurrent user (id, username, groups, display_name)
sessionSessionCurrent session (id, remote_address, connect_time, terminal_size)
bbsBBSThe BBS instance (name, tagline, sysop, settings)
channelStringCurrent chat channel (in chat event context)
boardStringCurrent board name (in board event context)
msgMessageIncoming message (in event context)

Operators

CategoryOperators
Arithmetic+, -, *, /, %
Comparison==, !=, <, >, <=, >=
Logicaland, or, not
String${expr} (interpolation), contains, starts, ends, match (regex)
Listin, [], [:] (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

FunctionDescription
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.

FunctionDescription
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

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"
}
FunctionDescription
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

FunctionDescription
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

FunctionDescription
say "text"Print to current channel (in chat context)
say "#channel: text"Print to a specific channel

System

FunctionDescription
now()Current timestamp
format_time(time, "fmt")Format time
log "message"Write to BBS log
run "script.sh"Execute shell script
script_outputCaptured 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

FunctionDescription
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:

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:

  1. Press S from the main menu to access the SysOp menu (requires sysop group)
  2. Select M for Menu Editor
  3. Type phosphor to 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.