Development

So you want to extend ANetBBS — write a door game, add a web feature,
ship a theme, hook into the message system. This page walks through
each major extension point with working examples.

Project layout

anetbbs/
├── anetbbs/              ← Python package (importable)
│   ├── web/              ← Flask blueprints (one file per feature area)
│   ├── core/             ← terminal session, menu engine
│   ├── echomail/         ← FidoNet binkp / QWK / TIC
│   ├── games/            ← door runner + Synchronet JS shim
│   │   ├── sbbs_stubs/   ← Synchronet API surface (pure Node)
│   │   ├── sbbs_doors/   ← bundled doors (LORD lives here)
│   │   └── web_games.py  ← in-browser mini-game registry
│   ├── mrc/              ← MRC chat client + namespace
│   ├── msp/              ← Inter-BBS IM (RFC 1312)
│   ├── rss/              ← RSS poller + reader
│   ├── wiki/             ← collaborative wiki engine
│   ├── features/         ← cross-cutting helpers
│   ├── templates/        ← Jinja2 HTML templates (mirrors blueprint names)
│   ├── static/           ← CSS, JS, images
│   ├── models.py         ← SQLAlchemy models (single source of truth)
│   └── web_app.py        ← create_app() factory + extension wiring
├── deploy/               ← systemd units, nginx template, wsgi wrapper
├── docs/                 ← these markdown files
├── vendor/               ← shipped third-party binaries (Mystic, BotWars…)
└── mrc/                  ← standalone MRC bridge daemon (aiohttp-based)

Everything in anetbbs/ is one Python package. Blueprints register
themselves via create_app() in web_app.py — that's where to wire
in a new feature.


Doors — 10 ways in

ANetBBS supports ten distinct door types out of the box. Each
maps to a Game model row (sysop creates via /admin/games/) with a
game_type discriminator.

game_type Run path Drop file Use when
door_dos DOSBox-staging bridge with TCP nullmodem on 127.0.0.1:5000-5100 DOOR.SYS / DORINFO1.DEF Original DOS doors (TradeWars, LORD-DOS, Usurper…)
door_dosemu dosemu2, virtual COM1 bridged to caller's PTY (no FOSSIL) DOOR.SYS DOS doors that need dosemu2 specifically instead of DOSBox
door_native Direct exec, stdio piped to caller configurable Linux-native doors (Java, Go, …)
door_synchronet Real jsexec if present, otherwise Node + our compat shim DOOR32.SYS Synchronet .js doors (LORD-JS, MajorMUD-JS, dorkit-based doors)
door_mystic Runs a Python door script via a mystic_bbs compat shim Mystic-style Mystic-API Python doors (.py)
door_mystic_mps Auto-compiles .mps.mpx via mplc, then runs the real mystic binary Mystic-style Mystic Pascal source/bytecode doors (.mps/.mpx)
door_rlogin Outbound rlogin to remote BBS game-server none — passes user identity DoorParty, A-Net Online, Synchronet xtrn servers
door_telnet Outbound telnet to remote game server, no pre-auth handshake none TWGS (Trade Wars Game Server) and similar telnet-only remotes
builtin_web Flask-routed browser game (xterm.js not needed) none Pure-web mini-games (Snake, 2048, Hangman…)
door_dos_browser EmulatorJS + dosbox_pure, runs entirely client-side in the browser none DOS shareware bundles playable with no telnet/SSH client at all

Door entrypoints (config fields)

Every door row has these knobs (sysop sees them at /admin/games/):

Field Tokens supported Example
executable_path %U %n %P (per-node dir) … /home/anetbbs/doors/lord/lord.sh
working_directory same /home/anetbbs/doors/lord/
command_line_args same + %f (drop file path) -l %f -node %n
drop_file_path %P %P/door.sys
drop_file_type DOOR.SYS, DORINFO1.DEF, DOOR32.SYS
max_nodes 4
min_time_required minutes 5
enabled bool
sort_order int for menu ordering

Full token table is in 14-door-games.md.

