The first page of the rubbs manual: how to install the gem, write and run a first board, which of the three layers to use for what, what each
BBS.configurekey does, how the server treats connections (limits, idle timeouts, logging), and how a real board is laid out and shipped in Docker. Written against rubbs 1.0.0.
The gem is called bbs and is not on rubygems.org; it comes from our Git server. In the Gemfile:
source 'https://rubygems.org'
gem 'bbs', git: 'https://git.teletypegames.org/engines/rubbs.git'
then bundle install. Ruby 3.2 or newer is required (the idle timeout of a connection is set with IO#timeout=, see Idle timeouts). The runtime dependencies are artii (FIGlet banners) and sqlite3 (BBS::SQLite and BBS::Store), which builds a native extension, so the machine or the image needs a C compiler.
Every release has a tag, vX.Y.Z (v1.0.0), and the version is in bbs.gemspec. Bundler locks the commit that was HEAD at bundle install in Gemfile.lock, and bundle update bbs moves to the newest. To stay on a release, add tag: 'v1.0.0'; to pin a specific commit, ref: 'COMMIT'. Coming from 0.x, the UPGRADING.md of the repository lists every removed or renamed API with its replacement.
Everything is loaded by one require:
require 'bbs'
# bbs.rb
require 'bbs'
BBS.configure do |c|
c.flow = BBS::Flow.define do
big_banner 'My BBS'
ask :name, prompt: 'Your name'
say 'Welcome!', style: :success
pause 'Hanging up...', seconds: 2
end
end
BBS.start
$ bundle exec ruby bbs.rb
BBS listening on port 2323 — connect with: telnet localhost 2323
In another terminal:
$ telnet localhost 2323
__ ___ ____ ____ _____
/ |/ /_ __ / __ )/ __ ) ___/
/ /|_/ / / / / / __ / __ \__ \
/ / / / /_/ / / /_/ / /_/ /__/ /
/_/ /_/\__, / /_____/_____/____/
/____/
Your name: Zsolt
Welcome!
Hanging up...
When the flow runs out of steps the server hangs up. Ctrl-C in the server's terminal stops it (Server stopped.).
Any telnet client works: the telnet command (on macOS brew install telnet), SyncTERM, NetRunner, Qodem, PuTTY in telnet mode. For the mouse and the full-screen layers use a terminal that speaks xterm escape sequences (SyncTERM, or telnet inside iTerm2, Terminal.app, a Linux terminal). A plain nc is not enough: it does not answer the telnet negotiation, so the server does not learn the window size.
rubbs gives three ways to talk to the caller. They are not alternatives for a whole board: one call usually walks through several of them, starting in a Flow.
| Layer | What it is | Use it for | Manual |
|---|---|---|---|
BBS::Flow |
A line-oriented, declarative dialogue: steps run top to bottom (say, ask, menu, persist, call, …), the text scrolls like on an old BBS. |
The terminal question, a login, a questionnaire, the glue around the rest. Every board starts in one. | Flow |
BBS::Application |
A widget framework: menubar, status bar, overlapping windows, forms, dialogs, mouse, themes, doors. | A Synchronet-style main shell with several windows and features, for ANSI terminals. | Application |
BBS::Petscii::Screen and BBS::Selector |
The screen of a line-by-line board: a title bar, paragraphs, pages with RETURN for more, numbered cursor menus, input fields and an editor. 40×25 on a Commodore 64, any size on a modern terminal. | The C64 face of a board, or a whole board that works line by line. | Commodore 64 |
A rule of thumb: start with a Flow. If the board needs windows, dialogs, a menubar or the mouse, the main part is an Application, with a Flow in front of it for the terminal question or the login. A C64 face, or a board that is simpler line by line, is a Petscii::Screen driven from a call step.
The three side by side, in one board:
require 'bbs'
NEWS = ['rubbs 1.0 is out', 'Feedia reads RSS on a C64', 'New door: Pixelia'].freeze
DESKTOP = BBS::Application.define do
theme BBS::Theme.synchronet
menubar do
menu '&File' do
item '&About' do BBS::Dialogs.message(self, 'Hello from rubbs!') end
item 'E&xit' do :halt end
end
end
status_hint('F10', 'Quit') { :halt }
end
def read_news(session)
screen = BBS::Petscii::Screen.new(session, cols: 80, rows: 24)
item = screen.pick('the news', NEWS)
screen.say(item, color: BBS::Petscii::Colors::YE) if item
screen.pause
end
BBS.configure do |c|
c.flow = BBS::Flow.define do
ask :name, prompt: 'Your name'
menu 'Where to?', loop: true do
option('News (line by line)') { call { |_ctx, runner| read_news(runner.session) } }
option('Desktop (Application)') { app DESKTOP }
option('Log off') { exit_menu }
end
say 'Goodbye!'
end
end
BBS.start
Besides the layers there are building blocks you reach from them: BBS::Door and BBS::Gateway hand the line to another program or board (Doors), BBS::FakeOS is a simulated machine (Doors), BBS::AI a character to talk to (AI), BBS::Petscii and BBS::Terminal serve a Commodore 64 caller (Commodore), and BBS::SQLite, BBS::Store, BBS::HTTP, BBS::Cache, BBS::RateLimit and BBS::Wikipedia are the data and network parts of a content board (Data and network).
flowchart TD
A[BBS::Server accepts a TCP connection] --> B{"fewer than max_sessions callers, and fewer than max_sessions_per_ip from this address?"}
B -- no --> X["writes 'Too many callers right now' or 'Too many calls from your address' and closes"]
B -- yes --> C[new thread, socket timeout = idle_seconds or 900 s]
C --> D[BBS::Session.new: random session_id]
D --> E[telnet negotiation: WILL SGA, WILL ECHO, DO SGA, DO NAWS]
E --> F{config.mouse and config.mouse_on_connect?}
F -- yes --> G[xterm mouse reporting on]
F -- no --> H
G --> H[FlowRunner runs config.flow]
H --> I[steps: say, ask, menu, call, ...]
I --> K[app: Application]
I --> L[call: Petscii::Screen, doors, sub-flows]
K --> I
L --> I
I --> M[last step done, or :halt, or the caller hung up]
M --> N[mouse off, socket closed, on_session_end called]
The same as text:
TCPServer ─ accept ─ admit? ─ Thread ─ Session#run
├ negotiate (SGA, ECHO, NAWS)
├ enable_mouse (config.mouse && config.mouse_on_connect)
└ FlowRunner(config.flow).run
├ app ─▶ Application#run ─ return to the next step
└ call ─▶ your code (Petscii::Screen, doors, sub-flows, BBS::Terminal)
ensure: disable_mouse, close socket, config.on_session_end.call(session)
Every connection runs in its own thread with its own Session and its own flow context (ctx); definitions (Flow.define, Application.define) are shared, read-only templates. Anything mutable you put in a definition at load time (a constant Hash or Array) is shared by all callers.
BBS.configure do |c|
c.flow = MY_FLOW
end
BBS.configure yields the single, process-wide BBS::Config object; BBS.config returns it (creating it on first use). Configure before BBS.start; the server and every session read the values live, so a key changed later affects later connections.
| Key | Default | What it does |
|---|---|---|
flow |
nil |
The BBS::Flow every connection runs. Required: without it each session fails with BBS.config.flow is not set (logged, the caller is dropped). |
port |
ENV['BBS_PORT'] or 2323 |
TCP port for BBS.start. The environment variable is read when the config object is created, i.e. at the first BBS.configure or BBS.config. |
max_sessions |
ENV['BBS_MAX_SESSIONS'] or 100 |
How many connections are served at once. One more is told Too many callers right now, try again later. and closed, and the server logs a BBS busy line. The environment variable is read like BBS_PORT; a value set in BBS.configure wins. |
max_sessions_per_ip |
ENV['BBS_MAX_SESSIONS_PER_IP'] or 6 |
How many connections are served at once from one address. One more is told Too many calls from your address, try again later. and closed (BBS crowded in the log). Private, loopback and link-local peers are not limited: a web proxy such as bbs-web brings many callers from the Docker network. |
idle_seconds |
nil |
The idle limit in seconds. It is the socket read/write timeout of every connection (900 s when nil), and the hard idle disconnect of an Application (with an "Idle" message). See Idle timeouts. |
mouse |
false |
Whether the board uses xterm mouse reporting (SGR, buttons and drags). Applications and doors switch it on and off around themselves only when this is true. |
mouse_on_connect |
true |
With mouse, whether reporting starts right after the negotiation. Set it to false when the flow asks something first, e.g. a two-faced board asking modern or C64: the mouse's escape sequence would be garbage on a Commodore. BBS::Terminal.flow then switches it on for a modern terminal. |
screens_dir |
nil |
Folder of the ERB files for screen steps, for flows that do not name their own (Flow.define(screens_dir:)). See Flow: screens. |
on_session_end |
nil |
A callable invoked with the BBS::Session after each connection ends, normally or with an error. Runs in the connection's thread; useful for logging, a "who is online" list or cleaning up per-session state. |
theme |
nil |
The theme of every Application that has no theme call of its own (and the base of a theme do … end block without one); nil means BBS::Theme.synchronet. Since 0.19.0. See Drawing. |
log_tag |
ENV['BBS_LOG_TAG'] or 'bbs' |
What every BBS.log line starts with: the board's technical name (feedia-bbs). See Logging. |
ai_concurrency |
2 |
How many BBS::AI::Client requests run at once in the process; the next waits up to 5 s, then gets BBS::AI::Busy. See AI. |
user_agent |
'rubbs/VERSION (+https://teletypegames.org)' |
The User-Agent of BBS::HTTP requests. See Data and network. |
A complete configuration:
require 'bbs'
WHO = {}
WHO_LOCK = Mutex.new
BBS.configure do |c|
c.port = 2323
c.max_sessions = 20
c.log_tag = 'harbor-bbs'
c.idle_seconds = 600
c.mouse = true
c.mouse_on_connect = true
c.screens_dir = File.join(__dir__, 'screens')
c.on_session_end = lambda do |session|
WHO_LOCK.synchronize { WHO.delete(session.session_id) }
puts "caller #{session.session_id} left"
end
c.flow = BBS::Flow.define do
call do |ctx, _runner|
WHO_LOCK.synchronize { WHO[ctx[:session_id]] = Time.now }
nil
end
say 'Hello!', style: :success
pause 'See you.', seconds: 2
end
end
BBS.start
BBS.start # port from BBS.config.port
BBS.start(port: 2424) # override
BBS.start(port: BBS.config.port) creates a BBS::Server and calls its run, which blocks for the life of the process. The same by hand:
BBS::Server.new(port: 2424).run
| Listening address | 0.0.0.0 (all interfaces), the given port. A port already in use raises Errno::EADDRINUSE at start. |
| Threads | One Ruby thread per connection; the accept loop never waits for a session. |
| Session limit | config.max_sessions, counted under a mutex; the slot is freed when the session thread ends. |
| Address limit | config.max_sessions_per_ip per peer address, except private, loopback and link-local ones. |
| Accept errors | Any SystemCallError or IOError on accept is logged (accept: …), the loop waits 0.5 s and goes on. |
| Session end | The caller hanging up, a broken pipe or an idle timeout is logged as session: dropped by the client (…); any other exception as session: CLASS: MESSAGE with three lines of backtrace. Neither stops the server. The slot is given back whatever ends the session. |
| Stop | Ctrl-C (SIGINT) or SIGTERM (docker stop) prints Server stopped. and returns from run. |
idle_seconds works at two levels:
timeout = idle_seconds, or BBS::Server::DEFAULT_TIMEOUT (900 s) when it is nil. A blocking read or write that waits longer raises, and the session ends as "dropped by the client". This is what drops a caller who sits at an ask prompt, a "press RETURN", or inside a door.idle_seconds it runs its on_idle block (if any), and unless that returns :keep it shows "Disconnected after N s idle." and returns. With nil it never disconnects by itself.
session.idle_seconds = 3600 changes both for one caller (a sysop, a long read).The library writes every log line through one method, BBS.log(tag, message), to stderr, as LOG_TAG: TAG: MESSAGE, so the lines of many boards in one log read the same. Set log_tag to the board's technical name and use the same method in the board:
BBS.configure { |c| c.log_tag = 'feedia-bbs' }
BBS.log('catalog refresh', 'done in 3.2 s') # feedia-bbs: catalog refresh: done in 3.2 s
BBS.log('catalog refresh', error) # feedia-bbs: catalog refresh: SocketError: …
Work out of the callers' way (an index loaded at boot, a catalog refreshed every hour) goes in BBS.background(name, every: nil) { … }: a thread that logs an error and tries again with a growing pause. See Data and network.
The boards in our network (Feedia BBS, Alexandria BBS, the Teletype BBS) share one layout: a short bbs.rb that wires things together, the board's own logic in lib/domain (repositories that talk to the outside world, services that decide what the caller sees), and one folder per face of the board in lib/ui.
feedia/
├── bbs.rb # configure + start, nothing else
├── Gemfile # gem 'bbs', git: 'https://git.teletypegames.org/engines/rubbs.git'
├── Gemfile.lock
├── lib/
│ ├── domain/ # the board's logic, testable without a line
│ │ ├── feeds.rb
│ │ ├── inbox.rb
│ │ └── html_text.rb
│ └── ui/
│ ├── modern.rb # UI::Modern.flow(...) → Flow + Application for ANSI terminals
│ ├── petscii.rb # UI::Petscii.flow(...) → Flow for a Commodore 64 (PETSCII, 40 columns)
│ └── petscii/screen.rb
├── config/feeds.yml # data shipped with the image
├── data/ # runtime data (stores, logs), a volume in Docker
├── bin/ # small admin scripts
├── Dockerfile
├── compose.yaml
├── entrypoint.sh
└── env-example # the environment variables the board reads
Feedia's whole bbs.rb:
require 'bbs'
require_relative 'lib/domain/feeds'
require_relative 'lib/domain/inbox'
require_relative 'lib/ui/modern'
require_relative 'lib/ui/petscii'
feeds = Feeds.load(ENV.fetch('FEEDIA_FEEDS', File.join(__dir__, 'config/feeds.yml')))
inbox = Inbox.new(path: ENV.fetch('FEEDIA_INBOX', File.join(__dir__, 'data/inbox.jsonl')),
webhook: ENV.fetch('FEEDIA_DISCORD_WEBHOOK', ''))
BBS.configure do |config|
config.mouse = true
config.mouse_on_connect = false
config.idle_seconds = 600
config.flow = BBS::Terminal.flow(title: 'Feedia', modern: UI::Modern.flow(feeds, inbox),
petscii: UI::Petscii.flow(feeds, inbox))
end
BBS.start
The pieces:
bbs.rb and handed to the UI factories (UI::Modern.flow(feeds, inbox)); the UI code reaches them as Application services or through the closure. The domain may use the data and network parts of rubbs (BBS::HTTP, BBS::Cache, BBS::SQLite, BBS.log), never its screens. A repository raises, a service rescues, logs once and decides what the caller sees, the UI never rescues BBS::HTTP::Error (see Data and network).UI::Modern.flow builds an Application and returns a small Flow whose call step runs it (app.run(session: runner.session, session_id: ctx[:session_id], context: ctx)) and then says goodbye. UI::Petscii.flow returns a Flow whose call step drives a BBS::Petscii::Screen.BBS::Terminal.flow asks the caller which terminal they have and runs the matching face; this is why mouse_on_connect is false. A board with one face sets config.flow to its own Flow directly. See Commodore.Alexandria BBS is the same shape with one domain object (lib/domain/wikipedia.rb) and an extra window module (lib/ui/modern/pharos.rb). The Impostor BBS is the other common shape: a gem whose lib/impostor.rb defines a Flow with its own screens/ folder, so the same Flow runs as a standalone board and as a door inside another board (see Flow).
The boards run in Docker, one container each, from the official Ruby Alpine image. git is needed for the Git-sourced gem, build-base for native extensions (sqlite3).
Dockerfile:
FROM ruby:3.3-alpine
RUN apk add --no-cache git build-base tzdata
WORKDIR /app
ENV BUNDLE_WITHOUT=development
COPY Gemfile Gemfile.lock ./
RUN bundle install
COPY bbs.rb entrypoint.sh ./
COPY lib/ ./lib/
RUN chmod +x entrypoint.sh
EXPOSE 2323
ENTRYPOINT ["./entrypoint.sh"]
entrypoint.sh: our boards update rubbs at every start, so a restart picks up a new release without a rebuild. After a server reboot the Git server may answer 502 for a while, so it waits up to a minute and falls back to the installed version:
#!/bin/sh
set -e
RUBBS_GIT=https://git.teletypegames.org/engines/rubbs.git
tries=0
until git ls-remote "$RUBBS_GIT" HEAD >/dev/null 2>&1 || [ "$tries" -ge 12 ]; do
tries=$((tries + 1))
sleep 5
done
bundle update bbs || echo "rubbs update failed, starting with the installed version" >&2
exec bundle exec ruby bbs.rb
exec matters: Ruby becomes PID 1, so docker stop reaches it as a signal.
compose.yaml:
services:
myboard:
build: .
container_name: myboard
mem_limit: 128m
ports:
- "2323:2323"
volumes:
- ./data:/app/data
environment:
- TZ=Europe/Budapest
restart: unless-stopped
Inside the container the board always listens on 2323; the host side of ports picks the public port (Feedia is "2327:2323"). Anything the board writes at runtime (BBS::Store and BBS::SQLite files) goes under /app/data, which is a volume, so it survives a rebuild.
docker compose up -d --build
docker compose logs -f myboard
telnet localhost 2323
Board output (puts, BBS.log) goes to the container log: the listening line, dropped sessions and session errors with backtraces, each line after the board's log_tag.
BBS::Petscii::Screen, BBS::SelectorBBS::SQLite, BBS::Store, BBS::HTTP, BBS.log, BBS.background