---
title: 'Scriptorium BBS'
url: 'https://wiki.teletypegames.org/services/scriptorium-bbs'
markdown: 'https://wiki.teletypegames.org/services/scriptorium-bbs.md'
lang: en
date: '2026-11-01'
description: 'The library of the BBS network: thousands of public-domain books from Project Gutenberg, cut into pageable chapters for a modern terminal or a Commodore 64. Live at teletypegames.org:2330, in the browser on bbsweb, and through TTG Gate BBS Overview A Ruby app on the rubbs gem, like the Teletype B…'
taxonomy:
  tag:
    - howto
---

# Scriptorium BBS

> The library of the BBS network: thousands of public-domain books from Project Gutenberg, cut into pageable chapters for a modern terminal or a Commodore 64.
> 
> **Live at `teletypegames.org:2330`**, in the browser on [bbsweb](https://bbsweb.teletypegames.org), and through [TTG Gate BBS](https://wiki.teletypegames.org/services/ttg-gate-bbs)

# Overview

A Ruby app on the [**rubbs**](https://git.teletypegames.org/engines/rubbs) gem, like the [Teletype BBS](https://wiki.teletypegames.org/services/teletype-bbs), [Alexandria BBS](https://wiki.teletypegames.org/services/alexandria-bbs) and [Commodex BBS](https://wiki.teletypegames.org/services/commodex-bbs).

It has two faces on one port:

- **Modern**: ANSI colours, windows, menus and the mouse (SyncTERM, NetRunner, PuTTY, bbsweb). Shelves, books and chapters in two-pane windows, and a reader window that wraps the chapter to its width.
- **Commodore 64**: PETSCII on a 40×25 screen, for a C64 terminal program (CCGMS, Novaterm, UltimateTerm) behind a WiFi modem. A one-key menu, numbered lists, and 38-column pages that wait for RETURN.

A call starts with rubbs' `BBS::Terminal` question: RETURN takes the guess, `1` picks the modern face, `2` the C64.

# The books

- **The catalog** is Project Gutenberg's own, `pg_catalog.csv.gz` (about 78,000 texts and 400 bookshelves). The board imports it into SQLite with a full-text index on titles and authors and reads it again every week; the old catalog serves until the new one is ready. Every search word matches as a word prefix, accents do not matter (`bronte` finds Brontë), and `lang:fr` keeps one language.
- **The Classics shelf** is the board's own pick of 36 well-known books (`config/classics.yml`); it works even before the first catalog import.
- **A book's text** is fetched from gutenberg.org when someone first opens it, cleaned (the Project Gutenberg header and footer off, prose lines joined into paragraphs) and cut into chapters by its headings (CHAPTER, BOOK, PART, ACT, STAVE, LETTER, CANTO, roman numerals…), skipping the table of contents. A very long chapter is split into parts; a book with no headings into sections of about 16 KB. The cleaned text and its chapter index stay in a disk cache (300 MB, least recently read out first), and only the chapter on the screen is read into memory.
- **Most read** counts the books opened on this board, once per call.

Every book page credits it: *eBook #N from Project Gutenberg, gutenberg.org/ebooks/N — public domain in the USA.*

# Reading

**On a modern terminal:**

| Key | Action |
|---|---|
| F2 | The Classics shelf |
| F3 | Search by title or author |
| F4 | Project Gutenberg's bookshelves |
| F7 | Most read on this board |
| Enter or Alt-R | Open a book / read a chapter |
| PgUp, PgDn, Home, End | Page in the reader |
| ← → or P, N | Previous / next chapter |
| F5 or Alt-Enter | Full screen and back |
| Alt-X | Log off |

**On a Commodore 64** the menu has `1` the Classics, `2` the bookshelves, `3` search, `4` most read, `A` about, `Q` log off. A book has an info page and a numbered chapter list; in the reader RETURN gives the next page (and at the end the next chapter), `B` goes back a page, `N`/`P` change chapter, `Q` goes back to the list. Curly quotes, dashes and accented letters are turned into what a C64 can show.

# Configuration and data

| Variable | Default |  |
|---|---|---|
| `SCRIPTORIUM_BBS_MIRROR` | `https://www.gutenberg.org` | where the texts come from; a mirror works too |
| `SCRIPTORIUM_BBS_CATALOG_URL` | `https://www.gutenberg.org/cache/epub/feeds/pg_catalog.csv.gz` | the catalog |
| `SCRIPTORIUM_BBS_CATALOG_TTL` | `7` | days before the catalog is read again |
| `SCRIPTORIUM_BBS_CLASSICS` | `config/classics.yml` | the Classics shelf |
| `SCRIPTORIUM_BBS_DB` | `data/catalog.sqlite3` | the imported catalog |
| `SCRIPTORIUM_BBS_CATALOG_FILE` | `data/pg_catalog.csv.gz` | the downloaded catalog |
| `SCRIPTORIUM_BBS_TEXTS` | `data/texts` | the text cache |
| `SCRIPTORIUM_BBS_CACHE_MB` | `300` | the text cache's size |
| `SCRIPTORIUM_BBS_BOOK_MAX` | `8388608` | the largest book fetched, in bytes |
| `SCRIPTORIUM_BBS_READS` | `data/reads.jsonl` | the reads, one JSON line each |

The board has no secrets. After a change to the chapter rules, clear `data/texts` so the cached books are cut again.

# Under the hood

A push to master deploys it from Woodpecker (RuboCop, compose check, `ci-deploy scriptorium-bbs`); the container is `scriptorium-bbs`, in `/srv/stacks/scriptorium-bbs`, and `data/` stays between deploys. The entrypoint updates rubbs on start.

The code follows the house layering: `lib/domain` (the book, shelf, chapter and text models; the Gutenberg, catalog, classics, read and text-cache repositories; `ChapterService`, `LibraryService`, `ReadingService`; the composition root `ScriptoriumBBS`) knows nothing of telnet, and `lib/ui` holds `UI::Modern` with its `ReaderWindow` and `UI::Petscii`. A book is fetched and cut one at a time, so a burst of new books cannot blow the memory limit.

'; 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: [Services](https://wiki.teletypegames.org/services.md)
- Previous: [Public site](https://wiki.teletypegames.org/services/public-site.md)
- Next: [Discord Thread Scheduler](https://wiki.teletypegames.org/services/statusbot.md)