Per-node scratch dirs

The node manager allocates one of GAMES_MAX_NODES slots (default 10)
on each door launch and creates data/temp/nodeN/ for that session.
Use %P in any path field to point at it. Multiple concurrent players
each get isolated dirs — never coordinate by writing to
<door>/NODE1.DAT and hoping; use %P.

Writing a Synchronet JS door

The compat shim is at anetbbs/games/sbbs_stubs/. ~270 functions/objects
covering bbs.*, console.*, system.*, user.*, File, load(),
require(), dd_lightbar_menu, mouse_getkey. If you write a door
that uses only those APIs, it'll run on our shim without needing
Synchronet installed
.

Example minimal door (hello.js):

load('sbbsdefs.js');
console.clear(ANSI_NORMAL);
console.putmsg('\x01n\x01c\x01hHello from a JS door!\r\n');
console.putmsg('Your handle: \x01y' + user.alias + '\r\n');
console.putmsg('\r\n\x01wPress any key...\x01n');
console.getkey();

Drop this in ~/doors/hello/hello.js, create a Game row with:
- game_type: door_synchronet (label "Synchronet JS Game" in the
admin dropdown)
- Synchronet Script Path (.js): ~/doors/hello/hello.js
- Synchronet Exec Dir: leave blank unless the script expects to
run from a specific working directory
- drop_file_type: door32.sys
- drop_file_path: %Pdoor32.sys

There's no separate wrapper shell script to point at — executable_path
and command_line_args aren't used for this door type at all. The
launcher auto-detects a real jsexec if present, otherwise falls back
to the Node.js compat shim, using the two Synchronet-specific fields
above to find your script. Drop-file generation, terminal I/O,
ANSI/CP437 passthrough, and user info injection are all handled the
same generic way every other door type gets them.

When the shim isn't enough: drop a real Synchronet install in
/usr/local/sbbs/ and set SBBS_PATH in .env. The launcher detects
jsexec and prefers it over the shim.

See 15-synchronet-compat.md for the
complete shim API surface and known gaps.

Writing a Mystic-style door

ANetBBS ships the full Mystic 1.12 A48 runtime at
<install>/vendor/mystic/ — sysop doesn't need to install Mystic
separately. The runtime includes mplc (compiler), mystic (script
interpreter), mide (script editor), and the full data/menus/themes.

For a .mps Pascal source door (or a pre-compiled .mpx):
- game_type: door_mystic_mps
- mystic_script_path: path to the .mps/.mpx file
- Runs the real mystic binary via MYSTIC_BBS_PATH (default
/usr/local/bin/mystic), auto-compiling .mps.mpx with mplc
on launch if the bytecode is missing or older than the source. If
mplc isn't installed, it falls back to assuming the script is
already compiled.

For a Mystic-API Python door:
- game_type: door_mystic
- mystic_script_path: path to the .py script
- Runs via a fake mystic_bbs module that maps Mystic's BBS API onto
our compat helpers. Drop a .py script that does
from mystic_bbs import * and you're a few function calls from a
working door.

rlogin out-dial doors

Connect to a remote BBS's game server without re-authenticating:
- game_type: door_rlogin
- executable_path field repurposed: host:port
- command_line_args repurposed: rlogin server-key (some servers want
a shared secret)

Important quirk: Synchronet's rlogin uses INVERTED field order
vs RFC 1282 — sends password\0username\0 (not client\0server\0).
Our connection class handles this automatically; if you're writing
against a non-Synchronet target, see anetbbs/games/door_rlogin.py.

Built-in web games

Add a new web-only mini-game in anetbbs/games/web_games.py:

WEB_GAMES.append({
    'name': 'My Cool Game',
    'slug': 'mycoolgame',
    'description': 'It is very cool.',
    'category': 'puzzle',          # puzzle | action | strategy | rpg | cards | casino | other
    'icon': 'bi-controller',        # Bootstrap icon class
    'web_game_module': 'mycoolgame',
    'sort_order': 110,
})

