Running Your Own Bar
The World is one Node process: a room server, a small web app, and a Discord bot (WorldBot, by default). This walks through a fresh install, from the Discord Developer Portal to a public HTTPS address. Budget an hour.
- What you need
- 1. Discord application
- 2. Invite the bot
- 3. Run it locally
- 4. Configure a room
- Tests
- 5. Put it on a server
- 6. First boot checklist
- Beyond one room
- Enabling Harry
What you need
- A Discord server you administer, or none: see Without Discord.
- Node 22 or newer, and git.
- For the public version: a small Linux host you can SSH into, a domain name pointing at it, and a reverse proxy that speaks WebSockets. The deploy files assume Apache with certbot on Ubuntu, but anything that proxies HTTP and
/wsto a local port works.
1. Create the Discord application
- Go to discord.com/developers/applications and click New Application. Name it whatever your bot should be called.
- General Information: copy the Application ID. That is your
DISCORD_CLIENT_ID. - OAuth2: copy (or reset) the Client Secret into
DISCORD_CLIENT_SECRET. Under Redirects addhttps://bar.example.com/auth/callbackfor production andhttp://localhost:5173/auth/callbackfor local development. Discord only accepts callbacks that match this list exactly. - Bot: click Reset Token and copy the token into
DISCORD_TOKEN. It is shown once. Then turn on both Server Members Intent and Message Content Intent under Privileged Gateway Intents; the bot needs them to see who is in the channel and what they say. Consider unticking Public Bot so only you can invite it.
2. Invite the bot to your server
Open this URL with your own Application ID in place of CLIENT_ID, pick your server, and approve:
https://discord.com/oauth2/authorize?client_id=CLIENT_ID&scope=bot%20applications.commands&permissions=2251800350944272
The permissions number grants View Channels, Send Messages, Manage Webhooks, Manage Messages, Read Message History, Attach Files, Embed Links, Use External Emojis, Manage Channels, and Pin Messages. Manage Webhooks is how room talk gets posted under people's own names. Pin Messages is what keeps the room picture pinned; Discord split it out of Manage Messages, so a bot invited with an older permissions number posts the picture fine and then fails to pin it. Manage Channels is only used when you ask the settings screen to make a channel for a new room, and can be left off if you always make the channel yourself first. The bot never kicks, bans, or changes anyone's roles on Discord: its own gags and bans are inside the bar, so it needs none of those permissions.
If the bar's channel is private, add the bot's role to that channel explicitly. Channel overrides can hide a channel even from a role with server-wide View Channels.
3. Run it locally
git clone <this repo> palacebot && cd palacebot
npm install
cp .env.example .env # fill in the three Discord values from step 1
cp rooms/example.json.sample rooms/myroom.json
npm run dev
The server listens on port 3000 and the Vite dev server on 5173. Open http://localhost:5173/myroom. With Discord credentials in .env you can log in for real; without them the server runs in a Discord-less mode and ?dev=Name on the URL lets you in as a test user. npm run fake-users -- 8 fills the room with wandering bots for layout testing.
4. Configure a room
Each file in rooms/ is one room, tied to one channel on one Discord server. Edit your copy of the sample:
id: the room's URL slug, e.g.myroommakes/myroom. Must match the file name.access:{ "provider": "discord", "community": "…", "window": "…" }. For the two ids, turn on Developer Mode in Discord (Settings → Advanced), then right-click the server and the text channel and choose Copy ID. Together they are the room's door: only members of that server who can see that channel get in. The window is optional: with"window": ""the room has no channel in Discord (no pinned picture, no/palaceof its own, no mirror) and any member of the server may come in; the roles, the grid and the floor still apply. A file from before September 2026 saysguildIdandchannelIdinstead; it still loads, and the server rewrites it this way at boot.name: what the room is called in the page title, the pinned message, and the slash commands.painting: who may paint on the room outside a game:"off","hosts"(default), or"anyone". Harry can widen it during a session.mirrorToDiscord:trueto post room talk into the channel under each speaker's name. Defaultfalse; the pinned picture shows the balloons instead, and the channel stays quiet.background: a 512×384 image underassets/. The bundled one is Harry's Bar from the 1995 Palace.capacity: how many people can be in the room at once. Twenty is already generous; the original defaulted to sixteen.spawnSpots,sleeperSpots,floor: where newcomers appear, where people chatting from Discord are seated, and the clickable rect. All in room pixels.
Tests
npm test runs the unit tests (about 280 of them, in-process, a few seconds): the room server with fake connections and a manual clock, the moderation queue on a fake model, the games, the parser, the permissions. npm run deeptest runs the deep tests: it builds, starts the real room server and the real harryd as child processes against a stub OpenRouter (the test scripts what the model says), drives them over real WebSockets the way a browser does, and opens the built client in a headless Chromium to work the lists, the disguise tag and the picture button. About half a minute. It needs Playwright's browser once: npx playwright install chromium; without it the browser part skips itself. deploy/deploy.sh runs the typecheck and the unit tests before it syncs anything, so nothing goes out red (SKIP_TESTS=1 bypasses it).
5. Put it on a server
The deploy/ folder has a systemd unit, two Apache virtual hosts, and a sync script. On the host, one time:
# Node 22
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt-get install -y nodejs build-essential
# Apache modules and the HTTP vhost (edit the ServerName first)
sudo a2enmod proxy proxy_http proxy_wstunnel rewrite headers ssl
sudo mkdir -p /var/log/apache2/palacebot
sudo cp deploy/apache-palacebot.conf /etc/apache2/sites-available/023-palacebot.conf
sudo a2ensite 023-palacebot && sudo systemctl reload apache2
sudo certbot --apache -d bar.example.com
# replace the generated SSL vhost with deploy/apache-palacebot-le-ssl.conf (proxy + WebSocket lines)
sudo cp deploy/palacebot.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable palacebot
On your own machine, copy deploy/local.env.example to deploy/local.env with your SSH host and target directory, then run deploy/deploy.sh. It rsyncs the repo (without .env, data, or build output), installs, builds, and restarts the service. A restart drops everyone in the bar for a few seconds (their pages reconnect by themselves), so say so first: deploy/announce.sh "This is Control. The World restarts in thirty seconds. Hit refresh if it gets weird." puts that line up as a caption in every room. It runs over SSH and posts to the room server on the host's localhost with the agent token from the host's .env, so the token never leaves the host; the server takes one every ten minutes and logs each. When the restart is over and the server answers its health check, deploy/deploy.sh puts up a sign in every room, And... we're back!, for ten seconds and takes it down again (NO_BACK_SIGN=1 skips it). Moving the bar to a new name without signing anyone out: point the new name at the bar as usual, keep the old name's virtual host proxying to the bar too (not redirecting), set PUBLIC_URL to the new name and OLD_HOSTS to the old one. A page opened at the old name goes to the same page at the new one with a one-time pass that starts a session there, so nobody logs in again; tabs already open stay connected at the old name until they reload. Add the new /auth/callback address to the Discord app's OAuth2 redirects before switching. What a browser keeps for itself (the transcript, the pane layout, sounds, sealing keys) belongs to the address and starts fresh at the new one. Put the production .env on the host by hand with PUBLIC_URL=https://bar.example.com, a long random SESSION_SECRET, PORT=3311, and NODE_ENV=production. Your rooms/*.json files are synced along with everything else.
6. First boot checklist
Watch sudo journalctl -u palacebot -f on the first start. In order you should see the bot log in, then per room: sleepers seeded from recent history, "mirroring #channel in Server", and finally "ready". On first contact with a channel the bot creates a webhook named after itself, posts the room snapshot, pins it, and registers /palace (peek, who, say) and /harry.
A quiet channel, so the picture stays in view
If people chat in the room's channel, the pinned picture ends up a long scroll away. The fix is a permissions change on that channel, no new channel needed: in the channel's Permissions, for @everyone turn off Send Messages, Send Messages in Threads, and Create Threads, and leave View Channel, Read Message History, and Use Application Commands on. Then add an override for the bot's role with Send Messages, Embed Links, Attach Files, and Manage Messages on (a role's allow beats the everyone-deny; Manage Messages is what lets it pin). With nobody posting, the live picture is also the last message in the channel, so it's always in view. Sleepers stop appearing (they only ever came from channel messages) and /palace say becomes the way in from Discord, from any channel. To clear out old chatter once, npx tsx scripts/clear-channel.ts <channelId> counts what it would delete (everything but pinned messages) and --yes does it; Discord leaves no stubs.
- Missing Permissions on webhooks: the bot's role lacks Manage Webhooks on that channel. Fix the role or re-run the invite URL.
- could not pin snapshot message: harmless; the snapshot exists but isn't pinned. Grant Manage Messages or pin it by hand.
- Used disallowed intents: the two privileged intents aren't switched on in the Developer Portal.
- Login returns "Bad OAuth state" or a Discord error: the redirect URL in the Portal doesn't exactly match
PUBLIC_URLplus/auth/callback.
curl localhost:3311/healthz reports the Discord connection and how many people are in each room.
Without Discord
Discord is the bar's integration: where its people come from, whether they may come in, their tier from their roles, and the window where the room shows in a channel. A bar can do without one. Put INTEGRATION=local in .env and skip the Discord steps: people come in only by invitation, with a name and a password, their tiers are blessings on the People tab, and there is no window, no mirror and no /palace. On the first boot there is nobody to make an invitation, so the server writes one to its log, good for a week and for one person, who becomes the barowner:
[palacebot] no accounts yet. The first barowner signs up here (good for 7 days, once): https://your.host/invite/…
Each boot until someone has used it writes a fresh one and retires the last, so an old log line is never a live key. Invitations work on a Discord install too, beside the Discord login, for people who have no account there; they go through their own door, so the Discord server's membership isn't checked for them.
Beyond one room
Add another file to rooms/ for another server or channel and restart. One bot login serves them all; each room gets its own URL, webhook, snapshot, and membership check. Login is per person, not per room, so someone in two of your servers can walk between rooms without signing in again. The optional ROOMS=a,b line in .env limits which room files load and sets which one / redirects to.
One Discord server per install. Everything above the door belongs to the whole install, not to a room: the room list, transport between rooms, the house's roles, permissions and floor, the blocked words and moderation policy, the ledger of bans, strikes and pictures, and the room list's order and grouping. Serving two Discord servers from one process therefore mixed them — each one's room names and head counts showed in the other's list, and both shared one set of moderation records. So COMMUNITY_ID in .env (GUILD_ID, its older name, still works) names the one server this install serves, and a room file naming a different one is left alone at boot with a line in the log saying so. Unset, it is taken from the main room's file and warns. A second community is a second copy of the install: its own COMMUNITY_ID, PORT, DB_PATH and ROOMS_DIR, its own systemd unit and its own database — which is how its house, its people and its bans stay its own.
A picture is a file; the room owns its layout. Where the floor is, where Harry speaks from, what furniture stands in the room and where the doors go belong to the room, not to the picture it is showing — as in the original mansion script, where a ROOM carries its name, doors and spots and PICT is a bare filename, and one picture serves six rooms with six different door sets. Changing a room's picture changes only what everyone is looking at. assets/backdrops/index.json is a catalogue of layouts: a picture plus a room to start from, read when a room is created and copied in, never consulted again. A room file written before this (2026-09-26) is brought forward at boot — whatever it was inheriting is written into it, so nothing moves — and stamped "layoutVersion": 2.
Room pictures ("backdrops") live in assets/backdrops/ with the layouts in index.json; a room is one picture, named in its file or brought in on the settings screen. The library is in two places: the pictures the bar ships with, under assets/, and the ones this install uploaded, under data/backdrops/ with their entries in data/backdrops.json — per install, gitignored, and outside what a deploy copies, so an uploaded picture survives both. Both answer at /backdrops/<file>, and /backdrops/index.json is the two merged. Overlay images are in assets/overlays/ with their own index; word lists in games/wordlists/. Harry sees the rooms on the server with their pictures' descriptions, so write good ones. There are two shipped room sets, both tracked and both added from the Rooms tab. rooms/mansion.json is the 1995 mansion: fourteen rooms (the Palace Gate, the Hallway, the Red Room, the Chess Room, the Study, the Onyx Room, a Guest Room, the Honeymoon Suite, the Spa, the Court Room, the Slabs, the Pit, Heaven's Gate and Nrutas) and the forty-seven doors between them, carrying the original script's own polygons. Adding it opens whichever rooms are missing, with no Discord channel — it is somewhere to walk around, and several of them are bedrooms — and then hangs the doors in every room of the graph you have, the Armory and the Game Room included. The mansion's own bar is harrys-old-bar, a room of its own, because a server's main room is usually somewhere else entirely and the mansion should not hang its doors in it. The set is declarative and safe to run twice: in a room it names, it owns every to-<room> door, so the room ends up with exactly the doors the graph gives it; in a room it does not name, its doors are taken back out. Only to-<room> ids with a matching goto are touched, so a door you drew and named yourself is never in question. rooms/standard.json lists the rooms every bar gets at first boot (boardroom, armory, chessboard, checkers, backgammon, game room, White House; the board rooms carry "game" and lay their pieces out themselves), written as room files beside the main room.
Sounds. Seven roles, each with its own switch in the sounds panel, from the 1995–96 sound set. Four of them are the ones the client itself carried, which the Mac build held as snd resources 128–131 (U-SNDS.H: S_DoorClose, S_DoorOpen, S_Fader, S_DoorChime) and the Windows build needed as files: those files survive in the MADE archive, and scripts/convert-sounds.sh takes that folder as a second argument. doorshut and dooropen are the originals, and are rung exactly where U-RGNS.C rang them — a bolt going across and a bolt drawn back. choir is the fifth file in that folder, S_Choir, which rang for an excited balloon with no speaker — the room's own announcement, which is what a game declaring a winner is. It was commented out on 6 October 1995 "to save memory use )Amen", so it ships off by default; turn it on in the panel.
The bolt. Anyone in a room who holds bolt_door (members, by default) can bolt its door from the inside with /bolt, and draw it back with /unbolt — the mansion's guest rooms and Onyx Room each had a BOLT spot for exactly this. While it is thrown: nobody new comes in (they are told the door is bolted, not that the room is full), the window in Discord is shuttered to the same card a paused preview shows, nothing said inside is mirrored to the channel, and the people in the room are not listed to anyone outside — the room still appears in the room list, marked bolted, with an honest head count, because you should be able to see that a conversation is happening. A barkeep can always come in, and arrives announced like anyone else, so a room is never lost behind the bolt; the last person to leave draws it back, so a room is never left locked and empty; and a muted person cannot throw it, since they have no private conversation to protect. Bolting is in the audit log both ways. While it is thrown the room's talk is sealed (below), so Harry doesn't hear it and automoderation doesn't rate it: a bolted room is one the house has agreed not to listen to. It also shuts the door: any spot anywhere that leads to the bolted room and has been given two pictures switches to the second one, and back when the bolt is drawn — picture 0 is the door open, picture 1 is the door shut. That is the mansion's own arrangement. A spot with one picture has nothing to say and is left alone. A spot can also run by itself: give it "animate": { "frames": [{ "state": 0, "ms": 3000 }, { "state": 1, "ms": 3000 }] } and it steps through those frames for ever, each holding for its own time, with an optional jitterMs added at random so a roomful of screens doesn't blink in lockstep. That is the bar's neon sign (three frames, three seconds each, from the script's 180-tick alarm) and the honeymoon suite's television (a long dark wait with a random spread, then a brief flash). It runs entirely in the browser and the room server never ticks for it: it is cosmetic, every viewer can run it alone, and nothing about it needs agreeing. The original worked the same way — its animations used SETSPOTSTATELOCAL, the client-only cousin of the SETSPOTSTATE a bolt still uses. A new scene puts an animated spot back where the server says and starts it again. A door has two ways to care: by default it follows the room it leads to, which is the door seen from outside; set "follows": "room" and it follows the room it is in, which is the door you stand next to and watch close behind you. Which picture means shut is the room's own business rather than the flag's — the Onyx Room's picture already shows its door closed, so its states run door, nothing where the honeymoon suite's run nothing, door. State 0 is open either way.
Doors and spots. A spot is a polygon on the room's picture with a typed command (goto study makes it a door). It may also own up to four pictures and show one of them — the original's PICTS, which is what most of the life in the 1995 mansion was made of: a door drawn open or shut, a neon sign lit or dark, a television flickering. You bring the pictures in yourself on the Doors tab; nothing is picked from a canned set. Mark a spot a click turns it to the next picture and anyone in the room may work it, like a light switch; the state is the room's and survives a restart. Harry can set one with scene.spot. What a spot is showing is drawn with the room's furniture, behind the people unless you say otherwise, and its id carries bg: so he can't remove it.
The 1995 furniture. npm run spot-art -- <MansionSrcMessy dir> turns the mansion's small pictures into transparent PNGs you can upload as a spot's states: the Hallway's five doors, the guest-room door open and shut, the Onyx door, the Study door, the Vegas suite's door, bed and television, and the six faces of a die. It writes them to data/spot-art/, which is per-install and untracked. The wrinkle it exists for: the mansion script, not the file, says which colour is see-through (PICTURE ID 101 NAME "BouDoorO.GIF" TRANSCOLOR 255 ENDPICTURE), and several GIFs declare a different index from the one the script uses — so a modern decoder renders them solid black. The script reads the index out of the script, looks it up in that picture's own colour table, and knocks the colour out; a GIF that is honestly transparent comes through untouched, and one whose TRANSCOLOR names a colour it hasn't got is reported as solid rather than silently left alone.
What you, the operator, can't read. Whispers between people, and everything said in a bolted room, are sealed end to end: encrypted in the sender's browser (X25519 and Ed25519 keys made there and kept in IndexedDB, AES-256-GCM per message) and opened only in the recipients'. The server passes on enc payloads it can't open. It never stores a key, and it refuses a line in the clear between two browsers that can seal, so a client bug can't leak one. What you still see is who whispered to whom and when, who is in a bolted room, and the public keys, which are harmless. A whisper to Harry or a puppet is in the clear, because the house has to read it to answer, and the client marks it 🔓. So is one to or from a browser too old for X25519 (Chrome 133, Safari 17, Firefox 130). This protects the people in the bar from the host's disk, logs, backups and a curious admin. It does not protect them from an operator who changes the code, because the browser runs whatever the server sends. That is why there are safety numbers, and why a published-build check comes later (PRIVACY.md, "The seal"). The browser's copy of the transcript keeps the whispers it opened, as it always has.
The mansion's art, put in place. Two scripts do the rest for a bar that has added the mansion, both reading the 1995 script and writing straight into the install (run them with the server stopped; without --apply each only says what it would do). npm run mansion-doors -- <MansionSrcMessy dir> --apply hangs each bedroom's door art on the doors that lead to it, so bolting a room shuts its door in the Hallway. npm run mansion-spots -- <MansionSrcMessy dir> --apply brings back the moving pictures: the neon sign over Harry's old bar (dark, one, two, every three seconds), and in the Honeymoon Suite the bed's lights blinking every two seconds and the television flickering at random. Those were alarms in 1995 that turned a spot's state on each viewer's own client, and they are the same here: a spot's animate frames, run in the browser, nothing ticking on the server.
The artist. Every room in the 1995 mansion credits whoever painted it — Damon Williams, Elaine Alderette, Kevin Tudish — and so does every room here: "artist" in the room file, seeded from the layout, editable under the picture on the Room tab.
A backdrop is <id>.gif unless its entry names a file. The room is always 512×384 logical pixels, so a bigger picture doesn't change any coordinate; it just draws sharper on high-density screens. A picture of another shape is scaled to fill the room and cropped centered (a 16:9 picture loses about an eighth off each side), so author at 4:3 when you can. An entry can also list overlays: furniture that belongs to the picture, like the White House podium, which has z 1 and behindPeople: it draws behind the people but over a voice whose layer is back, so Harry, in avatar mode with his spawn behind it, looks like he's giving a briefing while everyone else stands in front of the lectern. Furniture ids get a bg: prefix and Harry can't move or remove them.
Pictures from the web ("Harry, find a picture of a lemur") need nothing to start: the defaults search Wikimedia Commons and then Openverse, both keyless. PICTURE_PROVIDERS in .env sets the order; OPENVERSE_KEY raises Openverse's rate limit; PICTURE_CACHE_DIR is where the server keeps its local copies (default data/pictures next to the database), each re-encoded and scaled to 1024 px, at most 300 files or 100 MB, swept after a week unused. The server only ever fetches URLs the providers returned, over https, from public addresses, as images, under 8 MB; it sends a User-Agent naming your PUBLIC_URL, which Wikimedia requires.
Automoderation (optional; Moderation in Hosting) needs an OpenRouter key of its own, entered on the settings screen's Keys tab as OpenRouter, moderation, never in .env and never Harry's; the Moderation tab's Model box picks the model, else MODERATION_MODEL in .env (a small, cheap one by default). If the house has chat judged by Jev instead, a TypeSafe key goes on the same tab as TypeSafe (JEV_MODEL, JEV_USD_PER_MTOK and TYPESAFE_BASE in .env are the model, its price per million input tokens, and the endpoint; leave them alone). Until the chosen judge's key is there, the AI modes on the Moderation tab run as the plain word filter. A rating column is added to the images table and a strikes table to the database on first start; older databases pick them up on their own. npx tsx --conditions=source scripts/automod-try.ts a.jpg -- "a line" on the host tries the prompts by hand, with the key from the database; scripts/rate-race.ts races the judges on a labeled set of lines (its header says how).
A picture uploaded on the settings screen goes through the same careful intake as an avatar: the header is checked before anything is decoded, the decode itself runs in a short-lived child process, and what is stored is a fresh PNG of the server's own making, scaled to fit 1024 px (twice the room, so it stays sharp on a good screen) and named by its sha1 — so the same picture twice is one picture. It is rated on the way in like any other picture and refused over the house's ceiling. One every three seconds a person, 8 MB at most, PNG, JPEG, GIF or WebP; an animated GIF keeps its first frame. Uploading only puts it in the library; Save is what moves the room onto it. A picture a room is standing in can't be dropped.
Custom avatars and props ("Change avatar…" on your own head) are stored under data/avatars/ next to the database: each picture is decoded, shrunk to fit 132×132, re-encoded as PNG (metadata gone), hashed, and kept once however many people use it; emoji are fetched from Twemoji's CDN at 44×44. Limits: 4 MB a picture, thirty new pictures a person a day, 5,000 pictures or 200 MB in all (the least recently worn go first), and a nightly sweep removes anything nobody has worn or dropped in six weeks. Addresses go through the same fetch guard as wall pictures. Who may bring pictures in is the load_new_avs flag (clotheshorse and above by default); who may wear a face-hiding one is wear_custom_avs (members). A barkeep can take a picture out of the bar for good from the menu on whoever wears it; that bans the image.
Notification sounds are in assets/sounds/, one WAV per role (doorbell, door, knock, name, murmur) named in sounds.json; scripts/convert-sounds.sh rebuilds them from the 1995 Palace sound set if you have it, and any 16-bit mono WAV of a second or so will do in their place. Nothing to configure; each person switches them on or off in their own browser. An audition page of the candidates is at reference/sounds.html.
Props are PNGs in assets/props/, 44×44 drawn centered on the head, or 132×132 with the prop already placed in the 3×3 area around it. The ids in PROP_IDS in the shared package are the file names. They appear as "the bar's props" in the avatar changer: on first use each is cropped to its picture and brought into the same store as pasted pictures, remembering where it sat, so it can be moved and sized like any other piece. The scripts folder has tools for pulling props out of original Palace .prp files if you have any.
Who can do what
Six tiers, low to high: guest (not in the Discord server), member (in the server, no bar role), barfly, clotheshorse, barkeep (the old wizard: directs Harry, handles the room), barowner (the old god: everything, including the punishments; the server owner and Administrators are barowners). Each thing a person can do is a flag with a lowest tier that has it:
| flag | means | default |
|---|---|---|
enter | may come in at all | member |
room_talk | what you say out loud is seen by others | member |
room_dms | may whisper to people | member |
talk_to_ghosts | Harry hears you | member |
wear_custom_avs | may wear a smiley-hiding avatar | member |
load_new_avs | may bring new avatars and props into the bar | clotheshorse |
rename_self | may go by a chosen name here (shown as an alias) | member |
command_ghosts | Harry does what you ask: games, pictures, the room | barkeep |
voice_of_god | may whisper to someone who has closed their DMs | barowner |
ears_of_god | hears people who are gagged (never whispers) | barowner |
gag_people | may gag someone (they speak, nobody hears) | barowner |
ban_people | may ban someone for a while | barowner |
bless_people | may raise someone's tier by hand | barowner |
set_floor | may raise or lower the floor | barowner |
pause_preview | may shutter the picture in Discord | barowner |
put_out | may show someone out of the bar for an hour (the original's kill), from their menu or the People tab; whoever may ban may too | barkeep |
mark_bots | may mark someone as a bot (an amber bot head on their name, for everyone) or take the mark off; anyone may say it of themselves, and take off a mark they put on | barkeep |
room_authoring | may open the room's own settings (The room, Ghost, Doors) and the Rooms tab: new rooms, their windows, their order | barkeep |
manage_moderator_settings | all of room_authoring, and the People tab: the list, invitations below their own tier, and whichever of its actions their other flags allow | barkeep |
manage_admin_settings | every tab: also Pictures, Permissions, Moderation and Keys; grants, password links, and inviting their own tier | barowner |
transport_self | may go to another room (doors, goto) | member |
whiteboard | may put the whiteboard up on the wall here and take it down | barkeep |
announce | may pin a sign in the room (^^ text) or in every room (^^^ text) | barkeep |
transport_people | may send someone, or everyone, to another room; Harry takes people at their word | barkeep |
hide_badge | may hide the ✱ that marks a barkeep or barowner | barowner |
status_spoof | may pose as a lesser tier to test what they see (/spoof); a ban, a gag or a closed door only unmasks them | barowner |
handle_room | may move, resize, swap and remove pictures and banners, clear loose props, move Harry and Ratbot | barkeep |
paint_always | may paint whenever (not only when the floor hands out markers) and wipe the board | barkeep |
ban_props | may take a picture off everyone and ban it | barkeep |
Roles and permissions are the house's: one set for every room on the server. The main room's file (the first in ROOMS) is their baseline, and the Permissions tab, from any room, lies over it; the same keys in any other room's file are ignored. A room file's "permissions" changes the defaults: any flag can be set to a tier ("enter": "guest" lets non-members in to watch; they'll be silent unless you lower room_talk too), and "floor" raises every flag whose default is member, so "floor": "barfly" means only people wearing the barfly role or better can come in and talk. A barowner can also raise or lower the floor live with /harry floor barfly (or typed in the bar: floor barfly), which holds in every room and survives restarts until lowered again; hand the barfly role to your regulars ahead of time so a raised floor costs them nothing.
The room file's "painting" (off, hosts, anyone) is the paint mode outside a session; hosts now means whoever holds paint_always. Nothing else in the room file decides who may do what; that is all the grid.
The settings screen. Three keys open it, and they nest: room_authoring opens the room's own tabs and the Rooms tab; manage_moderator_settings adds People; manage_admin_settings adds Pictures, Permissions, Moderation and Keys. Whoever holds a wider one holds the narrower ones too. By default the first two are barkeep's (barkeeps are the house's moderators) and the third is the barowner's; set room_authoring to clotheshorse in the grid to let someone who builds rooms and costumes in without the People tab. Any of them gives a ⚙ button in the bar and Settings… on their own menu, opening /<room>/settings. The tab bar is in two groups, This room (The room, Ghost, Doors) and The World (the rest). room_authoring opens The room: its name; its picture, which you bring in yourself — choose a file, drag one onto the picture, or paste one — and which takes effect on Save (it moves everyone to the new picture at once, and its floor and spots come with it; a game's picture still comes and goes on top). What the picture is is set afterwards and separately: where Harry speaks from on the Harry tab, the doors on the Doors tab, and a fresh picture starts with the floor and spots the room already had. Pictures brought in here are listed under the upload, newest last, to put the room back in one or drop it for good; there is no grid of the bar's own pictures, since a room that wants one of those is opened from a layout; and its limits: capacity, the paint mode, whether talk is mirrored to Discord, how many loose props the floor holds and how long they stay, the seconds between Harry's pictures and how many he may hang a session, his budget of room actions a session, and how long a host's consent to a game stays good. It also opens Doors: hotspots drawn on the room's picture, each with a name and a command the click issues (goto boardroom for a door to another room), after the original's polygon hotspots; and Harry: his name in this room, his presence (full; hosts only, where he hears and answers only people who may command him; absent, the same switch as /harry off: he doesn't come in at all), his look on the room's own picture (a ghost, a voice from a spot with no head, or an avatar, a head that can be moved; it belongs to the room, so changing the picture leaves it alone and another room on the same picture can put him elsewhere), his place (type it or click the picture), what he wears, set from his right-click menu in the bar (Change Harry's look…, the same outfit editor as your own; a face-hiding piece replaces his portrait), and his macros, phrases that run a command as the speaker (play chess → take everyone to chessboard). manage_admin_settings opens the house rules: the Discord role names for each tier and the permissions grid, one row per flag with the lowest tier that has it, plus the floor (Permissions); the people of note, where you find anyone who has been in and give them a blessing, a grant (one tool, by hand, for a trusted lieutenant: a barkeep who may gag but not ban, say; an admin's alone, and you can only hand out what you hold yourself), a gag or a ban (People, which manage_moderator_settings opens too: a moderator sees only the buttons for the tools they hold, and grants and password links stay with the admin); where pictures may come from: pasting, addresses, emoji, an allowlist of sites, the search providers in order (Pictures); the blocked words, automoderation for chat and pictures, the rating ceiling, the policy table and the strike rules (Moderation; see Moderation in Hosting); and the keys, kept on the server and never shown again: Openverse's, and automoderation's own OpenRouter key with the day's spend beside it (Keys). Setting the server up is a command on the host, run from the install's directory while the server runs: npm run setup -- --standard opens the board rooms every bar gets (any that are missing), npm run setup -- --mansion opens the 1995 mansion's rooms and hangs the doors between them, and npm run setup -- --adopt-channels binds channels that exist rather than cutting new ones. They speak to the server on localhost with the agent token from .env, so no browser reaches them (until 2026-09-27 they were buttons on the Rooms tab; each rewrites many rooms at once, which is an installer's job). Running one twice is harmless. --adopt-channels: for every room with no window it looks for a channel named after the room's address or its name the way Discord writes channel names (The Onyx Room finds #the-onyx-room or #onyx-room), and the oldest match wins, since a duplicate made later is the spare. It creates nothing and deletes nothing, and it reports which rooms found no channel, which duplicates it left alone, and which channels match no room — the leftovers from earlier attempts, yours to clear. A room's window in Discord is on its Room tab and can be changed at any time, not only when the room is opened: none takes the channel away, an id binds an existing channel (room_authoring), and make one has the bot cut a new one in the same category as the channel of the room you are looking at, which takes room_authoring and the bot's Manage Channels. It applies at once, with no restart, and the channel id is written into the room file where the rest of the install's bindings live. The room list everyone sees — in the bar, and when Harry reads the rooms out — is the house's, not each person's. Drag a room by its pad (⠿) on the Rooms tab to move it, with a mouse or a finger, or press its ↑ or ↓; the order saves as you drop it, and rooms you never place follow in server order. Group the list into titled sets (room_authoring) lists each room under the name of the Discord category its channel sits in, each set together (sets in the order their first room comes in yours; the Rooms tab shows each room's set), so the sets are the ones already on your server; rooms without a channel have no category and fall in together at the end. room_authoring opens Rooms: the rooms on this server and a form to open a new one: an address (/poolroom), a name, and its window in Discord: no channel (the default: anyone in the server may come in, nothing is posted anywhere, and /palace peek room:poolroom works from any channel), make a channel named after the address (the bot needs Manage Channels), or an existing text channel's id (which also narrows the door to those who can see it). The new room opens in the picture of the room you are in and gets one of its own on its own Room tab. It is written to rooms/<address>.json with this room's roles and permissions and Harry's name, is live at once, and is loaded at every restart even when ROOMS names only the originals. Everything set on the screen is kept in the database and laid over the room file, so the file stays the install's baseline. A grant never undoes a punishment, and nobody can act on someone above them or on an equal who holds the same tool.
Punishments, for whoever holds the tool, from the right-click menu on a head, typed (mute Ada, unmute Ada, ban Ada, unban Ada, bless Ada barfly), or from Discord (/harry mute|ban|pardon|bless|floor|punished): a user-mute leaves the person able to type and see their own balloons while nobody else receives them, except a barowner, who sees them in a typewriter face with a dashed rim and marked "(muted)" in the transcript; the muted person is not told. (The typed word for a user-mute in the bar is gag Ada / ungag Ada; plain mute is the personal mute anyone has.) A ban refuses them at the door for an hour, a day, a week, or for good, and shows them out at once. A blessing raises someone's tier by hand, whatever their Discord roles say, never to the blesser's own rank. Every one of these goes in the audit log with who, whom, and why, and they're kept by Discord id, so a new name doesn't shed them. gag_people, ban_people, bless_people and set_floor are separate flags, so a barkeep can be trusted with some of them (on the Permissions page) or one person can be given one (on the People page). Whoever holds pause_preview can shutter the live picture in Discord: /harry preview off (or typed preview off) replaces it with a black PAUSED card until preview on; /palace peek shows the card too.
Enabling Harry (optional)
Harry is an LLM host that speaks from a spot in the picture. He runs as a separate process, harryd, that connects to the room server as an agent and can only use a small server-checked API (say, whisper, who). Nothing he does reaches Discord. Full design in HARRY.md.
- In the server's
.env, addAGENT_TOKENS=harry:<long random token>(openssl rand -hex 24) and restart the server. - Copy
packages/harryd/.env.exampletopackages/harryd/.env: the same token asAGENT_TOKENand the room server's WebSocket URL (ws://127.0.0.1:3311when on the same host). His OpenRouter key goes on the settings screen's Keys tab (OpenRouter, Harry), and his model, daily dollar cap, idle-reply cap, image model and picture check on the Harry tab under Model and budgets, all server-wide and applied within a minute; each may instead be set in harryd's.env(OPENROUTER_API_KEY,HARRY_MODEL,HARRY_DAILY_USD…), which is the fallback for anything the screen leaves empty. What's about the install (addresses, tokens, paths) stays in.env; what's about how he runs is on the screen. - Name this server's Discord roles for the bar's tiers in the main room's file (or on the Permissions tab),
"roles": { "barfly": ["barfly"], "clotheshorse": ["regular"], "barkeep": ["barkeep", "wizard"], "barowner": ["barowner"] }; a tier's own name always counts (an older file'shostRoleis folded into barkeep's list when it loads). The server owner and Administrators are barowners regardless. See who can do what. Optionally set"voice": { "name": "Harry", "avatar": "harry", "spot": [322, 45] }(and"mode": "avatar"to give him a head on the room's own picture); the settings screen's Harry tab changes all of that without touching the file. How he appears is the room's ownvoice:"mode"isghost(a bodiless voice out of the painting, as in the bar) oravatar(a movable head drawn fromassets/faces/<avatar>-head.png, 88×88, square),"spot"is where, and in avatar mode"layer"isback(behind all the people, and behind furniture markedbehindPeople, which is how he ends up behind the White House podium),among(the default, sorted with the people by depth) orfront. A room that says nothing is a ghost speaking from the middle of the picture just above the floor. A layout seeds all of it when the room is created, and the room owns it from then on. - Run it:
npm run harrydlocally, or on the host installdeploy/harryd.serviceandsudo systemctl enable --now harryd. The deploy script restarts it with the server.
Controls from Discord, barkeeps and up: /harry status, /harry off (drops him and refuses him until /harry on), /harry log (his last hour), /harry reset (room back to normal). In the room, a host saying Harry, stop clears his balloons and tells him to stand down. HARRY_ENABLED=0 on the server refuses agents entirely. Every call he makes is written to the agent_log table. For Pictionary he needs an image model: Image model on the Harry tab, or HARRY_IMAGE_MODEL in harryd's .env (default Gemini 2.5 Flash Image on OpenRouter, about four cents a grid of nine doodles) with its own daily cap; the word none on the screen (or an empty variable) and he draws from shapes instead.
Before you publish a fork
Secrets live only in files that are ignored by git: .env, packages/harryd/.env, rooms/*.json, deploy/local.env, and data/. Each has an .example or .sample beside it. Before pushing to a public remote, run scripts/secret-scan.sh: it greps the tracked tree and the whole history for API keys, bot tokens, private keys, Discord ids, and per-install files, and exits non-zero if it finds any. Edit its hostname pattern for your own domain.