TITLE=RuBBS ROUTE=/engines/rubbs LOCALE=en MODIFIED=1792691329 URL=https://wiki.teletypegames.org/engines/rubbs L=1 #66 q│ rubbs is an in-house Ruby gem for building Telnet BBS servers — from a q│ one-screen line prompt to a full Synchronet-style multi-window application, q│ all in Ruby. It powers our 1BBS Server. 1What you get 1════════════ • bTelnet & ANSI0 — IAC negotiation (SGA, ECHO, NAWS terminal size), full-screen ANSI with a double-buffered delta diff, xterm 1000/1006 SGR mouse for clickable menus and wheel-scroll lists. • bWidgets0 — Label, Button, TextInput, TextArea, Checkbox, ListBox, ScrollView, ProgressBar, Spinner, plus HBox / VBox / Panel containers and a CP437 ANSI-art viewer. • bPre-built windows0 — cInfo0, cForm0, cMasterDetail0, cStream0 (chat / log), opened imperatively or from a window DSL; per window, optionally maximizable (zoom gadget, F5, Alt-Enter, double click on the title). • bTheming0 — five presets (csynchronet0, cnortoncommander0, cdosnavigator0, cneumanntronics0, cmono0), each a frozen role-name → ANSI palette, overridable per key. • bMarkdown & ASCII art0 — Markdown-to-ANSI renderer, box-drawing banners, FIGlet (Artii) big banners, ERB screen templates. • bSimulated machines0 — cBBS::FakeOS0: a character-mode OS as configuration — a teletype boot script, a command table, and an con_unknown0 hook for everything else. • bDoors0 — cBBS::Door0: a program the caller steps into from the menu; the BBS hands the line over and takes it back when the program exits. • bFile downloads0 — cBBS::Zmodem0: the sending side of ZMODEM, so SyncTERM, NetRunner, Qodem, crz0 or zmodem.js in a web terminal saves a file from the board; cApplication#with_line0 hands it the raw line and repaints after. • bAI characters0 — cBBS::AI0: a persona from a YAML file talking through a small model on any OpenAI-compatible endpoint, streamed to the terminal word by word, with canned replies when the model is down. • bPersistence0 — cBBS::Store0: a thread-safe, upsert-by-csession_id0 CSV file with an in-memory cache. Invalid UTF-8 in the file is scrubbed on read, and every value is cleaned (invalid bytes and control characters dropped) before it is written, so raw telnet bytes in a player's input cannot break the store. 1Overview 1════════ The gem is named cbbs0. It boots a TCP listener, handles Telnet negotiation, hands each connection a cSession0, and gives you three layers to build on. Your code declares only screens, windows and services. p require 'bbs' p p BBS.configure do |c| p c.flow = BBS::Flow.define do p big_banner 'My BBS' p ask :name, prompt: 'Your name' p say 'Welcome!', style: :success p end p end p p BBS.start Connect with ctelnet localhost 23230. 1The three layers 1════════════════ t┌──────────────────┬────────────────────────────┬────────────────────────────┐ t│h Layer t│h What it is t│h When to use t│ t├──────────────────┼────────────────────────────┼────────────────────────────┤ t│0 cBBS::Flow0 t│0 Line-oriented declarative t│0 Login, registration, t│ t│0 t│0 dialogue (ask / say / menu t│0 simple wizards t│ t│0 t│0 / persist) t│0 t│ t├──────────────────┼────────────────────────────┼────────────────────────────┤ t│0 cBBS::TUI0 t│0 Single-screen page router t│0 Single-purpose full-screen t│ t│0 t│0 with frame-buffer delta t│0 UIs t│ t│0 t│0 rendering t│0 t│ t├──────────────────┼────────────────────────────┼────────────────────────────┤ t│0 cBBS::Application0 t│0 Widget framework: Menubar, t│0 Multi-window t│ t│0 t│0 Window, Button, TextInput, t│0 Synchronet-style BBSes t│ t│0 t│0 ListBox, … t│0 t│ t└──────────────────┴────────────────────────────┴────────────────────────────┘ Beside them sit three building blocks: cBBS::FakeOS0 (a simulated machine), cBBS::Door0 (hands the line from an Application to a program and back) and cBBS::AI0 (a character the caller can talk to). They mix in one session — typically cFlow0 for login, then cApplication0 for the main shell. Inside a flow you can hand off and return when the user exits. p TCPServer → Session → FlowRunner ─┬→ TUIRunner p ├→ Application#run ⇄ Door#run (FakeOS, …) p └→ FlowRunner (sub-flow) 1Doors 1═════ A door is declared in the Application DSL. The program is a cBBS::FakeOS definition, a cBBS::Flow0, or anything that responds to ccall(session, context)0: p APP = BBS::Application.define do p door :sermo, Sermo::OS, label: '&SERMO' p p menubar do p menu '&Doors' do p doors # one item per declared door p end p end p end p p # in the login flow: p call do |ctx, runner| p APP.run(session: runner.session, session_id: ctx[:session_id], context: ct» p x) p nil p end copen_door(:sermo)0 works from any handler too (a status-bar hint, a button). cAPP.run0 builds and runs the application; when the caller leaves it through a door, it clears the screen, switches mouse reporting off, runs the program, and builds the application again when the program returns. The context hash is the same throughout, so a door keeps its state for the rest of the call. Since 0.7.0: • A bflow door0 runs on a copy of the context, so its cask0 steps cannot overwrite the BBS's keys, and asks for ENTER before handing the line back, since a flow has no exit command. cBBS::Flow.define(screens_dir: …)0 keeps the flow's ERB screens next to it, so a flow shipped in a gem renders its cscreen0 steps inside any BBS. • A FakeOS with a coloured screen sets ccolors base: "\e[94;44m"0: every reset returns to it and ccls0 fills the screen with it, so the background survives the prompt, the echo and new lines. • cBBS::Widgets::AnsiArt.sauce(path)0 reads the SAUCE record of an art file (title, author, group, date). Since 0.8.0, for small models: cChat#reply(text, ctx, facts:, fallback:)0 adds facts to the system prompt for one request and replaces the canned reply when the model fails, and a persona's cdeflect:0 rules are answered by the code before the model is asked. The Teletype BBS's Chat of Ra and TTG Master are built on these. Since 0.9.0: • cBBS::Zmodem.send_files(session, [[name, bytes, mtime]])0 sends files by ZMODEM (c:ok0, c:cancelled0, c:timeout0, c:hangup0), inside capp.with_line { c|session| … }0. Control characters go ZDLE-escaped and 0xFF as a doubled telnet IAC, so it needs no BINARY mode and zmodem.js reads it too. The Teletype BBS's FTP Browser downloads with it. • cMasterDetail0 takes con_activate0 (Enter on an item) and creplace_items0, for a browser that changes folders in place. • An audit round: NAWS sizes are clamped (one 65535×65535 report used to exhaust memory), lines and telnet subnegotiations are capped, sockets get the idle limit as timeout and connections are capped (cmax_sessions0); creadline0 returns UTF-8, creadkey0 reads UTF-8 characters, CR LF is one Enter and a lone Esc no longer waits; a text field lets Esc through to its window, button hotkeys work; narrow terminals, the wheel over a window's border and a handler's exception no longer end the call. Since 0.10.0: cmaximizable: true0 (cw.maximizable true0 in the window DSL) gives a window the Turbo Vision zoom gadget in its top right corner: c[↑]0 fills the desktop between the menubar and the statusbar, c[↕]0 puts it back, and F5, Alt-Enter and a double click on the title bar do the same. It is off by default. cMasterDetail0, cStream0, cForm0 and a scrolling cInfo0 can do it; a custom window joins in with cresizable?0, cnormal_rect0 and cplace(x, y, w, h)0. Since 0.11.0: cAnsiArt.view(session, lines, title:)0 shows art on the whole screen, the way an art viewer of the old boards did, scrolled when taller than the screen; run it inside capp.with_line0. Since 0.12.0: a window that is not maximized can be moved, and a resizable one sized: drag its title bar or its bottom right corner, or press Ctrl-F5 / F6 and use the arrows (Shift+arrows size, Tab switches, Enter ends). cmovable: cfalse0 turns it off; the telnet reader returns xterm modifiers (c:shift_up0, c:ctrl_f50). Since 0.13.0: con_tick { … }0 runs in the application once a second while the caller is idle (a clock on cstatusbar.right_text0), and con_door { |door, ccontext| … }0 just before the line goes to a door (who is in which door, for a node list). cstatus_hint(key, label, hidden: true)0 binds a key without taking room on the statusbar. Fixes: Enter in cDialogs.input0's field presses OK; a door that raises brings the caller back to the board instead of ending the call; an indented line under a Markdown list item continues the item. The Teletype BBS's nodes, statusbar clock and logon sequence are built on these. Since 0.14.0: windows can be switched, Turbo Vision style. A click on a window behind the active one brings it to the front (and the click goes on to it), F6 and Shift-F6 step through them, Alt-1 … Alt-9 picks one by the number on its top frame. The active window has a double frame, the others a single one. A modal window (the cDialogs0) keeps the front until it closes; the cBBS::Windows types are no longer modal. F6 moves a window only while it is the only one, Ctrl-F5 always. capp.raise_window0, ccycle_windows0 and cswitchable_windows0 do it from code. 1AI characters 1═════════════ A persona file holds who the character is: p system: | p You are the oracle of this board. … p examples: # turns in the right voice p - user: hi p assistant: I am here. p options: # extra request fields, e.g. sampling p max_tokens: 96 p temperature: 0.4 p fallback: # canned replies, tried top to bottom p replies: p - match: '\b(hello|hi)\b' p reply: I am here. p default: 'You said: "{input}".' and four classes do the rest: p ORACLE = BBS::AI::Chat.new( p persona: BBS::AI::Persona.load('lib/oracle.yml'), p client: BBS::AI::Client.new(url: ENV.fetch('ORACLE_LLM_URL', '')), p history: 2 p ) p p on_unknown do |input, io| # in a FakeOS definition p out = BBS::AI::Typewriter.new(io, prefix: 'ORACLE: ', style: :dim) p ORACLE.reply(input, io.ctx) { |piece| out.write(piece) } p out.finish p end t┌────────────┬───────────────────────────────────────────────────────────────┐ t│h Class t│h t│ t├────────────┼───────────────────────────────────────────────────────────────┤ t│0 cPersona0 t│0 the YAML file: system prompt, example turns, request options, t│ t│0 t│0 canned replies t│ t├────────────┼───────────────────────────────────────────────────────────────┤ t│0 cClient0 t│0 streams from an OpenAI-compatible c/v1/chat/completions0 t│ t│0 t│0 (llama.cpp's cllama-server0, Ollama, vLLM, a hosted API); an t│ t│0 t│0 empty URL means no model t│ t├────────────┼───────────────────────────────────────────────────────────────┤ t│0 cChat0 t│0 sends persona + history + question, keeps the history in the t│ t│0 t│0 caller's context, strips markdown and emoji, falls back to t│ t│0 t│0 the canned reply t│ t├────────────┼───────────────────────────────────────────────────────────────┤ t│0 cTypewriter0 t│0 word-wraps the streamed reply to the caller's terminal under t│ t│0 t│0 a prefix t│ t└────────────┴───────────────────────────────────────────────────────────────┘ With a small model (we run Gemma 3 270M for 1SERMO), the examples matter more than the system prompt, and a long history makes it repeat its own slips: teach facts as example turns, and keep chistory0 short. 1Installation 1════════════ p gem 'bbs', git: 'https://git.teletypegames.org/engines/rubbs.git' 1Used by the Teletype Games BBS 1══════════════════════════════ Our public 1BBS Server (cteletypegames.org:23230) runs on rubbs. The gem provides the ANSI interface, menubar, stacked modal windows, Telnet server, mouse support, the doors and the AI chat; the cbbs-server0 repo holds only Teletype-specific configuration — service wiring, window definitions, formatters. Its SERMO door is a cBBS::FakeOS0 whose analog side is a cBBS::AI character; the Commodore 64 and Plus/4 doors are FakeOS machines with a coloured cbase0, and the Impostor door is a cBBS::Flow0 from a gem, with its own screens. 1Source layout 1═════════════ t┌─────────────────────────────────────┬──────────────────────────────────────┐ t│h File t│h Responsibility t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 clib/bbs.rb0 t│0 Public surface: cBBS.configure0, t│ t│0 t│0 cBBS.config0, cBBS.start0. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cbbs/server.rb0 t│0 TCP accept loop, one thread per t│ t│0 t│0 client. A client hang-up (reset, t│ t│0 t│0 broken pipe, timeout) is logged as a t│ t│0 t│0 dropped session, not as an error. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cbbs/session.rb0 t│0 Telnet negotiation, mouse t│ t│0 t│0 enable/disable, idle timer. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cbbs/telnet.rb0 t│0 Telnet protocol, key parsing, xterm t│ t│0 t│0 SGR mouse. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cbbs/flow.rb0, cbbs/flow_runner.rb0 t│0 Flow DSL + interpreter. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cbbs/tui.rb0, cbbs/tui_runner.rb0 t│0 Page router with frame-buffer t│ t│0 t│0 rendering. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cbbs/frame_buffer.rb0 t│0 Double-buffer, delta diff, ANSI t│ t│0 t│0 string utilities. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cbbs/application.rb0 t│0 Widget event loop, window stack, t│ t│0 t│0 builder DSL. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cbbs/widgets.rb0, cbbs/widget.rb0, t│0 Widget tree + built-in widgets. t│ t│0 cbbs/container.rb0 t│0 t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cbbs/window.rb0, cbbs/windows/*0 t│0 Window + pre-built window types. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cbbs/fake_os.rb0 t│0 Character-mode OS simulator: boot t│ t│0 t│0 script + command shell. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cbbs/door.rb0 t│0 Doors: hand the line to a program t│ t│0 t│0 and take it back. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cbbs/zmodem.rb0 t│0 ZMODEM send: file downloads to the t│ t│0 t│0 caller's terminal. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cbbs/ai.rb0 t│0 AI characters: persona file, t│ t│0 t│0 streaming client, chat with history t│ t│0 t│0 and fallback, typewriter output. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cbbs/theme.rb0 t│0 Style palette presets. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cbbs/markdown.rb0 t│0 Markdown → ANSI line renderer. t│ t├─────────────────────────────────────┼──────────────────────────────────────┤ t│0 cbbs/store.rb0 t│0 Thread-safe cached CSV upsert; t│ t│0 t│0 scrubs invalid UTF-8 on read and t│ t│0 t│0 write. t│ t└─────────────────────────────────────┴──────────────────────────────────────┘ 1Repository 1══════════ https://git.teletypegames.org/engines/rubbs — MIT licensed (cLICENSE0).