---
title: Doors
url: 'https://wiki.teletypegames.org/engines/rubbs/doors'
markdown: 'https://wiki.teletypegames.org/engines/rubbs/doors.md'
lang: en
date: '2026-10-27'
description: "Everything in rubbs that hands the caller's telnet line to something else and takes it back: doors (BBS::Door), simulated machines to type into (BBS::FakeOS), telnet gateways to other boards (BBS::Gateway), and ZMODEM file downloads to the caller's terminal (BBS::Zmodem). Every example on this pag…"
taxonomy:
  tag:
    - reference
---

# Doors

> Everything in [rubbs](https://wiki.teletypegames.org/engines/rubbs) that hands the caller's telnet line to something else and takes it back: doors (`BBS::Door`), simulated machines to type into (`BBS::FakeOS`), telnet gateways to other boards (`BBS::Gateway`), and ZMODEM file downloads to the caller's terminal (`BBS::Zmodem`). Every example on this page was run against rubbs 0.17.0, and the page is updated for 0.17.1.

# What a door is

On the old boards a door was a separate program the caller stepped into: the BBS handed over the line, the program talked to the caller directly, and the line came back when the program exited. In rubbs a door is a `BBS::Door` that wraps one of three kinds of program:

| Program | What it is | How the door runs it | How the caller leaves |
|---|---|---|---|
| `BBS::FakeOS` definition | a simulated machine: a boot script and a command shell | `FakeOS::Runner.new(session, definition, context: context).run` | a command that calls `io.halt` (EXIT, BYE, …) |
| `BBS::Flow` | a line dialogue | `FlowRunner` on a **copy** of the context, then "Press ENTER to return to the BBS..." | the flow ends, then ENTER |
| anything with `call(session, context)` | a lambda, a method object, a class of your own, a `BBS::Gateway` | `program.call(session, context)` | the method returns |

A door round trip, from an Application:

```mermaid
sequenceDiagram
  participant C as Caller
  participant A as Application
  participant B as Builder#run
  participant D as Door#run
  participant P as Program
  C->>A: picks Doors > TT-80
  A->>A: open_door(:tt80) sets context[:door], stop!
  A-->>B: event loop ends
  B->>B: on_door block (door, context)
  B->>D: door.run(session, context)
  D->>C: mouse off, reset colours, clear screen
  D->>P: run the program
  P->>C: talks to the caller on the line
  C->>P: EXIT
  P-->>D: returns
  D->>C: mouse back on (if BBS.config.mouse)
  D-->>B: returns
  B->>A: builds the Application again (same context)
  A->>C: the desktop, repainted
```

The smallest door is a lambda, run from a flow:

```ruby
require 'bbs'

DICE = BBS::Door.new(:dice, lambda { |session, ctx|
  session.write("Rolling for #{ctx[:username]}... #{rand(1..6)}\r\nPress a key.")
  session.readkey
}, label: '&Dice')

BBS.configure do |c|
  c.flow = BBS::Flow.define do
    ask :username, prompt: 'Your name'
    call do |ctx, runner|
      DICE.run(runner.session, ctx)
      runner.write "\r\nBack on the board.\r\n"
      nil
    end
  end
end

BBS.start
```

# BBS::Door

Source: `lib/bbs/door.rb`.

## Door.new

```ruby
BBS::Door.new(name, program, label: nil)
```

| Argument | Default | Meaning |
|---|---|---|
| `name` | required | the door's name; stored as a Symbol (`door.name`) |
| `program` | required | a `BBS::FakeOS::Definition`, a `BBS::Flow`, or any object that responds to `call` |
| `label:` | `name.to_s.upcase` | the menu text; `&` marks the hotkey, as in `'&SERMO'` |

Anything else raises `ArgumentError` ("not a FakeOS definition or a callable"). The readers are `name`, `label` and `program`.

## Door#run

```ruby
door.run(session, context = {})
```

What it does, in order:

1. `session.disable_mouse`, so the program does not get mouse reports it cannot read.
2. Writes `ESC[0m ESC[2J ESC[H`: default colours, a clear screen, the cursor at the top left.
3. Runs the program by its kind (the table above).
4. Afterwards, whatever happened: `session.enable_mouse` if `BBS.config.mouse` is set.

It returns `nil` when the program fails (see below); otherwise what the program returned.

### The context

The `context` hash is the caller's state for the whole call. A FakeOS door and a callable door get **the same hash**: what the door writes there stays after it returns, so a door can keep its state (an AI chat history, a drive letter, a score) for the rest of the call, and the board can read it afterwards.

A `BBS::Flow` door gets `context.dup` instead, so its `ask` steps cannot overwrite the board's own keys (`:username`, `:authenticated`, …). The flow runner also needs `context[:session_id]`; inside an Application it is already there, on its own pass it in. Give a flow that ships with its own ERB screens a `screens_dir:` (see [Flow](https://wiki.teletypegames.org/engines/rubbs/flow)). A flow has no exit command of its own, so when it ends the door writes "Press ENTER to return to the BBS..." and waits for a line.

### Errors and hang-ups

A door's fault must not end the caller's call:

| What happens in the program | Result |
|---|---|
| any `StandardError` (a bug, a failed lookup) | logged with `BBS.log` under `door NAME` (class, message, three backtrace lines); `run` returns `nil` and the caller is back on the board |
| `IOError` or `SystemCallError` (the line dropped) | re-raised: the call ends, as it should |
| a FakeOS or Flow program sees the line drop | its runner swallows `IOError`, `EPIPE` and `ECONNRESET` and returns; the board notices the hang-up at its next read |

```ruby
require 'bbs'

broken = BBS::Door.new(:broken, ->(_session, _ctx) { raise 'boom' })
broken.run(session, ctx)   # logs "my-bbs: door broken: RuntimeError: boom", returns nil

dropped = BBS::Door.new(:dropped, ->(_session, _ctx) { raise IOError, 'line dropped' })
dropped.run(session, ctx)  # raises IOError: the call ends
```

## Doors in an Application

The [Application](https://wiki.teletypegames.org/engines/rubbs/application) DSL declares doors, lists them in a menu, and opens them from any handler. Source: `lib/bbs/application.rb`.

| DSL / method | Where | What it does |
|---|---|---|
| `door(name, program, label: nil)` | `Application.define` | declares a door: `Door.new(name, program, label: label)`, kept in declaration order |
| `doors` | inside `menu(...) { }` | one menu item per declared door, labelled with `door.label`, each calling `open_door(door.name)` |
| `open_door(name)` | on the running application (menu items, status hints, key handlers, window buttons, list callbacks, helpers) | puts the door in `context[:door]`, stops the event loop after the current event and returns `:halt`; raises `ArgumentError` for an unknown name |
| `on_door { \\|door, context\\| }` | `Application.define` | runs just before the line goes to a door: a node list entry, a log line |
| `Builder#run(session:, session_id:, context: {})` | the defined application | builds and runs the application; when it ends through a door, runs the door and builds the application again; returns when the caller leaves without a door, or the line is lost (then a pending door is not opened) |
| `Application#doors` | the running application | the declared doors as a Hash by name |

`open_door` works through `Builder#run`, which the flow step `app BOARD` also uses (since 0.17.1). A plain `BOARD.build(...).run` runs the event loop once and stops: `open_door` just ends the application there. Start an application with doors from a flow `app` step, or from a `call` step when code goes around it, as here:

```ruby
require 'bbs'
require_relative 'tt80' # the TT-80 FakeOS from the complete example below

QUIZ = BBS::Flow.define do
  big_banner 'Quiz', font: 'small'
  ask :answer, prompt: 'What is 6 x 7'
  call do |ctx, runner|
    runner.write(ctx[:answer] == '42' ? "  Right!\r\n" : "  No, it is 42.\r\n")
    nil
  end
end

DICE = lambda do |session, ctx|
  session.write("Rolling for #{ctx[:username]}... #{rand(1..6)}\r\nPress a key.")
  session.readkey
end

BOARD = BBS::Application.define do
  door :tt80, TT80, label: '&TT-80'
  door :quiz, QUIZ, label: '&Quiz'
  door :dice, DICE, label: '&Dice'

  menubar do
    menu('&Doors') { doors }
  end

  status_hint('F5', 'Dice') { open_door(:dice) }
  status_hint('Alt-X', 'Bye') { :halt }

  on_door do |door, ctx|
    warn "#{ctx[:username]} enters #{door.name}"
  end
end

BBS.configure do |c|
  c.flow = BBS::Flow.define do
    ask :username, prompt: 'Your name'
    call do |ctx, runner|
      BOARD.run(session: runner.session, session_id: ctx[:session_id], context: ctx)
      nil
    end
  end
end

BBS.start
```

F5 rolls the dice, Alt-D opens the Doors menu with TT-80, Quiz and Dice. The quiz's `:answer` stays in the quiz's copy of the context; the TT-80's drive letter stays in the board's context.

In the Teletype BBS (`bbs/teletype-bbs`) the board keeps its doors as data (`TeletypeBBS#doors` in `lib/domain/teletype_bbs.rb`: name, program, label, the narrowest width it needs, an optional C64 version), the modern face declares them in a loop, and `on_door` puts "Door: SERMO" on the caller's node in Who is Online:

```ruby
bbs.doors.each { |d| door d.name, d.program, label: d.label }

on_door do |door, ctx|
  bbs.online.activity(ctx[:session_id], "Door: #{door.label.delete('&')}")
end
```

## A door without an Application

`Door#run` needs only a session and a context. A flow `call` step, a PETSCII screen or your own code can open one directly, as in the first example on this page. The PETSCII face of the Teletype BBS (`lib/ui/petscii/board.rb`) has no Application and does exactly this, picking the 40-column version of a door when it has one:

```ruby
BBS::Door.new(door.name, door.petscii || door.program, label: door.label).run(@screen.session, @ctx)
```

Without the `Door` wrapper, a flow can also run a FakeOS in place with the `os` step. It shares the flow's context, but gets no screen clearing, no mouse handling and no error guard:

```ruby
BBS.config.flow = BBS::Flow.define do
  ask :username, prompt: 'Your name'
  os TT80
  say 'Back on the board.'
end
```

# BBS::FakeOS

Source: `lib/bbs/fake_os.rb`. A teletype-style simulated machine: a scripted boot sequence, then a command shell. Each simulated OS is a different definition; the engine is generic, so a new machine is configuration, not new engine code.

## The shape of a definition

```ruby
require 'bbs'

DOS = BBS::FakeOS.define do
  name   'MS-DOS'
  prompt 'C:\\>'
  colors text: "\e[0;37m", accent: "\e[1;32m"

  boot do
    print 'Award Modular BIOS v4.51PG', delay: 0.4
    memtest 16_384
    pause 0.5
    cls
    big_banner 'MS-DOS'
  end

  ready { puts 'Type VER, CLS or EXIT.' }

  command('ver')          { |_args, io| io.puts 'MS-DOS Version 6.22' }
  command('cls')          { |_args, io| io.cls }
  command('exit', 'quit') { |_args, io| io.halt }

  on_unknown do |_input, io|
    io.puts 'Bad command or file name'
  end
end
```

`BBS::FakeOS.define { ... }` evaluates the block on a new `FakeOS::Definition` and returns it. There are two kinds of block in a definition, and they see different things:

- `boot` and `ready` run **on the console** (`instance_eval`): `print`, `puts`, `cls`, `ctx`, `session` and the rest work without a receiver. Note that `puts` and `print` there write to the caller, not to the server's stdout; log with `warn`.
- `command` and `on_unknown` blocks are **called with arguments**: `(args, io)` and `(input, io)`. Use `io` for all output and state.

## The definition DSL

| Method | Default | What it does |
|---|---|---|
| `name(value = nil)` | `'OS'` | sets the name; without an argument returns it. Informational: the shell does not show it |
| `prompt(value = nil, &block)` | `'> '` | a String, or a block / lambda taking the context and returning the prompt, evaluated before every line; without arguments returns the current one. `''` is allowed (the Commodore doors print their own `READY.`) |
| `colors(overrides = nil)` | `DEFAULT_COLORS` | merges SGR strings into the colour roles; without an argument returns the role Hash |
| `boot { }` | none | the power-on script, run once on the console |
| `ready { }` | none | runs once after `boot`, before the first prompt |
| `command(*names, help: nil) { \\|args, io\\| }` | none | registers a command under one or more names; the first is the canonical one |
| `on_unknown { \\|input, io\\| }` | prints "Bad command or file name" | what happens with input that matches no command |

The readers `boot_block`, `ready_block`, `commands`, `order` and `unknown` expose what was declared. `commands` maps every lower-cased name (aliases included) to `{ name:, help:, block: }`; `order` lists the canonical names in declaration order. A HELP command builds its list from these (see the complete example).

A prompt that follows the state of the machine:

```ruby
prompt { |ctx| "#{ctx[:drive] || 'A'}> " }
# or
prompt ->(ctx) { "#{ctx[:drive] || 'A'}> " }
```

## Colour roles and `base`

Every output primitive takes a `style:` role. An unknown role falls back to `:text`.

| Role | Default | Used by |
|---|---|---|
| `text` | `"\e[0;37m"` (light grey) | `puts`, `print`, `type`, `memtest` |
| `bright` | `"\e[1;37m"` | your highlights |
| `accent` | `"\e[1;32m"` | `banner`, `big_banner` |
| `error` | `"\e[1;31m"` | "Bad command or file name", "Error: …" |
| `dim` | `"\e[1;30m"` | your quiet text (AI replies, hints) |
| `prompt` | `"\e[0;37m"` | the prompt |
| `base` | `''` | what every reset returns to |

After every piece of output the console writes `ESC[0m` followed by `base`, and `cls` writes the same before erasing the screen. So when `base` sets a background, that background survives the prompt, the caller's echo and the new lines, and `cls` fills the whole screen with it. Give every other role the same background too: an SGR that starts with `0;` clears it.

```ruby
colors base:   "\e[44m",          # blue screen
       text:   "\e[0;37;44m",
       bright: "\e[0;97;44m",
       error:  "\e[0;91;44m",
       prompt: "\e[0;97;44m"
```

## The console

`FakeOS::Console` owns the session and the output. It is `self` in `boot` and `ready`, and the `io` argument of commands.

| Method | Defaults | What it does |
|---|---|---|
| `puts(text = '', style: :text)` |  | one line, ending in CRLF |
| `print(text = '', delay: 0, style: :text, newline: 1)` |  | text, then `newline` CRLFs, then a pause of `delay` seconds; `newline: 0` leaves the cursor on the line |
| `type(text, style: :text, cps: 900, newline: 1)` |  | a typewriter: one character at a time at `cps` characters a second (`cps: 0` writes at once) |
| `newline(count = 1)` |  | `count` CRLFs |
| `cls` |  | resets the colours (to `base`) and clears the screen |
| `pause(seconds)` |  | sleeps |
| `banner(text, style: :accent)` |  | a boxed one-line banner (`BBS::Banner.render`) |
| `big_banner(text, style: :accent, font: 'standard')` |  | a figlet banner through artii (`'standard'`, `'small'`, `'slant'`, …) |
| `memtest(kilobytes, step: 2048, delay: 0.03, label: 'Memory Test : ')` |  | the counting memory test, in place on one line, ending in "64K OK" |
| `cols`, `rows` | 80, 24 | the caller's window size (telnet NAWS) |
| `halt` |  | ends the shell after the current command |
| `ctx` |  | the context hash (the door's context) |
| `session` |  | the `BBS::Session`, for `readline`, `readkey`, `write` |
| `definition` |  | the definition being run |

## The shell loop

After `boot` and `ready` the console loops until `halt` or a hang-up:

1. Writes the prompt in the `prompt` role.
2. Reads a line (`session.readline`); `nil`, a hang-up, ends the loop.
3. Strips it; an empty line just prompts again.
4. Splits off the first word, lower-cases it and looks it up in `commands`, so commands are case-insensitive. The block gets the rest of the line as a String (`''` when there is none), and the console.
5. No match: `on_unknown` gets the whole stripped line; without `on_unknown`, "Bad command or file name" in the `error` role.

A `StandardError` in a command or in `on_unknown` is logged (`my-bbs: fake os command: …`) and shown to the caller as "Error: MESSAGE" in the `error` role, and the shell goes on. `IOError` and `SystemCallError` pass through. An exception in `boot` or `ready` is not caught by the console: inside a door it takes the caller back to the board.

There is **no built-in EXIT or HELP**. Register them yourself; EXIT calls `io.halt`:

```ruby
command 'exit', 'quit', 'bye', help: 'back to the BBS' do |_args, io|
  io.puts 'Goodbye.'
  io.halt
end
```

A command can hold its own little dialogue through the session:

```ruby
command 'name', help: 'asks for your name' do |_args, io|
  io.print 'NAME? ', newline: 0
  name = io.session.readline.to_s.strip
  io.puts "HELLO, #{name.upcase}."
end
```

## Running a FakeOS

| How | Context | Extras |
|---|---|---|
| `BBS::Door.new(:name, DEFINITION).run(session, ctx)` | the same hash | clear screen, mouse off and on, error guard |
| `door :name, DEFINITION` in an Application | the application's context | all of the above, plus the menu and `on_door` |
| `os DEFINITION` step in a flow | the flow's context | none |
| `BBS::FakeOS::Runner.new(session, DEFINITION, context: {}).run` | the given hash | none; returns `nil` on a hang-up |

## A complete machine: the TT-80

A made-up 1980 microcomputer with a blue screen, a boot sequence, a prompt that shows the current drive, a generated HELP, a command with arguments, one with its own question, one that fails on purpose, and EXIT. Save it as `tt80.rb`:

```ruby
require 'bbs'

TT80 = BBS::FakeOS.define do
  name   'TT-80'
  prompt ->(ctx) { "#{ctx[:drive] || 'A'}> " }

  blue = "\e[44m"
  colors base:   blue,
         text:   "\e[0;37;44m",
         bright: "\e[0;97;44m",
         accent: "\e[0;93;44m",
         error:  "\e[0;91;44m",
         dim:    "\e[0;36;44m",
         prompt: "\e[0;97;44m"

  boot do
    cls
    print 'TT-80 ROM MONITOR V1.3', delay: 0.2
    memtest 64, step: 16, delay: 0.02, label: 'RAM CHECK '
    pause 0.3
    cls
    banner 'TT-80 DISK OPERATING SYSTEM'
    type 'Insert a disk, or just type.', style: :dim, cps: 120
    ctx[:drive] ||= 'A'
  end

  ready do
    puts "#{session.term_cols}x#{session.term_rows} screen. HELP lists the commands.", style: :dim
    newline
  end

  command 'help', '?', help: 'this list' do |_args, io|
    io.definition.order.each do |name|
      entry = io.definition.commands[name]
      io.puts format('  %-6s %s', name.upcase, entry[:help]), style: :bright
    end
  end

  command 'dir', help: 'list the disk' do |_args, io|
    io.puts "  #{io.ctx[:drive]}: GAMES    BAS   #{io.ctx[:drive]}: README   TXT"
  end

  command 'drive', help: 'DRIVE B switches the drive' do |args, io|
    letter = args.strip.upcase
    if letter.match?(/\A[AB]\z/)
      io.ctx[:drive] = letter
    else
      io.puts 'DRIVE A or DRIVE B', style: :error
    end
  end

  command 'name', help: 'asks for your name' do |_args, io|
    io.print 'NAME? ', newline: 0
    name = io.session.readline.to_s.strip
    io.puts "HELLO, #{name.upcase}."
  end

  command 'size', help: 'the screen size' do |_args, io|
    io.puts "#{io.cols} COLUMNS, #{io.rows} ROWS"
  end

  command 'crash', help: 'a broken command' do |_args, _io|
    raise 'DISK NOT READY'
  end

  command 'exit', 'bye', help: 'back to the BBS' do |_args, io|
    io.puts 'SYSTEM HALTED.'
    io.halt
  end

  on_unknown do |input, io|
    io.puts "#{input.split.first.upcase}? SYNTAX ERROR", style: :error
  end
end
```

And a board that opens it as a door, `bbs.rb`:

```ruby
require 'bbs'
require_relative 'tt80'

TT80_DOOR = BBS::Door.new(:tt80, TT80, label: '&TT-80')

BBS.configure do |c|
  c.flow = BBS::Flow.define do
    ask :username, prompt: 'Your name'
    call do |ctx, runner|
      TT80_DOOR.run(runner.session, ctx)
      runner.write "\r\nBack on the board, #{ctx[:username]}. Drive was #{ctx[:drive]}.\r\n"
      nil
    end
  end
end

BBS.start
```

A session with a 100×30 terminal:

```text
TT-80 ROM MONITOR V1.3
RAM CHECK 64K OK

  ╔═══════════════════════════════╗
  ║  TT-80 DISK OPERATING SYSTEM  ║
  ╚═══════════════════════════════╝

Insert a disk, or just type.
100x30 screen. HELP lists the commands.

A> help
  HELP   this list
  DIR    list the disk
  DRIVE  DRIVE B switches the drive
  NAME   asks for your name
  SIZE   the screen size
  CRASH  a broken command
  EXIT   back to the BBS
A> drive b
B> crash
Error: DISK NOT READY
B> foo bar
FOO? SYNTAX ERROR
B> exit
SYSTEM HALTED.

Back on the board, Zsolt. Drive was B.
```

## FakeOS in production

**SERMO** (`bbs/sermo-bbs`, gem `sermo`, `lib/sermo/sermo_os.rb`): a green-phosphor machine from the Neumatronic universe. The boot sequence is a page of `print ..., delay:` lines and a `memtest` with its own `label:`; eleven literal commands (`VER`, `DIR`, `MEM`, …) are the "digital side", and `on_unknown` sends everything else to an AI character through `BBS::AI::Typewriter` (see [AI characters](https://wiki.teletypegames.org/engines/rubbs/ai)). The gem is only the definition, `Sermo::OS`; the host board runs it:

```ruby
require 'sermo'

BBS::FakeOS::Runner.new(session, Sermo::OS, context: ctx).run
# or, in an Application:
door :sermo, Sermo::OS, label: '&SERMO'
```

**Commodore 64 and Plus/4** (`bbs/teletype-bbs`, `lib/doors/commodore.rb`): one `Commodore.os(machine, games:, sizes:)` builds a FakeOS for either machine. The definition is tiny; the machine lives in a `Shell` object kept in the context:

```ruby
BBS::FakeOS.define do
  name   machine.platform
  prompt ''                      # the BASIC prints READY. itself
  colors machine.colors          # base: "\e[94;44m" for the C64's blue screen

  boot do
    ctx[:commodore] = Shell.new(self, machine, games: games, files: files)
    ctx[:commodore].power_on
  end

  on_unknown { |input, io| io.ctx[:commodore].enter(input) }
end
```

There are no `command` entries at all: every line goes to the shell, which parses `LOAD"$",8`, `LIST`, `RUN` and BASIC lines, wraps output at 40 columns itself, and calls `io.halt` on EXIT. A running BASIC program polls `io.session.readkey(timeout: 0)` so any key works as RUN/STOP.

**Chat of Ra** (`lib/doors/ra.rb`): a sun-god character with commands for the game logic (`RIDDLE`, `HINT`, `SKIP`, `GAME`) and `on_unknown` for free talk to the model.

# BBS::Gateway

Source: `lib/bbs/gateway.rb` and `lib/bbs/gateway/`, new in 0.17.0; the report and `connect_timeout:` came in 0.31.0, `translate:` in 0.34.0, `public_only:` in 1.0.0. A door to another telnet board, like the telnet gateways of the old BBSes: the caller's line goes on to that board as it is, and comes back when that board hangs up. The other board runs its own code on its own server; the gateway only carries the bytes.

## Gateway.new

```ruby
BBS::Gateway.new(host:, port:, name: nil, terminal: 'XTERM-256COLOR', connect_timeout: 5, idle: nil,
                 translate: false, public_only: true)
```

| Option | Default | Meaning |
|---|---|---|
| `host:` | required | the other board's host name or address |
| `port:` | required | its telnet port (converted with `to_i`) |
| `name:` | `"HOST:PORT"` | the name in "Connecting to …", "… hung up" and the log |
| `terminal:` | `'XTERM-256COLOR'` | the terminal type reported when the other board asks (telnet TTYPE) |
| `connect_timeout:` | 5 | seconds to wait for the other board to accept the connection |
| `idle:` | `nil` | seconds the line may stay silent both ways before the call ends as if the caller hung up; `nil` means `BBS.config.idle_seconds` (900 s when that is unset) |
| `translate:` | `false` | a PETSCII caller gets the other board through an `AsciiBridge` (see *A C64 calling an ASCII board*); a modern caller's line is never translated |
| `public_only:` | `true` | call only an address on the public internet (`BBS::Address.public?`); `false` for a trusted neighbour such as `host.docker.internal` |

| Constant | Value | Meaning |
|---|---|---|
| `Gateway::CONNECT_TIMEOUT` | 5 | the default `connect_timeout:` |
| `Gateway::BACK_WAIT` | 30 | seconds to wait for the caller's key before taking the line back anyway |

The readers are `host`, `port` and `name`. A gateway is a callable, `call(session, context = {})`, so it becomes a door like any program:

```ruby
door :feedia, BBS::Gateway.new(host: 'feedia.example', port: 2327, name: 'Feedia'), label: '&Feedia'
```

## Public addresses only

Since 1.0.0 the gateway resolves the host once (an IPv4 address when it has one), checks the address with `BBS::Address.public?` and connects to that vetted address. Loopback, private, link-local, CGNAT and ULA addresses are refused: a listing that names `127.0.0.1` or a container cannot turn the board into a relay into its own network. The caller reads "NAME may not be called from here.", and the report's `error` is a `BBS::Gateway::Refused`.

A board that dials its own neighbours on purpose says so:

```ruby
FEEDIA = BBS::Gateway.new(host: 'host.docker.internal', port: 2327, name: 'Feedia', public_only: false)
```

## A report of each call

A directory that dials many boards wants to know how each call went. With a block, `call` yields a `BBS::Gateway::Report` once the call is over (also when the caller hung up inside):

| Field | Meaning |
|---|---|
| `connected` | whether the other board accepted the connection |
| `error` | what kept it from connecting: `SocketError` (the name does not resolve), `BBS::Gateway::Refused`, `Errno::ECONNREFUSED`, a timeout; `nil` when it connected |
| `address` | the IP address called |
| `connect_seconds` | how long the connection took to open |
| `received` | the bytes the other board sent, telnet commands not counted |
| `hung_up` | who ended the crossing: `:board` or `:caller` |

```ruby
BBS::Gateway.new(host: board.host, port: board.port, name: board.name, connect_timeout: 10).call(session) do |report|
  record(board, report.connected ? 'ok' : report.error.class.name)
end
```

## A C64 calling an ASCII board

A gateway carries the bytes untouched, so without help a Commodore 64 can only call boards that speak PETSCII. With `translate: true` a PETSCII caller's line goes through a `BBS::Gateway::AsciiBridge` (since 0.34.0):

- the board's text (UTF-8, or CP437 once it turns out not to be UTF-8) reaches the C64 as PETSCII through the PETSCII encoder: letters in the C64's case, ANSI colours as C64 colours, line drawing as the nearest glyph;
- the C64's keys reach the board as an ANSI terminal sends them: RETURN as CR, DEL as backspace, the cursor keys as `ESC [ A`…;
- behind the bridge the C64 answers TTYPE as `ANSI` and NAWS with its 40×25, so the board formats for 40 columns.

```ruby
BBS::Gateway.new(host: board.host, port: board.port, translate: !board.petscii?).call(session)
```

## A crossing

```mermaid
sequenceDiagram
  participant C as Caller
  participant G as Gateway (this board)
  participant R as Other board
  C->>G: picks the door
  G->>C: Connecting to NAME…
  G->>R: TCP connect (5 s)
  R->>G: IAC DO NAWS, IAC DO TTYPE, IAC WILL ECHO …
  G->>R: IAC WILL NAWS + the caller's size, IAC WILL TTYPE, IAC DO ECHO …
  R->>G: IAC SB TTYPE SEND
  G->>R: IAC SB TTYPE IS XTERM-256COLOR
  loop until one side hangs up
    R->>G: screen bytes
    G->>C: passed on as they are
    C->>G: keys, mouse reports
    G->>R: passed on as they are
    C->>G: IAC SB NAWS (window resized)
    G->>R: IAC SB NAWS (new size)
  end
  R--xG: hangs up
  G->>C: NAME hung up. Press a key to return.
  C->>G: a key
  G-->>C: back on this board
```

## The telnet client side

Towards the other board the gateway is the telnet client. It answers each (command, option) pair once, so a board that asks again gets no loop:

| The other board sends | The gateway answers |
|---|---|
| `WILL ECHO`, `WILL SGA` | `DO` |
| `WILL` anything else | `DONT` |
| `DO NAWS` | `WILL NAWS`, then the caller's window size; on a PETSCII line `WONT NAWS` |
| `DO SGA` | `WILL SGA` |
| `DO TTYPE` (option 24) | `WILL TTYPE`; on a PETSCII line `WONT TTYPE` |
| `WILL BINARY`, `DO BINARY` | agreed, and the same asked of the caller's terminal, so a `BBS::Xmodem` transfer works through the gateway |
| `DO` anything else | `WONT` |
| `WONT`, `DONT` | nothing |
| `SB TTYPE SEND` | `SB TTYPE IS` plus the `terminal:` string; nothing on a PETSCII line |

And in the other direction:

- Ordinary bytes go through untouched both ways: ANSI, UTF-8, mouse reports, PETSCII. A doubled `IAC IAC` (a 0xFF data byte) stays doubled.
- The caller's window-size reports (`SB NAWS`) update this board's session too (`Session#resize!`), so after the crossing the board redraws at the new size; they are passed on as fresh NAWS reports once the other board has asked for NAWS.
- The caller's other telnet commands are not passed on: this board already negotiated with the caller's terminal, and the other board gets the gateway's answers instead.

A PETSCII line (after `session.petscii!`, see [Commodore 64](https://wiki.teletypegames.org/engines/rubbs/commodore)) reports no window size and no terminal type, so a two-faced board on the other end (`BBS::Terminal`) takes the caller for a Commodore 64 and speaks PETSCII. The bytes it sends go to the line untouched, not through this board's PETSCII encoder; only with `translate:` does the gateway step in (see *A C64 calling an ASCII board*).

`session.line` is the caller's raw socket. The gateway reads and writes it directly; a door of your own that hands the line on as it is can do the same.

## Hang-ups and failures

| What happens | What the caller sees | What `call` does |
|---|---|---|
| the other board hangs up | "NAME hung up. Press a key to return." | waits up to 30 s for a key, then returns: back on this board |
| the connection is refused, the host is unknown, or `connect_timeout` passes | "NAME does not answer. Try again later. Press a key." | logs the error (`gateway: NAME (HOST:PORT): …`), waits up to 30 s for a key, returns `nil` |
| the address is not public and `public_only` is on | "NAME may not be called from here. Press a key." | the same, with `BBS::Gateway::Refused` |
| the line stays silent both ways for `idle` seconds | nothing | ends the call as if the caller hung up |
| the caller hangs up | nothing | shuts the other board's socket and raises `IOError`: the call ends (`Door#run` re-raises it) |

Two consequences worth knowing:

- A crossing ends when the line is silent both ways for `idle:` seconds (`BBS.config.idle_seconds`, 900 s when unset); a caller who only reads while the other board keeps sending stays on.
- The other board's terminal modes stay as it left them. If it switched mouse reporting on and hung up without switching it off, and this board has `BBS.config.mouse` off, the caller's terminal keeps sending mouse reports.

## Example: one board as a door of another

Board B on port 2473 runs the TT-80 from above; board A on port 2474 offers it, and a board that is not there, as gateways:

```ruby
# board_b.rb
require 'bbs'
require_relative 'tt80'

BBS.configure do |c|
  c.port = 2473
  c.flow = BBS::Flow.define do
    call do |ctx, runner|
      runner.session.settle(timeout: 1.5)   # let the window size arrive first
      BBS::FakeOS::Runner.new(runner.session, TT80, context: ctx).run
      nil
    end
  end
end

BBS.start
```

```ruby
# board_a.rb
require 'bbs'

TT80_BBS = BBS::Gateway.new(host: '127.0.0.1', port: 2473, name: 'TT-80 BBS', public_only: false)
NOWHERE  = BBS::Gateway.new(host: '127.0.0.1', port: 2479, name: 'Nowhere BBS', public_only: false)

BBS.configure do |c|
  c.port = 2474
  c.flow = BBS::Flow.define do
    menu 'Where to?', loop: true do
      option 'TT-80 BBS' do
        call { |ctx, runner| BBS::Door.new(:tt80, TT80_BBS).run(runner.session, ctx); nil }
      end
      option 'Nowhere BBS' do
        call { |ctx, runner| BBS::Door.new(:nowhere, NOWHERE).run(runner.session, ctx); nil }
      end
      option 'Hang up' do
        exit_menu
      end
    end
  end
end

BBS.start
```

`telnet localhost 2474` with a 120×40 window, option 1: board B prints "120x40 screen"; resize the window and its SIZE command shows the new size; EXIT on board B brings "TT-80 BBS hung up. Press a key to return." Option 2 answers "Nowhere BBS does not answer. Try again later. Press a key."

## In production: TTG Gate BBS

TTG Gate BBS (`bbs/ttg-gate-bbs`, port 2399) reaches every board of our network through a gateway: its dialing menu picks a board from `config/boards.yml`, and `lib/ui/dial.rb` hands the line on. Our own boards are neighbours on the host, so that gateway says `public_only: false`; the boards of its Global Discovery directory are dialed with the default, at the vetted public address of the listing, with `translate:` when a C64 calls a board made for ASCII terminals, and the report of each call is recorded:

```ruby
BBS::Gateway.new(host: board.host, port: board.port, name: board.name, public_only: false).call(session)
```

Every board publishes its telnet port on the host, so the gate's container reaches them through the host (`TTG_GATE_BBS_GATEWAY_HOST`, `host.docker.internal`). Docker on Linux has no `host.docker.internal` by default; `compose.yaml` maps it to the host's gateway address:

```yaml
services:
  ttg-gate-bbs:
    extra_hosts:
      - "host.docker.internal:host-gateway"
```

A C64 caller reports no window size through the gateway either, so the board on the other end picks its PETSCII face. (Until 2026-11-01 the Teletype BBS had gateway doors of its own to Alexandria, Feedia and the rest; now the gate is the one way through.)

# BBS::Zmodem

Source: `lib/bbs/zmodem.rb`, since 0.9.0. The sending side of Chuck Forsberg's ZMODEM, enough for the caller's terminal to save files from the board.

## send\_files

```ruby
BBS::Zmodem.send_files(session, files, wait: 30, telnet: true)
```

| Argument | Default | Meaning |
|---|---|---|
| `session` | required | the caller's session; the transfer reads it with `read_raw_byte` and writes with `write` |
| `files` | required | an Array of `[name, data]` or `[name, data, mtime]`; `name` is reduced to its base name, `data` is a String (binary is fine), `mtime` a Time (default `Time.now`) |
| `wait:` | 30 | seconds to wait for a ZMODEM receiver to answer |
| `telnet:` | `true` | double every 0xFF as a telnet IAC; `false` for a plain byte pipe |

| Result | When |
|---|---|
| `:ok` | the terminal took the files, or skipped some of them (ZSKIP) |
| `:cancelled` | the caller pressed Esc or Ctrl-C while the board waited for the terminal, the terminal sent five CANs, or it answered ZCAN, ZABORT or ZFERR |
| `:timeout` | no receiver answered within `wait:`, or a file was offered five times without an answer |
| `:hangup` | the line dropped |

On `:cancelled` and `:timeout` the board sends the abort sequence (eight CANs, eight backspaces), so a terminal that is half-way in stops too.

## Application#with\_line

A transfer needs the raw line and a clean screen. `with_line` hands the session to its block on a cleared screen without mouse reporting, switches the mouse back on (if `BBS.config.mouse`) afterwards, forces a full repaint of the application, and returns what the block returned:

```ruby
require 'bbs'

README = "Welcome to the files area.\r\n"

FILES = BBS::Application.define do
  helpers do
    def download_readme
      result = with_line do |session|
        session.write("  Sending README.TXT by ZMODEM; Esc gives up.\r\n")
        BBS::Zmodem.send_files(session, [['README.TXT', README, Time.now]], wait: 20)
      end
      message = {
        ok:        'README.TXT is on your disk.',
        cancelled: 'The download was cancelled.',
        timeout:   'Your terminal did not start a ZMODEM download.'
      }.fetch(result, 'The line dropped.')
      BBS::Dialogs.message(self, message, title: ' Download ')
    end
  end

  status_hint('F2', 'Download') { download_readme }
  status_hint('Alt-X', 'Bye') { :halt }
end

BBS.configure do |c|
  c.flow = BBS::Flow.define do
    call do |ctx, runner|
      FILES.run(session: runner.session, session_id: ctx[:session_id], context: ctx)
      nil
    end
  end
end

BBS.start
```

Outside an Application, call `send_files` with the session directly, for example from a flow:

```ruby
require 'bbs'

BBS.configure do |c|
  c.flow = BBS::Flow.define do
    call do |_ctx, runner|
      session = runner.session
      data    = (0..255).map(&:chr).join.b * 64   # 16 KB holding every byte value
      session.write("Sending BYTES.BIN by ZMODEM; Esc gives up.\r\n")
      result = BBS::Zmodem.send_files(session, [['BYTES.BIN', data, Time.at(0)], ['HELLO.TXT', "Hello\r\n"]])
      session.write("\r\nResult: #{result}\r\n")
      nil
    end
  end
end

BBS.start
```

Both examples were checked with lrzsz's `rz` behind a telnet bridge: the files arrived byte for byte, 0xFF included, and the result was `:ok`.

## Which terminals work

SyncTERM, NetRunner and Qodem start the download by themselves when they see the ZMODEM request. Behind a plain telnet, lrzsz's `rz` receives it. In a web terminal, zmodem.js does (our bbsweb saves the file as a browser download). The board first sends `rz` and a CR, which starts `rz` in a shell-style client, then the ZRQINIT header, repeated at most every 10 s until the receiver answers.

## How it sends

- Every control character (0x00 to 0x1F and 0x80 to 0x9F) goes out ZDLE-escaped, so the stream holds no bare CR and needs no telnet BINARY mode.
- Data goes with CRC-32 when the receiver offers it (CANFC32 in its ZRINIT), otherwise CRC-16, in 1 KB subpackets.
- When the receiver announces a buffer size, the board stops after that many bytes for a ZACK.
- A ZRPOS restarts the data from that position; a ZCRC request is answered with the file's CRC-32.
- After the last file: ZFIN, then "OO".
- The whole file is a Ruby String in memory: put a size limit in front of it. The WarpEngine BBS sends files up to `WARP_ENGINE_BBS_DOWNLOAD_MAX` (1 MB by default).

## Gotchas

- **0xFF is a doubled telnet IAC, not ZRUB1.** ZMODEM can escape 0xFF as ZDLE plus ZRUB1, but zmodem.js does not know that escape: its ZDLE decoding is just "minus 64". So 0xFF is sent raw and, on a telnet line, doubled as `IAC IAC`, which the telnet layer of every receiver turns back into one 0xFF. Without telnet in between (a socket straight into `rz`, a pipe) pass `telnet: false`.
- **rz repeats itself.** lrzsz's `rz` sends several ZRINITs at the start and repeats ZRPOS; the sender ignores a ZRINIT that answers a ZFILE and a ZRPOS for the position it just started from, otherwise it would send the data twice.
- **Not on a PETSCII line.** On a C64 line `session.write` goes through the PETSCII encoder and would mangle the binary stream; a C64 gets its file by XMODEM (below).
- **Inside `with_line`.** In an Application, always run a transfer inside `with_line`: otherwise the event loop's mouse reporting and screen diffing interfere with the binary stream and the screen is not repainted afterwards.

In production the WarpEngine BBS (`bbs/warp-engine-bbs`, `lib/ui/modern.rb` and `lib/ui/transfer.rb`) does exactly the `with_line` example: it fetches the file from the catalog, sends it with `send_files(session, [[title.file, data]])` (or by XMODEM), and turns the result into a dialog.

# BBS::Xmodem

Source: `lib/bbs/xmodem.rb`, since 0.26.0. The sending side of XMODEM, XMODEM-CRC and XMODEM-1K: the transfer the terminals of a C64 know (CCGMS, Novaterm, UltimateTerm), and SyncTERM, Qodem or `rx` too. XMODEM sends no file name, so tell the caller to name the file and start the download in the terminal:

```ruby
result = app.with_line do |session|
  session.write("  Start an XMODEM download of ARIADNE.PRG now; Esc gives up.\r\n")
  BBS::Xmodem.send_file(session, bytes, one_k: true)
end
# => :ok, :cancelled, :timeout (no receiver started) or :hangup
```

- The receiver picks the check: a `C` gets CRC-16, a NAK the checksum. With `one_k: true` the blocks are 1024 bytes when the receiver asked for a CRC, else always 128.
- The last block is padded with SUB (0x1a), so the saved file can be up to a block longer; a C64 program loads all the same.
- It works on a PETSCII line: the bytes go out with `session.write_raw`, 0xFF as a doubled telnet IAC, and the line is asked into telnet BINARY mode for the transfer and back out after it (`telnet: false` for a plain pipe).
- It waits `wait:` seconds (60) for the receiver; Esc or Ctrl-C (RUN/STOP) gives up meanwhile, and two CANs from the terminal cancel at any time. A block goes again on a NAK or 20 s of silence, at most 10 times.

# See also

- [rubbs](https://wiki.teletypegames.org/engines/rubbs) — the overview and the table of contents
- [AI characters](https://wiki.teletypegames.org/engines/rubbs/ai) — an AI character in a FakeOS `on_unknown` or in a window
- [Application](https://wiki.teletypegames.org/engines/rubbs/application) — the desktop that doors leave and come back to
- [Flow](https://wiki.teletypegames.org/engines/rubbs/flow) — flows as doors, the `call` and `os` steps
- [Commodore 64](https://wiki.teletypegames.org/engines/rubbs/commodore) — PETSCII lines, and why a gateway reports no window size on them
- [Telnet and the store](https://wiki.teletypegames.org/engines/rubbs/telnet-and-store) — the session: `readline`, `readkey`, `read_raw_byte`, `line`
- [Changelog](https://wiki.teletypegames.org/engines/rubbs/changelog) — Door (0.6.0), Flow doors and `colors base:` (0.7.0), ZMODEM (0.9.0), Gateway (0.17.0), XMODEM (0.26.0), the gateway report (0.31.0), `translate:` (0.34.0), `public_only:` (1.0.0)
- [Data and network](https://wiki.teletypegames.org/engines/rubbs/data-and-network) — `BBS::Address`, `BBS.log` and the rest of the shared blocks

'; 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: [Data and network](https://wiki.teletypegames.org/engines/rubbs/data-and-network.md)
- Next: [Drawing and theming](https://wiki.teletypegames.org/engines/rubbs/drawing.md)
