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 — 7 ways in
ANetBBS supports seven 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:5001-N | DOOR.SYS / DORINFO1.DEF |
Original DOS doors (TradeWars, LORD-DOS, Usurper…) |
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 |
mystic binary running compiled .mpx script |
Mystic-style | Mystic Pascal-script doors (.mpx precompiled) |
door_mystic_mps |
Auto-compile .mps → .mpx via mplc, then run |
Mystic-style | Mystic source doors (.mps) |
door_rlogin |
Outbound rlogin to remote BBS game-server | none — passes user identity | DoorParty, A-Net Online, Synchronet xtrn servers |
builtin_web |
Flask-routed browser game (xterm.js not needed) | none | Pure-web mini-games (Snake, 2048, Hangman…) |
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
- executable_path: <install>/anetbbs/games/sbbs_doors/run_sbbs_js.sh
- command_line_args: ~/doors/hello/hello.js
- drop_file_type: DOOR32.SYS
- drop_file_path: %P/door32.sys
That's it. The shim handles drop-file generation, terminal I/O,
ANSI/CP437 passthrough, user info injection.
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 Pascal 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 source door:
- game_type: door_mystic_mps
- executable_path: /usr/local/bin/mystic
- command_line_args: -d %f -e <path-to-script.mps>
- The wrapper auto-runs mplc script.mps on launch if .mpx is older
than .mps.
For a pre-compiled .mpx:
- game_type: door_mystic
- command_line_args: -d %f -e <path-to-script.mpx>
Mystic Python (.py) doors run 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 | 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:
- 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')
- Register it in
anetbbs/web_app.pynext to the otherregister_blueprint(...)calls:
from anetbbs.web.myfeature import myfeature_bp
app.register_blueprint(myfeature_bp)
- Add
anetbbs/templates/myfeature/index.htmlextendingbase.html:
{% extends "base.html" %}
{% block title %}My Feature{% endblock %}
{% block content %}
<h1>Hello, {{ current_user.username }}!</h1>
{% endblock %}
- (Optional) Add a nav-bar link by editing the appropriate dropdown
intemplates/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')
Then generate a migration:
cd <install>
venv/bin/flask db migrate -m "add my_things"
venv/bin/flask db upgrade
Migrations are stored in migrations/ and run automatically on every
service start.
Adding a sysop admin page
Admin pages live behind admin.dashboard and require current_user.is_admin.
Two patterns:
- Drop a route into
anetbbs/web/admin.pyfor simple pages. - Make a sub-blueprint for larger features (see
echomail_admin.py
orgallery_admin.py).
Either way, add the page to the admin nav at
templates/admin/_nav.html (auto-included by every admin page) 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:
- Open the theme builder, tweak colors, save with a name.
- Export the resulting
Themerow's values as SQL (or note them). - Add a fixture call in
anetbbs/seed_data.py(look for the existing
theme seeding block) 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 (default 8080) 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/core/mrc_terminal.py - The web client is
anetbbs/static/js/mrc.js - The bridge daemon's command surface is
mrc/bridge/main.py
For an IRC↔MRC mirror, see anetbbs/admin/mrc_irc_bridges.py — sysop
configures the channel pairs at /admin/mrc-irc-bridges/.
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.
Outbound notifications / webhooks
Three outbound channels are built in:
- Webhooks — generic POST to a URL, configured at /admin/webhooks/
- Telegram — set TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID in .env
- Email — sysop newsletter, SMTP via EMAIL_* env vars
To fire one from your feature code:
from anetbbs.features.webhooks import fire_webhook
fire_webhook('new_post', {'board': 'general', 'title': 'Hello'})
Webhooks are async (queued, retried with backoff) so this won't block
your request.
Testing
cd <install>
venv/bin/pytest tests/
Pytest currently has 3 passing tests / 2 skips. Add tests for new
features under tests/. Use tests/conftest.py's app/client
fixtures — they spin up an isolated SQLite DB and Flask test client.
Where to ask questions
- Wiki: [[Development]] — community-editable companion to this doc
- Email —
a-net-online@proton.me - GitHub issues — bug reports + feature requests on the
anetonline/anetbbsrepo
If you build something cool, send a PR and we'll bundle it.