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_jsonandsector_xthat 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/loadbuiltins 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.