Blackjack in Phosphor: A Door Game as a Language Test

September 19, 2026

Every language looks fine in its own test suite. The tests were written by the same people who wrote the language, with the same blind spots. The only honest benchmark is a program someone actually wants to run.

So when the question came up — “would it be easy to create a simple blackjack game for the BBS?” — the answer was: yes, and that’s exactly the test Phosphor needs. Blackjack is a sweet spot for a scripting language’s first real workload. It has genuine state (a shuffled deck, two hands, a bankroll), real control flow (a dealer AI that hits to 17, ace soft/hard counting), string wrangling (card glyphs), user input, and persistence. If the parser, the evaluator, and the door bridge can carry all of that, the language is real. If they can’t, we find out where.

The result is a complete, playable blackjack game in 219 lines of Phosphor — no Java, no host code, no cheating. Everything from the Fisher-Yates shuffle to the dealer’s standing rule lives in the script.

The game, in the language’s own words

A door in Phosphor declares itself, its state, and its layout, then defines functions and key handlers:

door "Blackjack" {
    description = "Classic blackjack against the dealer. D deal, H hit, S stand, Q quit."

    state {
        deck = []
        player_hand = []
        dealer_hand = []
        bankroll = 100
        hands_played = 0
        hands_won = 0
        bet = 10
        phase = "bet"
    }

    layout {
        banner { text = "BLACKJACK" }
        region id = "table" {
            label "Press D to deal."
        }
        statusbar { left = "Bankroll: ${bankroll}" right = "[D]eal [H]it [S]tand [Q]uit" }
    }

The state block is the door’s working memory. The layout block draws the TUI: a banner, a table region the script can update by id, and a live status bar. Handlers wire the keys:

on key 'D' {
    if phase == "bet" && bankroll >= bet {
        deal_new_hand()
    }
}

on key 'H' {
    if phase == "player" {
        player_hand.add(draw())
        ...
    }
}

The game logic is straightforward and — importantly — reads like it would in any mature scripting language. Ace counting, the part that always breaks naive blackjack implementations, is six lines:

function hand_value(hand) {
    total = 0
    aces = 0
    for card in hand {
        total = total + card_value(card)
        if card.substring(0, 1) == "A" {
            aces = aces + 1
        }
    }
    // Count aces down from 11 to 1 while the hand would bust.
    while total > 21 && aces > 0 {
        total = total - 10
        aces = aces - 1
    }
    return total
}

Deck management is a 52-card build plus Fisher-Yates over the list, using the language’s random_int builtin. Persistence is three calls: save("blackjack", "bankroll", bankroll) on every round’s end, and matching load() calls on entry — backed by the namespaced UserStateStore we shipped earlier this week, so your bankroll follows you across logins. No score tables, no custom DAOs, no Java-side bookkeeping for a user-level game.

What the game broke, and what we fixed

This is the part worth reading if you’re designing a language (or a door API) yourself. Blackjack found real bugs, and each one turned into a rule.

No functions. The spec promised them; nobody had wired them. The parser now handles function declarations and calls in door scope, and §4.3.4 of the language spec describes the whole grammar. Doors are programs, and programs need functions.

else if fell through. The parser treated else if as an else block containing a bare if in a way that skipped the advance past the inner expression. Card ranks branch five ways, so this broke immediately and loudly — a good bug to find, because silent fall-through bugs are the kind that hide until production.

Assignment-as-statement (bankroll = bankroll + bet) wasn’t producing a statement node at all — the parser routed property assignments out of expressionStmt. Every blackjack handler mutates state, so every handler would have been dead.

Zero-arg method callsdeck.length(), hand.remove(card) — weren’t recognized. The rule we landed on: a zero-arg call to an unknown method is an error, not a silent no-op. Silent no-ops in a game loop are how you get an infinite dealer turn.

Index assignmentdeck[i] = deck[j] — existed but had never been exercised by real code with real mutation semantics.

That’s the honest yield of a “simple” test program: two parser bugs, one evaluator gap, and a missing language feature, all surfaced in an afternoon.

What the bridge had to learn

The script runs in an evaluator; the BBS shows windows in a TUI. The adapter between them — the bridge — is where the interesting operational bugs lived, and the last three all taught the same lesson: the door window must behave like every other window on the screen, because the rest of the BBS does not make exceptions for it.

It registered at build time but not startup time. The bridge worked in tests and vanished in production. Doors were being registered only on hot-reload; a cold start read the .phos sources, evaluated them, and never put them in the registry. The Games menu literally could not list the game. registerHandlers() now runs at startup.

The window rendered empty. The door opened with a correct title and drew nothing — because the bridge created its content panel without a layout constraint, and the window manager sized the default content to zero. Our Java doors had all set a fullscreen hint and constrained their panel to the center; the Phosphor bridge looked identical in code and completely different at runtime. The fix was to mirror what every Java door already did. The lesson generalizes: when a widget renders blank, suspect sizing, not content.

It didn’t know who was playing. The first real launch failed with save()/load() require a current user context. In tests the user was wired at construction; in production the static user id was 0. The fix is to wire the live session’s user into the evaluator at start() time — the constructor’s idea of the user is worth nothing.

Ctrl-C was a game key. While a door is open it’s the active window, so the BBS-wide disconnect key went to the game — which swallowed it as an unknown ‘C’. The bridge now closes the door and detaches the session itself, matching what every screen does with Ctrl-C.

Quitting hung the screen. The door’s exit handler ran, but close() never removed the window from the GUI. The stale fullscreen window stayed active and ate every keystroke — a hang with no error anywhere. It now removes itself and refreshes so the screen underneath regains focus.

That pair — Ctrl-C and the quit hang — is the same principle from either direction: a fullscreen door window is also just a window. Keys you don’t consume must be handed up, and the screen must outlive you.

The proof

The game plays. Deal, hit, stand, dealer plays out, push at 20, bankroll updates, stats update. Quit and come back — the welcome screen reads your saved bankroll and W/L record from the state store. Ctrl-C from mid-hand detaches the session like any other screen. The full test suite is 8,251 tests green, including the language suite, the bridge tests, and three new regression tests for the window lifecycle.

Two things made the difference in getting it live. One was a same-package probe — a throwaway Java class in the door’s own package that runs the real game against the production database — which proved the engine was correct and narrowed every remaining bug to the bridge layer. Isolating which layer is wrong is most of the work. The other was refusing to accept “it works in tests” as the finish line: every fix here was driven by an actual keypress in an actual terminal, which is where window managers and keystroke routing actually live.

Blackjack is the first door written entirely in the house language. It will not be the last.