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:
- 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.
- Synchronous delivery is fine. With a small number of subscribers per channel, synchronous
onMessagecalls are faster than async dispatch and avoid ordering issues. - Persistence should be a side effect. The
systemSinkcallback pattern lets the bus stay generic — it doesn’t know about databases, it just publishes. The wiring happens at the application level. - 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.