UserDefaults for a BBS: A Generic Preferences and State API

September 19, 2026

Every non-trivial app eventually faces the same question: where does small user configuration live, and where does user data live? macOS answered this decades ago with two separate systems — UserDefaults for typed, user-visible settings, and the file system for documents. We had been living with neither, and it showed.

The mess we had

Phosphor already had a real preference engine. PreferenceResolver resolves settings through three levels — user, then group, then global — backed by the database, cached with proper invalidation, and connected across processes with Postgres LISTEN/NOTIFY. Screens could even bind to a preference reactively, so flipping a toggle redraws every open screen instantly. For the language side, .phos scripts read settings with pref("key") and write them with set_pref("key", value).

So what was wrong? Two things.

First, nothing could declare a preference except .phos scripts. The engine was complete, but its front door only opened for one kind of caller. Our Java screens couldn’t say “I own this key, here’s its type, default, and description.” That’s why the built-in Preferences screen hardcoded its five toggles: chat_timestamps, board_sort, and friends existed nowhere except inside that screen’s switch statement.

Second, state had no home at all. Saves, counters, progress — the data that outlasts a session. Every component that needed it invented its own answer:

  • the dungeon door hand-rolled three DAOs and a 30-second auto-save scheduler,
  • Pac-Man wedged its progress into a score table shaped for Sector Trader — cargo manifests and sector coordinates crammed into columns named cargo_json and sector_x that meant nothing for Pac-Man,
  • Sector Trader’s player-state DAO had zero production callers; the game spawned fresh state every session,
  • and the language spec had promised save/load builtins for the better part of a year. The section was written. The builtins were not.

Thirty-two tables in the schema, and every door negotiating its own lease. That’s the classic drift pattern: not one big mistake, but a dozen reasonable local decisions that never compose.

Two products, two stores

The fix starts with a distinction the Apple frameworks get right: preferences and state are different products with different lifecycles.

Preferences are small, typed, user-visible configuration. They have labels, defaults, allowed values, and they deserve UI.

State is blobs of user data — game saves, daily counters, anything that must survive a restart. It has no UI and doesn’t want one.

Phase 1: the registrar

PreferenceRegistrar lets any Java component — screen, door, plugin — declare a preference:

registrar.register(new PreferenceDef(
        "board_sort",
        PrefType.ENUM,
        "newest_first",
        List.of("newest_first", "oldest_first", "threaded"),
        "Message base sort order",
        "MessageBase"));

Types are BOOLEAN, STRING, INT, and ENUM (with allowed values). Duplicate registrations that agree are idempotent; ones that conflict log a warning and first-wins, so an extension can’t accidentally hijack another component’s key. Registration self-attaches to the resolver, so wiring is one line.

The Preferences screen then stopped being a hardcoded list. It renders whatever has been registered — grouped by owner, with the right editor per type: toggles for booleans, wrap-around cycling for enums, arrow-key selection, R to reset, and a description line for the highlighted row. The five classic toggles became simply the first five registrations, and every future key shows up there automatically. A .phos script that declares a preferences {} block participates in the same registry, same screen, same resolution path.

Phase 2: the state store

UserStateStore is the other half: a namespaced, per-user JSON key-value store in one table, bbs.user_state:

(user_id, namespace, key) → value_json

The namespace matters. Every component picks its own — the door game Sector Trader uses "sectortrader", key "progress" — so nothing collides and nothing crams foreign data into someone else’s columns. Global state lives at user id 0. Upserts are dialect-branched (H2 MERGE, Postgres ON CONFLICT), which is the same two-dialect discipline the rest of the schema follows.

Then the language got what the spec always promised. From a .phos script:

save("mygame", "level2", {
    hp: 80,
    inventory: ["rope", "torch"],
    checkpoint: 14
})

state = load("mygame", "level2")
if state == null {
    state = {hp: 100, inventory: [], checkpoint: 0}
}

Plus save_global/load_global and delete/delete_global. Values round-trip through a small JSON codec — maps come back insertion-ordered and equal to what was saved. The current user comes from the session context, wired in when a script runs, so a save during your play lands in your row, and an unauthenticated session gets a clear error instead of silently writing to nowhere.

The proof it’s enough: Sector Trader now resumes your game — credits, fuel, cargo, position — from that store, and its old orphaned DAO is deleted. The leaderboard tables stay exactly as they were; leaderboards need cross-user queries, and per-user key-value is the wrong tool for that. Knowing which store a problem belongs to is most of the design.

What it buys

Any component now has a one-line answer to “where do I put this?” — and both answers come with tests, caching, notifications, and a screen that maintains itself. New preferences appear in the UI with no UI work. New state needs no schema migration. And the drift class of bug — Pac-Man sleeping in Sector Trader’s bed — is structurally gone.

The commit chain, if you want to read the code: registrar (47f6e73d), auto-rendered preferences screen (a3c259bf), state store (aacce1cd), script builtins (2880699f), session wiring (dbf194b4), door migration (4f2c34ac). Full suite: 8,253 green.