Then create anetbbs/templates/games/web_games/mycoolgame.html with
your game's HTML/JS. The game lobby auto-picks it up; the play route
is /games/play/<slug>.


Web extensions

Adding a new blueprint

A "blueprint" is Flask's term for a self-contained feature. To add one:

  1. Create anetbbs/web/myfeature.py:
from flask import Blueprint, render_template
from flask_login import login_required, current_user

myfeature_bp = Blueprint('myfeature', __name__, url_prefix='/myfeature')

@myfeature_bp.route('/')
@login_required
def index():
    return render_template('myfeature/index.html')
  1. Register it in anetbbs/web_app.py next to the other register_blueprint(...) calls:
from anetbbs.web.myfeature import myfeature_bp
app.register_blueprint(myfeature_bp)
  1. Add anetbbs/templates/myfeature/index.html extending base.html:
{% extends "base.html" %}
{% block title %}My Feature{% endblock %}
{% block content %}
  <h1>Hello, {{ current_user.username }}!</h1>
{% endblock %}
  1. (Optional) Add a nav-bar link by editing the appropriate dropdown
    in templates/base.html.

That's the whole flow. ~50 lines of code for a working feature.

Database / models

anetbbs/models.py is the single source of truth for SQLAlchemy
models. Add a new table by appending a class:

class MyThing(db.Model):
    __tablename__ = 'my_things'
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
    note = db.Column(db.Text)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)

    user = db.relationship('User', backref='my_things')

There's no migrations/ directory and no flask db migrate/upgrade
step in this project, despite Flask-Migrate being wired up in
web_app.py — schema changes are picked up automatically instead: an
auto-sweep in _lightweight_migrate() (web_app.py) walks
db.metadata.sorted_tables on every app start and ALTER TABLE ADD COLUMNs anything the model declares that the live DB is missing (new
tables come from the regular db.create_all() call right before it).
For a brand new table like MyThing above, that's all you need —
just restart the app (or re-run update.sh on a real install) and the
table appears. Only reach for a hand-written _ensure_column() call in
_lightweight_migrate() if you need a specific default/NOT NULL
constraint the generic permissive sweep doesn't give you (see that
function's own comments for examples).

Adding a sysop admin page

Admin pages live behind admin.dashboard and require current_user.is_admin.
Two patterns:

  1. Drop a route into anetbbs/web/admin.py for simple pages.
  2. Make a sub-blueprint for larger features (see echomail_admin.py
    or gallery_admin.py).

Either way, add the page to the ADMIN_HUB_SECTIONS dict at the top
of anetbbs/web/admin.py (each category page's tool list is built
from this, not a separate nav template) and make sure the route has
both @login_required and your is_admin gate.

Real-time / WebSocket features

Flask-SocketIO is set up in web_app.py. Pattern:

from anetbbs.web_app import socketio
from flask_socketio import emit

@socketio.on('connect', namespace='/mything')
def on_connect():
    if not current_user.is_authenticated:
        return False  # reject
    emit('hello', {'user': current_user.username})

@socketio.on('do_thing', namespace='/mything')
def on_do_thing(data):
    ...
    emit('thing_done', {'result': 'ok'}, to=request.sid)

Client side: const sock = io('/mything') then bind handlers.

The Web Terminal (anetbbs/web/web_terminal.py) and MRC client
(anetbbs/web/mrc.py) are good reference implementations — both
spawn background eventlet greenthreads, pump bytes between a backend
socket and the browser, and handle reconnect.


Themes

Themes are rows in the themes table with CSS variable values. The
sysop edits them visually at /admin/theme-builder/.

To ship a new built-in theme:

  1. Open the theme builder, tweak colors, save with a name.
  2. Export the resulting Theme row's values as SQL (or note them).
  3. Add it to the default-theme seed block in _create_default_data()
    in anetbbs/web_app.py (there's no separate seed_data.py — it's
    all in web_app.py) so fresh installs get your theme.

CSS variables driving the theme include --theme-bg-dark,
--theme-primary, --theme-accent, etc. — see templates/base.html
for the full list.


