Real-Time Chat on a BBS: Pub/Sub, Channels, and Wall Messages

June 19, 2026

Chat is the heartbeat of a BBS. In the 90s, you’d log in, see who was online, and jump into a channel. Messages appeared character by character as the other person typed. It felt alive.

Phosphor BBS brings that feeling back — but with a modern architecture. Instead of a single shared screen, every session runs independently, connected through a pub/sub message bus. Messages route by channel. Sessions subscribe to the channels they’re in. The bus handles delivery.

The ChatBus

At the core is ChatBus — an in-memory pub/sub system built on ConcurrentHashMap and CopyOnWriteArraySet:

public final class ChatBus {
    private final ConcurrentHashMap<String, Set<ChatSubscriber>> channels
            = new ConcurrentHashMap<>();

    public void subscribe(String channel, ChatSubscriber subscriber) {
        Set<ChatSubscriber> subs = channels.computeIfAbsent(
                channel, k -> new CopyOnWriteArraySet<>());
        subs.add(subscriber);
    }

    public void publish(String channel, ChatMessage message) {
        Set<ChatSubscriber> subs = channels.get(channel);
        if (subs == null || subs.isEmpty()) return;
        for (ChatSubscriber sub : subs) {
            sub.onMessage(message);
        }
    }
}

Channels are created lazily — the first subscriber brings them into existence. When everyone leaves, the channel stays in memory but has no subscribers, so publishing to it is a no-op. This is intentional: channel metadata (topic, min group) lives in the database, not in the bus.

Each subscriber implements ChatSubscriber:

public interface ChatSubscriber {
    void onMessage(ChatMessage message);
}

When a message is published, the bus iterates the subscriber set and calls onMessage synchronously on the publishing thread. This is deliberate — it keeps the delivery path simple and avoids the complexity of async dispatch for what is typically a small number of subscribers per channel.

The Single-Room Model

When a user joins a channel, they automatically leave their previous channel. This is the “single-room” model — you can only be in one channel at a time, just like the original BBS chat rooms:

// Joining a new channel auto-parts the old one
if (currentChannel != null) {
    chatBus.unsubscribe(currentChannel, this);
}
chatBus.subscribe(newChannel, this);
currentChannel = newChannel;

This keeps the chat screen simple. One channel, one message stream, one input line. No tabs, no split panes, no notification badges. You’re in a room, and you talk to whoever else is in that room.

Message Persistence

Chat messages are persisted to the database via a ChatMessageService:

public ChatBus(Consumer<ChatMessage> systemSink) {
    this.systemSink = systemSink;
}

public void publish(String channel, ChatMessage message) {
    if (systemSink != null) {
        systemSink.accept(message);  // persist to DB
    }
    // ... then deliver to subscribers
}

The systemSink is a simple callback that the BBS wires to ChatMessageService.save(). Every published message — across all channels — is stored in the database with the channel name, username, timestamp, and body. This enables chat history, message search, and audit trails.

Wall Messages and DMs

Beyond channel chat, the BBS supports two system-level messaging features:

Wall messages are broadcast to all active sessions. When a sysop sends a wall message, it appears as a popup dialog on every connected user’s screen — regardless of which channel they’re in or which screen they’re viewing:

┌─ System Message ───────────────────┐
│ Server going down in 5 minutes     │
│ for maintenance. Please log out.   │
│                        [  OK  ]    │
└─────────────────────────────────────┘

Direct messages (DMs) work the same way — a popup appears on the recipient’s screen. The difference is delivery: wall messages go to all sessions, DMs go to sessions for a specific user.

Both use the ChatBus listener system. The BBS registers a ChatBusListener that watches for messages published to #system (wall) or #dm:username (DM) channels. When a message arrives, it finds the target session(s) and shows a popup:

// Wall message popup
var popup = new MessageDialog(session, "System Message", message.body());
popup.show();

The popup is a modal jterm window — it appears on top of whatever the user is doing, they press OK to dismiss it, and they’re back where they were. No screen disruption, no lost input.

Group-Gated Channels

Channels can have a minimum group requirement. A channel with min_group = "vip" is invisible to regular users — they don’t see it in the channel list, and they can’t join it:

// In the channel list screen, filter by group
for (ChatChannel channel : channels) {
    if (channel.minGroup().isPresent()) {
        if (!session.groups().contains(channel.minGroup().get())) {
            continue;  // skip — user doesn't have access
        }
    }
    // show channel
}

This lets the sysop create private channels for staff, VIP users, or beta testers. The same group-based access control used for boards and file areas applies to chat — one permission system, many applications.

The Chat Screen

The chat screen itself is a full-terminal jterm interface. The top shows the channel name and topic. The middle is a scrollable message list. The bottom is an input line:

┌─ #lobby ─ Welcome to Phosphor BBS! ────────────────────┐
│ [14:23] alice: anyone here?                             │
│ [14:24] bob: yeah, just logged in                        │
│ [14:25] alice: cool, how's the new file area?            │
│ [14:26] bob: the ANSI art collection is great           │
│                                                         │
├─────────────────────────────────────────────────────────┤
│ > _                                                     │
└─ ESC to exit · TAB to switch channel ───────────────────┘

Messages stream in real time. When another user types a message in the same channel, it appears in the list immediately — no refresh, no polling. The ChatSubscriber.onMessage callback adds the message to the list model, and jterm’s reactive rendering handles the rest.

What We Learned

Building BBS chat taught us:

  1. Pub/sub is the right model. A central bus with subscriber sets is simple, fast, and naturally supports channel-based routing. No need for a message queue or actor system.
  2. Synchronous delivery is fine. With a small number of subscribers per channel, synchronous onMessage calls are faster than async dispatch and avoid ordering issues.
  3. Persistence should be a side effect. The systemSink callback pattern lets the bus stay generic — it doesn’t know about databases, it just publishes. The wiring happens at the application level.
  4. Popups for system messages. A modal dialog that appears on top of whatever the user is doing is the right UX for wall messages and DMs. It’s attention-grabbing without being disruptive.

Chat is what makes a BBS feel alive. You log in, see who’s online, and start talking. The messages flow in real time, the channel feels like a room, and the sysop can reach everyone with a wall message. It’s 1990 again — but with better architecture.