---
title: 'AI characters'
url: 'https://wiki.teletypegames.org/engines/rubbs/ai'
markdown: 'https://wiki.teletypegames.org/engines/rubbs/ai.md'
lang: en
date: '2026-10-27'
description: 'BBS::AI in rubbs (source lib/bbs/ai.rb, since 0.5.0) makes characters a caller can talk to: who the character is lives in a YAML file (the persona), a small model behind any OpenAI-compatible chat endpoint does the talking, a short memory per caller keeps the conversation, and canned replies stand…'
taxonomy:
  tag:
    - reference
---

# AI characters

> `BBS::AI` in [rubbs](https://wiki.teletypegames.org/engines/rubbs) (source `lib/bbs/ai.rb`, since 0.5.0) makes characters a caller can talk to: who the character is lives in a YAML file (the persona), a small model behind any OpenAI-compatible chat endpoint does the talking, a short memory per caller keeps the conversation, and canned replies stand in when the model is missing or fails. This page covers every class and key, how to wire a character into a door or a window, and the small-model approach our boards use in production. All examples ran against rubbs 0.17.0, offline or against a local stub, never a real model. The API is the same in 1.0.0; since 0.33.0 the client also has a deadline, a reply cap and a limit on requests running at once (see *Client*).

# The pieces

| Class | What it is |
|---|---|
| `BBS::AI::Persona` | who the model plays, as data: system prompt, example turns, request options, deflect rules, canned replies |
| `BBS::AI::Client` | a streaming client for `/v1/chat/completions` (llama.cpp's `llama-server`, Ollama, vLLM, a hosted API) |
| `BBS::AI::Chat` | a persona talking through a client, with a short history per caller in the caller's context |
| `BBS::AI::Typewriter` | types streamed text onto a FakeOS console, word-wrapped under a prefix |

How `Chat#reply` decides what to say:

```mermaid
flowchart TD
  Q[the caller's text] --> D{a deflect rule matches?}
  D -- yes --> DR[the rule's reply, no model call]
  D -- no --> E{client enabled? URL not empty}
  E -- no --> F
  E -- yes --> M[stream from the model, piece by piece]
  M --> R{any text came?}
  R -- yes --> OK[the model's reply, kept in the history]
  R -- no, or the request failed --> F{fallback: given?}
  F -- yes --> FB[the given fallback]
  F -- no --> P[the persona's canned reply: first matching rule, else default]
```

A character works with no model at all: with an empty URL the chat never calls one and answers from the canned replies, so a board can ship the character before it has a model to run.

# Persona

## The YAML file

```yaml
# The Oracle of the board, read once when the BBS starts.
system: |
  You are the Oracle, an old terminal in the back room of a 1985 bulletin board.
  You speak to one caller at a time, in one or two short sentences.
  You know the board and the night; you know nothing after 1985.

examples:
  - user: hi
    assistant: I am here, caller. The line is quiet tonight.
  - user: who are you?
    assistant: The Oracle, the oldest machine on this board. I hum, and sometimes I know things.

options:
  max_tokens: 80
  temperature: 0.4
  stop: ["\n\n", "```"]

deflect:
  - match: '\b(code|python|javascript|sql)\b'
    reply: I do not write code, caller. I only read the stars on the screen.
  - match: '\b(ai|chatbot|language model)\b'
    reply: Those words mean nothing to me. I am the Oracle.

fallback:
  replies:
    - match: '\b(hello|hi|hey)\b'
      reply: I am here, caller.
    - match: '\bweather\b'
      reply: '"{input}" — the sky is dark, as always at this hour.'
  default: 'The Oracle hums and says nothing about "{input}".'
```

| Key | Type | Default | Meaning |
|---|---|---|---|
| `system` | String | `''` | the system prompt; leading and trailing whitespace is stripped, line breaks inside are kept. Empty: no system message |
| `examples` | list of `user` / `assistant` pairs | `[]` | turns in the right voice, sent after the system prompt and before the history, on every request |
| `options` | Hash | `{}` | extra fields of the request body: `max_tokens`, `temperature`, `top_p`, `stop`, … anything the endpoint takes |
| `deflect` | list of `match` / `reply` | `[]` | answered **without the model**, tried first: questions a small model answers out of character however it is asked |
| `fallback.replies` | list of `match` / `reply` | `[]` | canned replies when the model is missing or fails, tried top to bottom |
| `fallback.default` | String | `''` | the canned reply when no rule matches |

- `match` is a Ruby regular expression, always case-insensitive. Write it in single quotes in YAML, so `\b` stays a backslash.
- `{input}` in any `reply` or in `default` is replaced with the caller's text.
- Other top-level keys are ignored, so a persona file can carry notes for people in comments or in keys of its own.

## Persona.load and Persona.new

```ruby
BBS::AI::Persona.load(path)
BBS::AI::Persona.new(system: '', examples: [], options: {}, deflect: [], fallback: {})
```

`load` reads the YAML (`YAML.safe_load`, no aliases) and passes the keys above to `new`. Building a persona in Ruby works too, but the nested keys must be **Strings**, as YAML gives them; a Symbol key raises `KeyError`:

```ruby
require 'bbs'

GUARD = BBS::AI::Persona.new(
  system:   'You are the gate guard of the board. One short sentence.',
  examples: [{ 'user' => 'hi', 'assistant' => 'Halt. Name and business.' }],
  options:  { max_tokens: 40 },
  deflect:  [{ 'match' => '\bpassword\b', 'reply' => 'I never say the password.' }],
  fallback: { 'replies' => [{ 'match' => '\bopen\b', 'reply' => 'The gate stays shut.' }],
              'default' => 'Move along.' }
)

GUARD.deflect('what is the PASSWORD?')  # => "I never say the password."
GUARD.fallback('open up')               # => "The gate stays shut."
GUARD.fallback('hello')                 # => "Move along."
```

| Method | Returns |
|---|---|
| `deflect(text)` | the reply of the first matching deflect rule, or `nil` |
| `fallback(text)` | the reply of the first matching fallback rule, else `default` |
| `messages(facts = nil)` | the messages sent before the history: a system message (the system prompt, plus `facts` after a blank line when given), then the examples as `user` / `assistant` messages |
| `options` | the `options` Hash with Symbol keys |
| `system`, `examples` | the stripped system prompt; the examples as message Hashes |

# Client

```ruby
BBS::AI::Client.new(url:, model: nil, api_key: nil, open_timeout: 2, read_timeout: 30, deadline: 120, max_bytes: 65_536)
```

| Option | Default | Meaning |
|---|---|---|
| `url:` | required | the endpoint's base URL, e.g. `http://llm:8080`. Empty: the client is disabled |
| `model:` | `nil` | the `model` field of the request; left out when `nil` |
| `api_key:` | `nil` | sent as `Authorization: Bearer KEY` when given |
| `open_timeout:` | 2 | seconds to open the connection |
| `read_timeout:` | 30 | seconds to wait for each read; a model that is still loading must be faster than this |
| `deadline:` | 120 | seconds for the whole reply; past it the stream stops with a `BBS::AI::Error` |
| `max_bytes:` | 65 536 | the longest reply kept; a longer one stops with a `BBS::AI::Error` |

| Method | What it does |
|---|---|
| `enabled?` | `false` for an empty URL |
| `stream(messages, **options) { \\|piece\\| }` | POSTs `{ messages:, stream: true, **options }` (plus `model` when set) to `/v1/chat/completions`, reads the server-sent events, yields each `choices[0].delta.content` as it arrives; raises on a non-2xx status ("HTTP 400") or a network error, and `BBS::AI::Busy` when the model is taken (see below) |
| `url` | the URL it was given |

The client reads no environment variables itself; the board decides where the settings come from. Our convention is a prefix per character family: SERMO reads `SERMO_LLM_URL`, `SERMO_LLM_MODEL` and `SERMO_LLM_API_KEY`; the Teletype BBS's own characters read `BBS_LLM_URL`, `BBS_LLM_MODEL` and `BBS_LLM_API_KEY`, and fall back to the SERMO values when one is unset (`lib/characters/llm.rb` in `bbs/teletype-bbs`):

```ruby
require 'bbs'

module Characters
  def self.env(name)
    own = ENV["BBS_LLM_#{name}"].to_s
    own.empty? ? ENV.fetch("SERMO_LLM_#{name}", nil) : own
  end

  def self.client
    BBS::AI::Client.new(url: env('URL').to_s, model: env('MODEL'), api_key: env('API_KEY'))
  end
end
```

**One model, many callers.** At most `BBS.config.ai_concurrency` requests (2 by default) run at once in the whole process, whichever client sends them; the next waits up to 5 s for its turn and then gets `BBS::AI::Busy` ("The model is busy, try again in a moment."). A `Chat` answers it from the persona's fallback, like a model that is away, so a crowd at the oracle never stacks up threads behind a small model. Set it to what the model server can serve in parallel:

```ruby
BBS.configure { |c| c.ai_concurrency = 4 }
```

Things to know about endpoints:

- The path is always `/v1/chat/completions` from the root of the host: `URI.join` drops any path in `url:`. An endpoint that lives under a path prefix (`https://example.com/openai/v1/...`) needs a proxy or a URL without the prefix.
- A llama.cpp server in router mode (several models behind one port) has no default model: a request without `model` gets HTTP 400. Always pass `model:` there.
- Our shared model service is such a router, llama.cpp on an OpenAI-compatible endpoint at `http://llm:8080` inside our Docker network, serving `qwen3.5-0.8b` among others; it wants a key per caller. Keep keys in the environment, never in the persona file or the repository:

```yaml
services:
  bbs:
    environment:
      - SERMO_LLM_URL=http://llm:8080
      - SERMO_LLM_MODEL=qwen3.5-0.8b
      - SERMO_LLM_API_KEY=${SERMO_LLM_API_KEY:-}
```

# Chat

```ruby
BBS::AI::Chat.new(persona:, client:, history: 6, key: :ai_history, filter: PLAIN_TEXT, options: {})
```

| Option | Default | Meaning |
|---|---|---|
| `persona:` | required | a `Persona` |
| `client:` | required | a `Client`, or any object with `enabled?` and `stream(messages, **options)` |
| `history:` | 6 | how many past messages (user and assistant both count) to send along; 2 is the last exchange, 0 none |
| `key:` | `:ai_history` | where in the caller's context hash the history is kept; give each character its own key |
| `filter:` | `Chat::PLAIN_TEXT` | a regexp of characters removed from every streamed piece; `nil` keeps everything |
| `options:` | `{}` | request options merged over the persona's (`max_tokens` from an environment variable, for example) |

`Chat::PLAIN_TEXT` removes what a plain-text terminal cannot show: the markdown marks `*`, `#` and backtick, emoji, and the joiners that build emoji.

## reply

```ruby
chat.reply(text, ctx = {}, facts: nil, fallback: nil) { |piece| }
```

Returns the reply as a String. With a block, the reply is also yielded as it arrives, piece by piece; a deflect or canned reply is yielded whole, once. In order:

1. `text` is forced to UTF-8, invalid bytes dropped, whitespace stripped.
2. A matching `deflect` rule answers at once; the model is not asked and nothing goes into the history.
3. If the client is enabled, the request is the persona's messages (with `facts`), then the history from `ctx[key]`, then the caller's text. Each streamed piece goes through `filter`; empty pieces are skipped.
4. A reply with any text is returned and remembered: the question and the answer are appended to `ctx[key]`, which is trimmed to the last `history` messages. A reply cut short by an error half-way is kept as it is.
5. When the client is disabled, the request fails before any text came or the model is busy (logged as `my-bbs: ai: model unavailable (…)`), or the model said nothing: `fallback:` if given, else `persona.fallback(text)`.

| Keyword | Meaning |
|---|---|
| `facts:` | a String added to the system prompt for this one request: what the character should know right now, looked up for the question. Not kept in the history |
| `fallback:` | the reply when the model is missing or fails, instead of the persona's canned one, e.g. the looked-up facts as they are |

The history lives in the caller's context, so it lasts for the call (through doors and back) and is gone at hang-up. A `ctx` that is not a Hash means no history.

Offline, with the Oracle persona above saved as `oracle.yml`:

```ruby
require 'bbs'

PERSONA = BBS::AI::Persona.load(File.join(__dir__, 'oracle.yml'))
CLIENT  = BBS::AI::Client.new(url: ENV.fetch('ORACLE_LLM_URL', ''))   # empty: no model
ORACLE  = BBS::AI::Chat.new(persona: PERSONA, client: CLIENT)

ctx = {}
puts CLIENT.enabled?                                   # false
puts ORACLE.reply('write me some python', ctx)         # I do not write code, caller. …
puts ORACLE.reply('hello there', ctx)                  # I am here, caller.
puts ORACLE.reply('how is the weather?', ctx)          # "how is the weather?" — the sky is dark, …
puts ORACLE.reply('what is the meaning of life', ctx)  # The Oracle hums and says nothing about "…".
puts ORACLE.reply('when is the meetup?', ctx, fallback: 'The meetup is on Friday.')
ORACLE.reply('hi', ctx) { |piece| print "[#{piece}]" } # [I am here, caller.]
```

# Typewriter

```ruby
BBS::AI::Typewriter.new(io, prefix: '', style: :text, width: nil)
```

| Option | Default | Meaning |
|---|---|---|
| `io` | required | a FakeOS console (the `io` of a command or `on_unknown`) |
| `prefix:` | `''` | written once at the start, e.g. `'ACP: '`; the following lines are indented by its length |
| `style:` | `:text` | the console colour role for the prefix and the text |
| `width:` | `io.cols - 1` | the line width, prefix included; the text gets at least 20 columns |

| Method | What it does |
|---|---|
| `write(piece)` | adds streamed text; whole words are written as they complete, wrapped to the width, and a line break in the text starts a new line |
| `finish(newlines: 2)` | writes the last word and `newlines` line breaks |

Words are held back until they are whole, and whitespace is written only before the next word, so a reply never ends on a dangling blank line. On a 30-column terminal:

```text
ORACLE: The line is quiet
        tonight, caller.
```

The typewriter needs the FakeOS console's `print(text, style:, newline:)`, `newline` and `cols`. In an Application window, wrap the finished reply yourself (see below).

# Wiring a character in

## In a FakeOS door

The natural home of a character is a FakeOS `on_unknown`: literal commands go to the machine, everything else to the model. A complete board:

```ruby
require 'bbs'

ORACLE = BBS::AI::Chat.new(
  persona: BBS::AI::Persona.load(File.join(__dir__, 'oracle.yml')),
  client:  BBS::AI::Client.new(url:     ENV.fetch('ORACLE_LLM_URL', ''),
                               model:   ENV['ORACLE_LLM_MODEL'],
                               api_key: ENV['ORACLE_LLM_API_KEY']),
  key:     :oracle_history,
  history: 2
)

ORACLE_OS = BBS::FakeOS.define do
  name   'ORACLE'
  prompt '? '
  colors text: "\e[0;36m", dim: "\e[2;36m", prompt: "\e[1;36m"

  boot do
    cls
    banner 'THE ORACLE'
    puts 'Ask anything. EXIT goes back to the board.', style: :dim
    newline
  end

  command 'exit', 'bye' do |_args, io|
    io.halt
  end

  on_unknown do |input, io|
    io.newline
    out = BBS::AI::Typewriter.new(io, prefix: 'ORACLE: ', style: :dim)
    ORACLE.reply(input, io.ctx) { |piece| out.write(piece) }
    out.finish
  end
end

BBS.configure do |c|
  c.flow = BBS::Flow.define do
    os ORACLE_OS
  end
end

BBS.start
```

Without `ORACLE_LLM_URL` it answers from the canned replies; with `ORACLE_LLM_URL=http://llm:8080 ORACLE_LLM_MODEL=qwen3.5-0.8b ORACLE_LLM_API_KEY=YOUR_KEY` it streams from the model. As a door in an Application: `door :oracle, ORACLE_OS, label: '&Oracle'` (see [Doors](https://wiki.teletypegames.org/engines/rubbs/doors)).

## In an Application window

A window must not wait for the model: the event loop would freeze for seconds. Ask in a thread, put the finished lines on a queue, and let a `Stream` window pick them up on its idle tick (about once a second):

```ruby
require 'bbs'

ORACLE = BBS::AI::Chat.new(
  persona: BBS::AI::Persona.load(File.join(__dir__, 'oracle.yml')),
  client:  BBS::AI::Client.new(url: ENV.fetch('ORACLE_LLM_URL', ''), model: ENV['ORACLE_LLM_MODEL']),
  key:     :oracle_history
)

BOARD = BBS::Application.define do
  helpers do
    def ask_oracle(queue, text)
      width = cols - 12
      queue << "you: #{text}"
      Thread.new do
        reply = ORACLE.reply(text, context)
        BBS::FrameBuffer.wrap_ansi("ORACLE: #{reply}", width).each { |line| queue << line }
        queue << ''
      rescue StandardError => e
        warn "Oracle: #{e.class}: #{e.message}"
      end
    end
  end

  window :oracle, type: :stream do |w|
    w.title       ' The Oracle '
    w.placeholder 'Ask the Oracle and press Enter…'
    w.initial_lines do |ctx|
      ctx[:oracle_queue] = Queue.new
      ['ORACLE: I am here, caller.', '']
    end
    w.on_submit { |text, app, ctx| app.ask_oracle(ctx[:oracle_queue], text.strip) }
    w.on_poll do |_app, ctx|
      lines = []
      lines << ctx[:oracle_queue].pop until ctx[:oracle_queue].empty?
      lines
    end
  end

  status_hint('F1', 'Oracle') { open_window(:oracle) }
  status_hint('Alt-X', 'Bye') { :halt }
end

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

BBS.start
```

The window spec's `ctx` is a fresh Hash for each opening of the window, so the queue lives there; the chat history goes into the application's `context`, which lasts for the call. This is how the TTG Master window of the Teletype BBS works (`ask_master` in `lib/ui/modern.rb`), with a "one question at a time" guard on top.

## Testing without a model

`Chat` only needs `enabled?` and `stream` from its client, so a test can pass a stub object. To exercise the real `Client` too, a few lines of Ruby make a local stand-in endpoint that streams a fixed reply:

```ruby
# stub_llm.rb — for tests only
require 'socket'
require 'json'

server = TCPServer.new('127.0.0.1', 2480)
loop do
  client = server.accept
  headers = {}
  while (line = client.gets) && line != "\r\n"
    name, value = line.split(':', 2)
    headers[name.downcase] = value.to_s.strip if value
  end
  body = JSON.parse(client.read(headers['content-length'].to_i))
  warn "model=#{body['model'].inspect} messages=#{body['messages'].size}"
  client.write "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n"
  ['The **line** ', 'is quiet ', 'tonight, ', 'caller.'].each do |piece|
    client.write "data: #{JSON.generate(choices: [{ delta: { content: piece } }])}\n\n"
  end
  client.write "data: [DONE]\n\n"
  client.close
end
```

```ruby
require 'bbs'

PERSONA = BBS::AI::Persona.load(File.join(__dir__, 'oracle.yml'))
CLIENT  = BBS::AI::Client.new(url: 'http://127.0.0.1:2480', model: 'qwen3.5-0.8b', api_key: 'YOUR_KEY')
ORACLE  = BBS::AI::Chat.new(persona: PERSONA, client: CLIENT, history: 2, key: :oracle_history)

ctx = {}
puts ORACLE.reply('is anyone there?', ctx) { |piece| print "[#{piece}]" }
# [The line ][is quiet ][tonight, ][caller. ]The line is quiet tonight, caller.
puts ORACLE.reply('and now?', ctx)
p ctx[:oracle_history]
# [{:role=>"user", :content=>"and now?"}, {:role=>"assistant", :content=>"The line is quiet tonight, caller."}]
```

The `**` marks are gone (the `filter`), and with `history: 2` only the last exchange is kept. The first request carried six messages (the system prompt, four example messages, the question), the second eight (plus the remembered exchange).

# Small models in production

Our boards run their characters on a model of under a billion parameters (Qwen3.5 0.8B on the shared llama.cpp service). A model that small words things well enough, but it does not keep facts and slips out of character. What works, measured over many runs:

**The code holds the facts, the model only phrases them.**

- **Chat of Ra** (`bbs/teletype-bbs`, `lib/doors/ra.rb` and `lib/characters/ra.rb`): the riddles, their answers and hints are in `ra_riddles.yml`; the code asks a riddle and checks the answer, so a right answer is always right. A question about the game itself (matched by `GAME_WORDS`) gets a line built from the catalog, not from the model, since the model mislays the address. Only free talk goes to the model.
- **The TTG Master** (`lib/characters/master.rb`): the sysop who answers questions about the board. For each question the code looks up at most two facts in `master_knowledge.yml` (keywords, phrase keys weigh double, generic words like "board" half) and live facts (latest releases, coming events, the catalog). A question with no facts never reaches the model: the persona's fallback answers it. Otherwise the facts go into the **user message** ("Facts: … Question: …"), the persona's examples have the same shape, and `history: 0`. After the reply, the code checks it: it must keep at least one anchor of the facts (a menu path, a title, a date) and name no menu path that is not in them. If it fails, or the model is away, the caller gets the first fact word for word (`fallback: facts.first.text`). The `facts:` keyword exists for this kind of use; in the Master the facts sit in the user turn because the 0.8B model ignored them in the system prompt.
- **SERMO** (`bbs/sermo-bbs`, `lib/sermo/acp.yml` and `lib/sermo/oracle.rb`): a character with no facts to look up, only a world. `history: 2`, because a longer memory makes the model copy its own slips.

**Teach with examples, not instructions.** A small model copies its examples far better than it follows the system prompt. A new fact or a new turn of voice is best taught as one more example turn. An example turn that keeps leaking into replies is better removed.

**Deflect what it always gets wrong.** Code, poems, "are you a bot?": however it is asked, a small model answers these out of character. A `deflect` rule answers them without the model:

```yaml
deflect:
  - match: '\b(code|script|program(ming)?|python|javascript|java|html|sql|function)\b'
    reply: The scribes wrote, and they are dust now. I do not write; I shine.
  - match: '\b(chat ?bot|bot|robot|ai|artificial|machine|computer)\b'
    reply: Those are words from a land I have never shone on. I am Ra, and you are the one who prayed.
```

**Keep replies short and stop them early.** `max_tokens` around 80 to 110, a low `temperature` (0.3 to 0.4), and `stop: ["\n\n", "```"]`, so a slip never grows into a second paragraph or a program. On Qwen3.5 0.8B, repetition penalties (`presence_penalty`, `frequency_penalty`, `repeat_penalty`) made it talk past `max_tokens`; SERMO's persona dropped them.

**Measure, do not judge single replies.** `bin/persona-eval` in `bbs/teletype-bbs` asks a character the questions in `lib/characters/eval/NAME.yml` several times and counts the slips: out-of-character words, markdown or emoji, replies too long for a line or two, and, for the Master, replies missing the expected phrases. Tune a persona by these numbers:

```text
BBS_LLM_URL=http://localhost:8080 bin/persona-eval ra 3
docker exec teletype-bbs bin/persona-eval master 3
```

**Keep the model loaded.** The client's `read_timeout` is 30 s; a model that loads on its first request may not answer in time. Our service loads the SERMO model at startup.

# See also

- [rubbs](https://wiki.teletypegames.org/engines/rubbs) — the overview and the table of contents
- [Doors](https://wiki.teletypegames.org/engines/rubbs/doors) — FakeOS, where most characters live, and doors
- [Windows](https://wiki.teletypegames.org/engines/rubbs/windows) — the `Stream` window for a chat inside the desktop
- [Application](https://wiki.teletypegames.org/engines/rubbs/application) — helpers, context, the idle tick
- [Changelog](https://wiki.teletypegames.org/engines/rubbs/changelog) — BBS::AI (0.5.0), `facts:`, `fallback:` and `deflect` (0.8.0)

'; 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)
- Next: [Application](https://wiki.teletypegames.org/engines/rubbs/application.md)
