A simulated Discord that runs entirely in the browser, for embedding live bot demos
  • TypeScript 65.7%
  • JavaScript 33.4%
  • Shell 0.9%
Find a file
2026-09-04 20:57:34 -07:00
bench bench: diff a bot's workflow across V2 and V3, and fix the 3 divergences it found 2026-08-23 22:24:03 -07:00
demo V3: slash commands browser-verified; SimInteraction.customId fixes silent misrouting 2026-08-23 17:50:24 -07:00
src V3: slash commands browser-verified; SimInteraction.customId fixes silent misrouting 2026-08-23 17:50:24 -07:00
tests V3: port V2's remaining bot-facing surface, and prove parity with V2's own probes 2026-08-23 17:39:10 -07:00
.gitignore Mass Data Save 2026-09-04 20:57:34 -07:00
build.mjs build: emit dist modules before bundling, and refresh the stale ones 2026-08-23 22:17:39 -07:00
package.json V3: a simulated Discord that runs entirely in the browser 2026-08-23 15:37:31 -07:00
push.sh add push.sh for the one remaining manual release step 2026-08-23 22:19:57 -07:00
README.md bench: diff a bot's workflow across V2 and V3, and fix the 3 divergences it found 2026-08-23 22:24:03 -07:00
tsconfig.json V3: a simulated Discord that runs entirely in the browser 2026-08-23 15:37:31 -07:00

discordsim-browser

A simulated Discord that runs entirely in the browser. No server, no login, no developer area. Bake your bot in, drop it on a page, and a visitor can talk to it as an anonymous guest.

The intended use is a live demo of a bot you are developing: a site where someone can try it without joining a server or installing anything.

<script src="discordsim.min.js"></script>
<script>
DiscordSim.createDemo({
  mount: '#demo',
  guild: { name: 'My Server', channels: ['general'] },
  bot: {
    name: 'MyBot',
    setup: (client) => {
      client.on('messageCreate', async (message) => {
        if (message.author.bot) return
        if (message.content === '!ping') await message.reply('pong')
      })
    }
  }
})
</script>

That is the whole integration. ~12 KB gzipped, no runtime dependencies.

What it is, and what it is not

This is the third implementation in a line:

V1 V2 V3 (this)
language TypeScript Rust TypeScript
runs as server server in the page
interface HTTP + WebSocket HTTP + WebSocket direct calls
audience bot development bot development, stress testing demoing a bot

V3 deliberately drops what a browser demo cannot use: no HTTP layer, no WebSocket, no gateway handshake, no tokens, no developer portal, no multi-session, no load generation. A page has no sockets and a visitor has no business minting bot tokens.

What it keeps is the part that took the longest to get right: backend behaviour.

Fidelity

The payload shapes are not re-derived by hand. They are ported from V2 and then compared against responses captured from a running V2 server:

node bench/engine-diff.mjs      # V3 payloads vs captured V2 responses
node --test tests/*.test.js     # behaviour a shape diff cannot see

# The strongest check: V2's own probe files, unmodified, against this engine.
node bench/http-shim.mjs 8145 &          # maps V2's REST routes onto Sim
python3 ../SimulatedDiscord2/bench/pagination-check.py 8145   # exit 0
python3 ../SimulatedDiscord2/bench/semantics-check.py 8145    # exit 0
# Strongest of all: the same bot workflow driven against BOTH, outcomes diffed.
node bench/workflow-diff.mjs <v2-port> <v3-port>    # 22 steps, 0 divergences

workflow-diff.mjs answers the question the other checks cannot. engine-diff compares payload shape, and V2's probes test V2's semantics; neither shows whether a bot doing real work gets the same answers from both. It drives one scenario against each and compares only the values a bot would branch on, so ids and timestamps are excluded by construction rather than by a skip list.

It found three divergences on its first run that everything else had passed over: pinning 404'd because only one of Discord's two pin routes was wired, and thread creation returned 200 where V2 returns 201.

The shim exists for that one purpose. The same behavioural probes that pass against a running Rust V2 pass against the engine here, which is evidence of equivalence rather than an assertion of it.

The differ found 6 missing fields on its first run, which is exactly why it exists rather than being assumed unnecessary.

The differ compares shape, not content. Emptying an array still reports a match. That limit is inherited knowingly from V2, where it was discovered by fault injection: the tool built in response to a bug about an empty roles array could not detect that bug. So the five gap classes found in V2 are asserted directly in tests/semantics.test.js instead:

  1. Content, not just shapeMESSAGE_CREATE carries the author's real roles, so a bot gating on member.roles does not fail closed.
  2. Mentions are parsed server-sidementions[], mention_roles and mention_everyone are derived from the content, not left empty. V1 and V2 both shipped with this missing.
  3. allowed_mentions gates each kind independently — and the test compares against a non-empty baseline, because a blunt suppress-everything implementation passes a suppression-only check.
  4. Paging cursors move the windowbefore, after and around work, and a paging loop terminates having seen each message exactly once. In V2 these were parsed by nobody, so such a loop ran forever.
  5. Input is validated — an empty message, content over 2000 characters, and a send without permission are all rejected rather than silently accepted.

Every one of these was proven to fail before it was fixed. Injecting three faults (roles emptied, cursors ignored, mentions dropped) fails 5 of the 13 tests.

API

createDemo(options) is the one-call path. The pieces are exported too:

  • Sim — the simulator. State plus operations, no transport.
  • SimClient — a discord.js-shaped client: on(Events.MessageCreate, ...), message.reply(), message.member.roles.has(), channel.send().
  • mountUI(sim, element) — the chat interface on its own.

The client covers the surface a message-handling bot actually touches. It is not discord.js and does not try to be: discord.js opens a real WebSocket and needs a REST base URL, neither of which exists in a page with no server. Your bot's logic is unchanged; its import line is not.

Supported in the UI

Messages with author grouping and timestamps, embeds, buttons and slash commands raising real interactions (type: 3 components, type: 2 APPLICATION_COMMAND via /name args), reactions (clickable, toggleable), typing indicators that expire, multiple channels, pinning, mention highlighting, and the subset of Discord markdown bots actually use: **bold**, *italic*, __underline__, ~~strike~~, `code`, code blocks and ||spoilers||.

Engine-side, ported from V2: message history with working paging cursors, bulk delete dispatching one MESSAGE_DELETE_BULK, reaction add/remove/clear, pin/unpin with CHANNEL_PINS_UPDATE, threads (THREAD_CREATE, including message-anchored ones), invites, role grants, permission enforcement, and input validation matching Discord.

Development

npm run typecheck               # strict TypeScript, zero errors
npm run build                   # dist/discordsim.min.js + .esm.js
npm test                        # 13 behaviour tests
node bench/engine-diff.mjs      # payload parity against V2
python3 -m http.server 8099     # then open demo/index.html

Verified in a real browser, not only in node: the demo page was loaded, messages sent, buttons clicked and interactions handled. A node test cannot prove the DOM works.

Publishing

The remote is git@git.unityailab.com:Sponge/SimulatedDiscord3.git. Forgejo disables push-to-create, so the repository has to be created once by hand at https://git.unityailab.com/repo/create (owner Sponge, name SimulatedDiscord3), empty — no README or licence, since an initialised repo gets its own root commit and pushing this history would then be rejected as unrelated.

After that, ./push.sh pushes develop and main and verifies the result. It exits with instructions if the remote is still missing.