---
title: 'TTG Blog BBS'
url: 'https://wiki.teletypegames.org/services/ttg-blog-bbs'
markdown: 'https://wiki.teletypegames.org/services/ttg-blog-bbs.md'
lang: en
date: '2026-10-28'
description: 'The Teletype Games blog as a BBS: the latest posts and the categories, a post read a screenful at a time. The editors log in on the same line and write, edit and delete posts. Live at teletypegames.org:2328, and in the browser on bbsweb, and through TTG Gate BBS Overview A Ruby app on the rubbs g…'
taxonomy:
  tag:
    - howto
---

# TTG Blog BBS

> The Teletype Games blog as a BBS: the latest posts and the categories, a post read a screenful at a time. The editors log in on the same line and write, edit and delete posts.
> 
> **Live at `teletypegames.org:2328`**, and 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 [Feedia BBS](https://wiki.teletypegames.org/services/feedia-bbs). It has two faces on one port, and both are **line by line**, without windows:

- **Modern** — ANSI colours, up to 80 columns and the caller's window height (SyncTERM, NetRunner, PuTTY, bbsweb).
- **Commodore 64** — PETSCII on a 40×25 screen, for a C64 terminal program (CCGMS, StrikeTerm) behind a WiFi modem.

The menus, the lists, the fields and the full-screen editor are the same on both. A call starts with rubbs' `BBS::Terminal` question: RETURN takes the guess, `1` picks the modern face, `2` the C64.

# Reading

| Key | Action |
|---|---|
| `L` | Latest posts, newest first |
| `C` | Categories with their post counts, then that category's posts |
| `G` | Go to a post by its number (the web shows it beside the post) |
| `E` | Editor login |
| `Q` | Log off |

Lists are numbered: the number and RETURN opens a post, RETURN alone turns the page, `Q` or `←` goes back. A post shows its title, category, date and author, the date of its last change, the summary, then the body (Markdown), paged a screenful at a time.

On the C64 every screen fills the whole 40×25: the menus and lists are cursor menus (CRSR moves the bar, RETURN takes it, the keys work too) with a reversed **footer bar** that tells the screen's keys. The front page lists as many of the newest posts as fit. A post keeps its footer on every page: RETURN, `+` or CRSR down turns the page, `-` or CRSR up goes back one, `Q` or `←` leaves; for an editor `E` edit, `P` publish and `D` delete stand on the row over the footer, the questions come in the footer over the post, and after an edit or a publish the post is shown again.

# Writing

After logging in, the menu has `U` unpublished posts, `W` write a post, `P` change your password and `O` log out, and every post has `E` edit, `P` publish or unpublish and `D` delete under it.

A new post is **unpublished**: only the editors see it (marked so in the lists), the readers and the web do not. Saving a new post keeps it unpublished; `P` under the post publishes it. The first publishing stamps `published_at` and rings Discord; taking a post back and publishing it again does not ring it twice. A new post asks for the title (up to 80 characters), the category, a one-line summary (up to 280) and the body, then shows the draft: `T` title, `C` category, `S` summary, `B` body, `V` view, `W` save, `Q` quit.

## Drafts and autosave

What an editor writes is kept as a **draft** until it is saved, so a dropped line loses nothing: after every field, every 15 seconds in the editor while the text changes (`TTG_BLOG_BBS_AUTOSAVE_SECONDS`), and when the caller hangs up. The draft is made at the first change, so opening a post and leaving makes none. Nobody but the composer sees a draft: not the lists, the counts, `G` or the web.

- `W` saves: an edit is written into its post and the draft goes; a new post's draft becomes the post itself, unpublished.
- `Q` after a change asks: `K` keeps the draft for later, `D` discards it, RETURN (on a C64 `back`) goes back to the draft.
- `E` on a post with drafts, and `W` while there are drafts of new posts, start from a list: the post as it is now (or a new post), then the drafts with their time and editor, the last first. `D` discards one (on a modern terminal `D` and then its number). Every editor sees every draft; the menu shows the drafts of new posts beside `W`.
- When another editor saved the post after your edit began, `W` asks before it overwrites their save.

The name, the passwords and the title are typed in a **field** of rubbs (`BBS::Field`), the summary and the body in its **full-screen editor** (`BBS::Editor`). Both start from the current text, so a title or a post is edited, not typed again: the cursor keys move anywhere, typing inserts at the cursor, DEL deletes before it, and the editor wraps the lines as they are typed. A field ends with RETURN; RUN/STOP or `←` (Esc) leaves it.

| Key in the editor | Action |
|---|---|
| cursor keys | Move; up and down keep the column |
| HOME / END | Start / end of the screen row |
| PgUp / PgDn | A screenful up / down (modern terminal) |
| RUN/STOP or `←` (Esc), then `S` | Save |
| RUN/STOP or `←` (Esc), then `Q` | Quit, keep the text as it was |
| F1 (Ctrl-S) | Save at once (the UltimateTerm of the C64 Ultimate keeps F1 for its help) |

The summary is one paragraph: RETURN saves it. In the body RETURN starts a new line and an empty line a new paragraph (Markdown).

# On the web

The posts are the blog of [teletypegames.org/blog](https://teletypegames.org/blog), in both languages of the site. Every post shows its number, the terminal it was typed on (**ANSI** or **C64**), and a **C64 screen** view (`?view=c64`): the lines a Commodore 64 caller sees, in the C64 palette. A card beside it dials the board in the browser (bbsweb, modern or as a C64), with the telnet command and `G` plus the post's number. The RSS of the site (`/api/v1/rss/blog`) carries the posts too.

The board serves them read-only over HTTP, in the same process as the telnet line (`Web::Api`, port 8080). The container joins the `interstack` network, and the API of the site reads it there as `http://ttg-blog-bbs:8080` (`TTG_BLOG_BBS_URL`); the port is never published.

| Request | Answer |
|---|---|
| `GET /posts` | the published posts newest first, without their bodies |
| `GET /posts/SLUG` | one post: Markdown, HTML (kramdown, GFM) and the C64 screen |
| `GET /categories` | the categories with their post counts |
| `GET /health` | `{"ok":true}` |

A post gets its **slug** when it is saved: its date and its title in plain ASCII (`2026-10-28-hello-world`). It stays when the title changes, so links keep working. With `TTG_BLOG_BBS_DISCORD_WEBHOOK` in the stack's `.env`, a new post goes to Discord too, with its link.

# Categories and editors

The categories come from [`config/categories.yml`](https://git.teletypegames.org/bbs/ttg-blog-bbs/src/branch/master/config/categories.yml) (`id`, `name`, `about`): News, Devlog, Releases, Retro, Events. A change is a commit to that file; a push to master deploys it. A post names its category by `id`.

Editors and posts are in one SQLite file, `data/ttgblog.sqlite3` in the stack (`users`, `posts` with `slug`, `face`, `status` (`draft`, `unpublished`, `published`), `published_at`, `created_at`, `updated_at`, and for a draft `original_id` and `base_updated_at`). Passwords are PBKDF2-SHA256 digests; three wrong logins end the tries of a call. The editors are managed on the server:

```bash
docker exec -it ttg-blog-bbs bin/user add NAME       # asks the password twice
docker exec -it ttg-blog-bbs bin/user passwd NAME
docker exec -it ttg-blog-bbs bin/user remove NAME    # their posts stay, without an author
docker exec ttg-blog-bbs bin/user list
```

# Under the hood

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

'; 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: [Teletype Orbit](https://wiki.teletypegames.org/services/teletype-orbit.md)
- Next: [TTG Gate BBS](https://wiki.teletypegames.org/services/ttg-gate-bbs.md)
