---
title: 'Getting started'
url: 'https://wiki.teletypegames.org/engines/rubbs/getting-started'
markdown: 'https://wiki.teletypegames.org/engines/rubbs/getting-started.md'
lang: en
date: '2026-10-27'
description: '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 …'
taxonomy:
  tag:
    - reference
---

# Getting started

> The first page of the [rubbs](https://wiki.teletypegames.org/engines/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](#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](https://wiki.teletypegames.org/engines/rubbs/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](https://wiki.teletypegames.org/engines/rubbs/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](https://wiki.teletypegames.org/engines/rubbs/commodore) |

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](https://wiki.teletypegames.org/engines/rubbs/doors)), `BBS::FakeOS` is a simulated machine ([Doors](https://wiki.teletypegames.org/engines/rubbs/doors)), `BBS::AI` a character to talk to ([AI](https://wiki.teletypegames.org/engines/rubbs/ai)), `BBS::Petscii` and `BBS::Terminal` serve a Commodore 64 caller ([Commodore](https://wiki.teletypegames.org/engines/rubbs/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](https://wiki.teletypegames.org/engines/rubbs/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](#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](https://wiki.teletypegames.org/engines/rubbs/flow). |
| `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](https://wiki.teletypegames.org/engines/rubbs/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](#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](https://wiki.teletypegames.org/engines/rubbs/ai). |
| `user_agent` | `'rubbs/VERSION (+https://teletypegames.org)'` | The `User-Agent` of `BBS::HTTP` requests. See [Data and network](https://wiki.teletypegames.org/engines/rubbs/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](https://wiki.teletypegames.org/engines/rubbs/data-and-network).

# A board's project layout

The boards in our network ([Feedia BBS](https://wiki.teletypegames.org/services/feedia-bbs), [Alexandria BBS](https://wiki.teletypegames.org/services/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](https://wiki.teletypegames.org/engines/rubbs/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](https://wiki.teletypegames.org/engines/rubbs/commodore).

[Alexandria BBS](https://wiki.teletypegames.org/services/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](https://wiki.teletypegames.org/engines/rubbs/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

- Write the dialogue: [Flow](https://wiki.teletypegames.org/engines/rubbs/flow).
- Data, HTTP, caching and limits: [Data and network](https://wiki.teletypegames.org/engines/rubbs/data-and-network).
- A windowed main shell: [Application](https://wiki.teletypegames.org/engines/rubbs/application), [Widgets](https://wiki.teletypegames.org/engines/rubbs/widgets), [Windows](https://wiki.teletypegames.org/engines/rubbs/windows).
- A Commodore 64 face: [Commodore](https://wiki.teletypegames.org/engines/rubbs/commodore).

# See also

- [rubbs](https://wiki.teletypegames.org/engines/rubbs) — the main page
- [Flow](https://wiki.teletypegames.org/engines/rubbs/flow) — the line-oriented dialogue layer
- [Application](https://wiki.teletypegames.org/engines/rubbs/application) — the widget framework
- [Commodore 64](https://wiki.teletypegames.org/engines/rubbs/commodore) — PETSCII, `BBS::Petscii::Screen`, `BBS::Selector`
- [Data and network](https://wiki.teletypegames.org/engines/rubbs/data-and-network) — `BBS::SQLite`, `BBS::Store`, `BBS::HTTP`, `BBS.log`, `BBS.background`
- [Telnet and Store](https://wiki.teletypegames.org/engines/rubbs/telnet-and-store) — the session, keys, mouse
- [Changelog](https://wiki.teletypegames.org/engines/rubbs/changelog)

'; var prevLevel = 2; for (var i = 0; i < headings.length; i++) { var h = headings\[i\]; var text = h.textContent.trim(); var slug = text.toLowerCase() .replace(/\[áàâä\]/g,'a').replace(/\[éèêë\]/g,'e').replace(/\[íìîï\]/g,'i') .replace(/\[óòôöő\]/g,'o').replace(/\[úùûüű\]/g,'u') .replace(/\[^a-z0-9\]+/g, '-').replace(/^-|-$/g, ''); if (!slug) slug = 'heading-' + i; if (used\[slug\]) { slug += '-' + i; } used\[slug\] = true; h.id = slug; var level = parseInt(h.tagName.charAt(1)); var cls = level > 2 ? ' class="toc-child"' : ''; html += '- [' + text + '](#'%20+%20slug%20+%20')
'; } html += '
'; toc.innerHTML = html; // Scroll-spy: highlight current heading var links = toc.querySelectorAll('a'); var ids = \[\]; for (var j = 0; j < links.length; j++) ids.push(links\[j\].getAttribute('href').slice(1)); function updateActive() { var current = ''; for (var k = 0; k < ids.length; k++) { var el = document.getElementById(ids\[k\]); if (el && el.getBoundingClientRect().top <= 80) current = ids\[k\]; } for (var m = 0; m < links.length; m++) { links\[m\].parentNode.classList.toggle('active', links\[m\].getAttribute('href') === '#' + current); } } window.addEventListener('scroll', updateActive, { passive: true }); updateActive(); })();

---

## Navigation

- Parent: [RuBBS](https://wiki.teletypegames.org/engines/rubbs.md)
- Previous: [Flow](https://wiki.teletypegames.org/engines/rubbs/flow.md)
- Next: [Minimal project](https://wiki.teletypegames.org/engines/rubbs/minimal-project.md)
