EN /HU | Login

Getting started

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.configure key 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.

Installation

The gem is called bbs and is not on rubygems.org; it comes from our Git server. In the Gemfile:

RUBY
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:

RUBY
require 'bbs'

Hello world

RUBY
# 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
TEXT
$ bundle exec ruby bbs.rb
BBS listening on port 2323 — connect with: telnet localhost 2323

In another terminal:

TEXT
$ 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.

The three layers

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:

RUBY
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).

How a connection runs

MERMAID
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:

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

RUBY
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:

RUBY
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 and BBS::Server

RUBY
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:

RUBY
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 timeouts

idle_seconds works at two levels:

  1. The socket. Each accepted socket gets 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.
  2. An Application reads keys with a one-second tick and counts silent seconds; at 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).

Logging

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:

RUBY
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.

A board's project layout

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.

TEXT
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:

RUBY
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:

  • The domain objects are built once in 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).
  • Each face is a method returning a Flow. 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).

Docker

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:

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:

SH
#!/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:

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.

TEXT
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.

Next steps

See also