MRC integration

The MRC bridge runs as a separate aiohttp daemon (mrc/bridge/)
on its own port (WEB_PORT + 1 — 5001 on the standard WEB_PORT=5000,
derived and written to mrc/bridge/config.json by install.sh; see
docs/PORTS.md) and is reverse-proxied at /mrcws and /mrcweb/. The
web BBS talks to it via socket.io at /mrc/.

If you want to add a slash-command, capability, or output format:

  • The terminal client lives in anetbbs/features/mrc_chat.py
  • The web client is anetbbs/static/mrc/client.js
  • The bridge daemon's command surface is mrc/bridge/main.py

For an IRC↔MRC mirror, see anetbbs/features/mrc_irc_bridge.py — this
one is CLI/systemd-only, no web admin UI exists for it yet. A sysop
creates MrcIrcBridge rows directly and runs one instance per bridge
via python -m anetbbs.features.mrc_irc_bridge --bridge-id N, wrapped
by the anetbbs-mrc-irc-bridge@.service systemd template.


Echomail / FidoNet plugins

The pollers live in anetbbs/echomail/. Each network type has its
own module (binkp.py, qwk.py, tic.py). If you're adding a new
network protocol, the cleanest pattern is to write a new module
exposing poll_outbound(network) and poll_inbound(network)
functions, then add a row to the echomail_networks table with the
matching protocol discriminator.

Echomail message bodies go through anetbbs/echomail/kludges.py for
FTS-0001-compliant CR-terminated kludge handling. Don't reinvent —
the spec is hostile to bytes.

On the hub side, anetbbs/web/hub_admin.py (the /admin/echomail/hub/
blueprint), the HatchQueue/TIC hatch pipeline, and the
ScheduledEvent handler registry (anetbbs/events/handlers.py) are
the extension points if you're adding hub-side infrastructure —
distributing a new file type over TIC, adding another handler to the
scheduler, etc. — rather than a new peer-side protocol. See
20-federation.md and
21-scheduled-events.md for how those fit
together.


Outbound notifications / webhooks

Two outbound channels are built in:
- Webhooks — generic POST to a URL, configured at /admin/webhooks/
- Email — outgoing SMTP is a DB-backed SmtpConfig row, edited at
Admin → SMTP / Email — not .env variables

To fire one from your feature code:

from anetbbs.features.webhooks import fire
fire('post', {'board': 'general', 'title': 'Hello'})

fire() spins up a plain thread and makes one POST attempt with an
8-second timeout — it won't block your request, but there's no queue
and no retry on failure. See 23-webhooks.md for
the full list of the 8 real event types and the exact payload shape
each one sends.


Testing

cd <install>
venv/bin/pip install -r requirements-dev.txt   # one-time, pulls in pytest
venv/bin/pytest tests/
# or:
venv/bin/python -m unittest discover -s tests -p "test_*.py"

requirements-dev.txt is needed either way: tests/test_mrc_integration.py
uses real @pytest.fixture decorators, so pytest has to be installed
for that one file to even be importableunittest discover will
fail to load it (not just skip it) without pytest present, same as
running it under pytest directly.

The suite is a mix of unittest.TestCase-based files (most of them)
and a single pytest-fixture-based file — there's no shared
conftest.py; each test file that needs a Flask app/test client
builds its own (see any test_*.py file's setUpClass/fixture
functions for the pattern most commonly used — create a fresh
create_app('testing'), point TestingConfig.SQLALCHEMY_DATABASE_URI
at a scratch file or tempfile.TemporaryDirectory(), and restore any
shared state in tearDownClass). Add tests for new features under
tests/.


Where to ask questions

  • Wiki: [[Development]] — community-editable companion to this doc
  • Emaila-net-online@proton.me
  • GitHub issues — bug reports + feature requests on the
    anetonline/anetbbs repo
  • IRCirc.a-net.online, port 6667 (plain) or 6697 (SSL),
    room #ANetBBS

If you build something cool, send a PR and we'll bundle it.