A scriptable, backend-accurate simulated Discord in dependency-free Rust, for bot development and stress testing
  • Rust 85.9%
  • JavaScript 10.2%
  • Python 3.4%
  • Shell 0.5%
Find a file
2026-09-04 17:38:45 -07:00
bench bench: a suppression check that cannot distinguish suppression from selection 2026-08-23 15:04:19 -07:00
crates/discordsim message semantics: mentions were never parsed, and bad input was accepted 2026-08-23 14:58:39 -07:00
examples DiscordSim V2: working Rust server, verified with a real discord.js bot 2026-08-22 16:16:10 -07:00
.gitignore Mass Data Save 2026-09-04 17:38:45 -07:00
Cargo.lock DiscordSim V2: std-only foundation in Rust 2026-08-22 15:32:53 -07:00
Cargo.toml DiscordSim V2: std-only foundation in Rust 2026-08-22 15:32:53 -07:00
CLEANROOM.md DiscordSim V2: std-only foundation in Rust 2026-08-22 15:32:53 -07:00
discordsim.yaml DiscordSim V2: capability parity beyond routes 2026-08-22 18:00:07 -07:00
node_modules DiscordSim V2: black-box parity audit, 86/86 against the Rust server 2026-08-22 16:21:28 -07:00
README.md bench: a suppression check that cannot distinguish suppression from selection 2026-08-23 15:04:19 -07:00
TRACEABILITY.md docs: correct an overstated speed claim and two count errors 2026-08-22 16:39:35 -07:00

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:

  1. an explicit /api/sessions/:id/... path prefix,
  2. an X-Session-Id header,
  3. the bot token, which is unique per session,
  4. 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.mjs runs 14 operations against both servers and compares REST response bodies. Found 51 missing fields; now zero.
  • bench/event-diff.mjs connects 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.