- Rust 85.9%
- JavaScript 10.2%
- Python 3.4%
- Shell 0.5%
| bench | ||
| crates/discordsim | ||
| examples | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CLEANROOM.md | ||
| discordsim.yaml | ||
| node_modules | ||
| README.md | ||
| TRACEABILITY.md | ||
DiscordSim V2
A simulated Discord for bot development and stress testing, written from scratch in Rust with zero dependencies.
cargo build --release
./target/release/discordsim --port 6625
REST http://127.0.0.1:6625/api/v10
Gateway ws://127.0.0.1:6625
Control http://127.0.0.1:6625/api/control
Portal http://127.0.0.1:6625/portal
Three separate pieces
DiscordSim is a stand-in for Discord itself, not a bot framework and not a test harness. Three things stay independent, and each works on its own:
graph LR
SIM["<b>The simulator</b><br/>REST + Gateway + portal<br/><i>runs alone; no bot required</i>"]
BOT["<b>Your bot</b><br/>discord.js, discord.py, anything<br/><i>deployable to real Discord</i>"]
SCR["<b>Your script</b><br/>Python, Go, curl, anything<br/><i>optional</i>"]
BOT -- "token + API URL" --> SIM
SCR -- "plain HTTP" --> SIM
- The simulator is the product. It serves REST and a gateway whether or not anything connects.
- Your bot is an ordinary Discord bot. Pointing it here is a configuration change, not a code change.
- Scripting users is a third, optional concern, over plain HTTP from any language.
The one thing that changes
const client = new Client({
intents: [...],
// Omit this and the exact same bot talks to real Discord.
...(process.env.DISCORD_API_URL ? { rest: { api: process.env.DISCORD_API_URL } } : {})
})
await client.login(process.env.DISCORD_TOKEN)
Scripting from any language
The control API is plain HTTP and JSON. No SDK, no bot, no client library.
# Build a server
curl -X POST localhost:6625/api/control/guilds -H 'content-type: application/json' \
-d '{"name":"Test","channels":["general"],"users":["Alice","Bob"]}'
# Make a user talk
curl -X POST localhost:6625/api/control/messages -H 'content-type: application/json' \
-d '{"channel_id":"...","user_id":"Alice","content":"hello"}'
# Scale it up
curl -X POST localhost:6625/api/control/populate -H 'content-type: application/json' \
-d '{"users":10000,"channels":50}'
Worked examples: examples/script.py (stdlib only),
examples/script.go (stdlib only), and
examples/discordjs-bot.mjs.
| Endpoint | Purpose |
|---|---|
POST /api/control/guilds |
create a guild with channels and users |
POST /api/control/users |
add a user (announces GUILD_MEMBER_ADD) |
POST /api/control/messages |
post as any user, by name or ID |
POST /api/control/reactions |
react as any user |
POST /api/control/populate |
bulk-create members and channels |
POST /api/control/applications |
mint a bot token |
GET /api/control/status |
counts, connections, default token |
POST /api/control/reset |
wipe back to empty |
Developer portal
A clone of the Discord developer portal at /portal. Create an application,
copy the bot token, reset it (which revokes the old one immediately), toggle
privileged intents.
Tokens use Discord's real format,
base64url(app_id).base64url(timestamp).hmac, so the application ID decodes
out of the token exactly as in production, and client.user.id matches the
application you created.
Zero dependencies
Cargo.toml declares nothing, and tests assert it stays that way. No serde,
no tokio. JSON, HTTP/1.1, WebSocket, SHA-1, SHA-256, HMAC and base64 are all
implemented here from their specifications and checked against published test
vectors. See CLEANROOM.md.
The HTTP server is thread-per-connection over std::net. Unfashionable, but
right for a workload of a few hundred long-lived gateway sockets, and it
avoids the single largest dependency an async runtime would bring.
Verification
cargo test # 86 tests
node bench/audit.mjs 6625 # 86-check black-box parity audit
node bench/interactions.mjs 6625 # interactions end to end with a real bot
node bench/roles-check.mjs 6625 # MESSAGE_CREATE carries the author's real roles
python3 bench/pagination-check.py 6625 # paging cursors actually move the window
python3 bench/semantics-check.py 6625 # request fields are honoured, not just accepted
node bench/concurrency.mjs 6625 # throughput sweep
python3 bench/parity-gap.py # V1 route inventory vs V2, fails on a gap
bash bench/compare-with-v1.sh # starts both servers, runs both differs
The audit speaks only HTTP and WebSocket, so it validates behaviour rather than internals and can be pointed at either implementation. It exits non-zero on divergence.
The strongest evidence is simpler: a real, unmodified discord.js bot works. discord.js was written against Discord, not against this project.
Measured against V1 (TypeScript)
Both run as HTTP servers, hit by the same client on an i5-12400F. Median of three runs, each server measured alone (measuring both at once distorts the numbers, which is how I first got this wrong):
| concurrent callers | V2 (Rust) | V1 (Node) | ratio |
|---|---|---|---|
| 1 | 2,277 req/s | 1,241 req/s | 1.83x |
| 10 | 3,354 req/s | 2,911 req/s | 1.15x |
| 50 | 4,468 req/s | 4,207 req/s | 1.06x |
| 100 | 5,268 req/s | 4,329 req/s | 1.22x |
| 200 | 4,650 req/s | 4,595 req/s | 1.01x |
Read this honestly: V2's clear win is at low concurrency, where per-request
overhead dominates and it is ~1.8x faster. By 200 concurrent callers the two
converge, because the Node benchmark client becomes the bottleneck rather
than either server. Reads (GET /messages?limit=50) are the standout at
roughly 12x, since V1 re-serializes history per request.
So the speed argument for the rewrite is real but narrower than "faster at
everything": it is latency per request, plus reads, plus a 1 MB binary that
starts instantly and holds a fraction of the memory. Run
node bench/concurrency.mjs <port> against your own workload rather than
trusting this table.
Config file bootstrap
Start populated, using the same discordsim.yaml format V1 and fakediscord
use:
discordsim --config discordsim.yaml
users:
- username: MyBot
bot: true
token: token # a token you cannot change in your client
- username: Alice
guilds:
- name: Test Guild
channels:
- name: general
- name: voice-chat
type: 2
enforceIntents: false
permissionEnforcement: none
JSON is accepted too, since it is easier to generate from a script. A declared bot renames the default bot, and a declared token authenticates as it.
Load generation
curl -X POST localhost:6625/api/control/load -H 'content-type: application/json' \
-d '{"rate":500,"duration_ms":1000,"as_users":true}'
{"sent":498,"achieved_rate":498,"target_rate":500,"events_dispatched":498,
"bot_replies":0,"dropped_by_loop_protection":0}
Runs in-process, so the numbers measure the simulator rather than the
caller's HTTP overhead. "rate":0 runs unthrottled (~24,000 msg/s here).
as_users posts as real users, so loop protection and author-based logic
behave as they would with human traffic.
GET /api/control/summary reports what exists, what happened and what looks
wrong (for example "no gateway connections: nothing is receiving events",
which is a common and confusing test mistake).
Multiple sessions
One server can host any number of fully isolated simulators. Each session has its own state, gateway, applications and bot token, so two test suites can run in parallel without seeing each other's guilds.
curl -X POST localhost:6625/api/sessions -H 'content-type: application/json' \
-d '{"name":"suite-a","guilds":[{"name":"Alpha","channels":["general"]}]}'
A request finds its session by, in order:
- an explicit
/api/sessions/:id/...path prefix, - an
X-Session-Idheader, - the bot token, which is unique per session,
- otherwise the default session.
Point three is the useful one: a bot pointed at a session's token lands in that session with no client-side change at all. Single-session use is unaffected, since anything unrouted falls through to the default.
| Endpoint | Purpose |
|---|---|
GET /api/sessions |
list sessions |
POST /api/sessions |
create one, optionally seeded with guilds |
GET /api/sessions/:id |
inspect one |
DELETE /api/sessions/:id |
delete and disconnect (never the default) |
Verbose logging
Every REST request, gateway frame and dispatched event is recorded, so you can answer what did Discord actually see when my bot did that? after the fact.
DISCORDSIM_LOG=debug ./discordsim # off|error|warn|info|debug|trace
curl 'localhost:6625/api/control/logs?category=rest&limit=20'
A REST entry carries the method, path, status, duration and both bodies, parsed, so a script can assert on fields rather than string-matching:
[rest ] POST /api/v10/channels/123/messages -> 200 (0ms)
request: {"content": "hello world"}
msg id: 1540885333091155968
took: 22us
[event] MESSAGE_CREATE -> 1 connection(s)
author: SimBot
Gateway frames log with decoded opcode names (op 2 (IDENTIFY) intents=36355),
which makes an intent mistake obvious at a glance.
| Query | Meaning |
|---|---|
?category=rest|gateway|event|session |
filter by kind |
?session=<id> |
restrict to one session |
?since=<seq> |
only what is new, for polling |
?limit=<n> |
newest N, default 200 |
The ring holds 20,000 entries and then evicts, because an unbounded log is a
leak under exactly the load testing this tool is for. POST /api/control/logs/level changes the stderr threshold at runtime, and
capture_bodies: false drops payloads if they are too noisy.
V1 parity
V2 covers every route V1 had: 93 REST endpoints against V1's 92, 54 control
endpoints against V1's 41, and all 54 gateway events. bench/parity-gap.py
compares the two inventories and exits non-zero on any gap; it currently
reports zero.
Route coverage is only part of parity. Two differs compare the actual JSON:
bench/payload-diff.mjsruns 14 operations against both servers and compares REST response bodies. Found 51 missing fields; now zero.bench/event-diff.mjsconnects a raw WebSocket to each and compares 14 gateway frames. Found 48 missing fields; now zero.
The second matters most: the bug that prompted this work was in a gateway payload, which no REST comparison would ever have seen. A raw socket is used deliberately, because a client library fills gaps from its own cache and hides exactly the fields you are trying to check.
bash bench/compare-with-v1.sh starts both servers on free ports, runs both
differs and cleans up, so the comparison is reproducible on demand.
Request semantics are checked separately too: a differ compares the response
to a request and never asks whether a field in the request was honoured. A
server that accepts allowed_mentions and ignores it looks identical to one
that respects it. Mentions were in fact never parsed at all (V1 hardcodes
mentions: [] too), so every allowed_mentions check passed vacuously:
suppressing something never produced is indistinguishable from suppressing it
correctly.
Asserting suppression is not enough either. A blunt "drop everything when
allowed_mentions is present" implementation passes a suppression-only check,
and did. Each case therefore compares against a baseline that must be
non-empty, and gates each mention kind independently. Both weaknesses were
found by injecting them.
Paging is checked separately for the same reason again: before/after/
around change which items come back rather than which fields, so a shape
differ cannot see them being ignored. They were in fact ignored entirely, and a
bot's paging loop never terminated.
The differs also only requested each endpoint's default representation
until query-param cases were added, so a documented flag that V2 silently
ignored looked identical to one that worked. That gap hid with_counts and
the whole of GET /invites/:code.
Both differs compare shape, not content. Emptying an array still reports
MATCH, which is precisely the original bug (member present, roles: []).
Shape comparison catches absent and mistyped fields; content assertions live
in bench/roles-check.mjs and the
message_create_carries_the_authors_real_roles integration test, both of
which do fail on that bug.
V1 namespaced its control API under /api/control/sessions/:id/... because it
was multi-session. V2 runs one simulator per process, which is simpler and
matches how the tool is used, so those paths are accepted as aliases for
the flat ones. A script written against V1 works unchanged.
Beyond the routes, V2 also has V1's behavioural features:
| Feature | Endpoint |
|---|---|
| Action recording (assert on what a bot did) | GET /api/control/actions |
| Export and replay a session | GET /api/control/recording, POST /api/control/replay |
| Inject any gateway event | POST /api/control/dispatch |
| Loop protection, tunable | POST /api/control/loop-protection |
| Intent filtering toggle | POST /api/control/intents |
| Force reconnects / invalid session | POST /api/control/gateway/disconnect |
| Stop heartbeat acks (zombie connections) | POST /api/control/heartbeat/stop-acks |
| Full state snapshot | GET /api/control/state |
Not yet implemented
Honest list, rather than silence:
- Voice media (channels and voice state work; no UDP or Opus)
- OAuth2 grant endpoints (client ID and secret exist as data)
- Message attachments and binary emoji upload
Monetisation surfaces (entitlements, skus) are routed so a bot that
queries them does not crash, but the simulator has no store behind them.