Twitch stream overlay - rpg.actor characters idle around a bonfire, autobattle monsters, and rank up.
Viewers link their Twitch and Bluesky accounts via dual OAuth, then type !join in chat. Their decentralized RPG character walks in and sits by the fire. Characters are sourced from the AT Protocol (Bluesky) - identity and stats live on each player's own Personal Data Server, not ours.
Live at: guildhall.todi.wtf
- Streamer adds
guildhall.todi.wtf/scene/:channelas OBS browser source (1920x1080, transparent) - Viewer visits
guildhall.todi.wtf/authand links their Twitch + Bluesky accounts (dual OAuth) - Viewer types
!joinin Twitch chat - their rpg.actor character walks in and sits by the fire - NPCs from the rpg.actor registry wander across periodically
- Encounters can be triggered from the admin panel - turn-based autobattle using real RMMZ stats
| Command | Description |
|---|---|
!join |
Join the scene with your verified rpg.actor character |
!leave |
Leave the scene |
!guildhall |
List who's currently in the scene |
!flex |
Show off your guild tier and level |
!fight |
Join the party for an assembling encounter |
!link |
Get the URL to link your accounts |
!unlink |
Remove your Twitch/Bluesky link |
| Path | Purpose |
|---|---|
/scene/:channel |
OBS browser source (transparent background) |
/test/:channel |
Preview with checkerboard background |
/admin/:channel |
Admin panel - manual joins, NPC spawns, encounters |
/auth |
Dual OAuth account linking page |
Unverified joins are not allowed. Both identities are proven via OAuth before linking:
Viewer visits /auth
-> "Login with Twitch" -> Twitch OAuth2 -> proves Twitch identity
-> "Link Bluesky" -> AT Protocol OAuth -> proves Bluesky DID ownership
-> verified_links row created (permanent)
Viewer types !join in any channel
-> bot looks up twitch_user_id in verified_links
-> resolves rpg.actor character from stored handle
-> character joins the scene
The link is global - once verified, !join works in any channel where the bot is active.
A single Bun process: HTTP server, Twitch chatbot, and SSE event bus.
graph TD
subgraph Twitch
TC[Twitch Chat]
end
subgraph External APIs
RPA[rpg.actor API]
PLC[plc.directory]
PDS[Player PDS]
MON[monster-database API]
end
subgraph "Bun Server"
AUTH[Auth Routes<br/><i>auth/</i>]
BOT[tmi.js Bot<br/><i>bot/</i>]
RES[Character Resolver<br/><i>character/resolver.ts</i>]
CHARS[Scene Characters<br/><i>scene/characters.ts</i>]
CFG[Scene Config<br/><i>scene/config.ts</i>]
FURN[Furniture + Layers<br/><i>scene/furniture.ts</i>]
NPC[NPC Ticker<br/><i>scene/npc.ts</i>]
ENC[Encounter Engine<br/><i>guildhall/encounter.ts</i>]
GH[Guildhall Stats<br/><i>guildhall/player.ts</i>]
DB[(SQLite<br/>WAL mode)]
SSE[Hono SSE Route<br/><i>/api/scene/:channel/stream</i>]
BUS((sceneBus<br/><i>scene/bus.ts</i>))
end
subgraph Browser
REN[Canvas Renderer<br/><i>renderer.js</i><br/>1920x1080 OBS Source]
end
TC -- "!join" --> BOT
BOT -- "lookup verified_links" --> DB
BOT --> RES
RES --> RPA
RES --> PLC
RES --> PDS
RES -- "cache hit/miss" --> DB
RES -- "resolved character" --> CHARS
CHARS -- "INSERT/DELETE" --> DB
CHARS -- "emit(channel, event)" --> BUS
CFG -- "INSERT OR REPLACE" --> DB
CFG -- "emit config" --> BUS
FURN -- "CRUD" --> DB
FURN -- "emit furniture/layer" --> BUS
NPC -- "emitNpc()" --> BUS
ENC --> MON
ENC --> GH
ENC -- "emit encounter" --> BUS
GH -- "read/write stats" --> DB
GH -- "sync guildhall block" --> PDS
BUS --> SSE
SSE -- "SSE events" --> REN
AUTH -- "Twitch OAuth + AT Protocol OAuth" --> DB
src/
index.ts # Boot: DB init, Hono app, bot start, NPC tickers, cleanup intervals
config.ts # Env -> typed config object
api/
routes.ts # HTTP routes + SSE endpoint + admin/test/page routes
templates/ # Server-rendered HTML templates
landing.ts # Landing page
scene.ts # OBS browser source page
test.ts # Checkerboard preview page
admin.ts # Admin panel
encounter.ts # Encounter assembly / results page
auth/
routes.ts # /auth page, Twitch OAuth, AT Protocol OAuth callbacks
atproto-client.ts # AT Protocol OAuth client (loopback dev / confidential prod)
channels.ts # Registered channel CRUD
cookie.ts # Auth cookie helpers
session.ts # Auth session management (cookie-to-state bridge)
verified-links.ts # Verified Twitch <-> Bluesky link CRUD
bot/
chat.ts # tmi.js client lifecycle
commands.ts # !join / !leave / !guildhall / !flex / !fight / !link / !unlink
character/resolver.ts # Handle -> DID -> PDS -> sprite pipeline + 5-min SQLite cache
db/
index.ts # SQLite singleton (WAL mode)
schema.ts # Base schema (CREATE TABLE IF NOT EXISTS) + versioned migrations
guildhall/
encounter.ts # Encounter logic: monster fetch, party build, reward distribution
gather.ts # Encounter party assembly (!fight join window)
partySummary.ts # Shared party/encounter summary helpers
player.ts # Player stat CRUD + leveling + guild tier progression
pds-sync.ts # Read/write guildhall block to player's AT Protocol PDS
stats.ts # Stat curves, EXP formula, guild tier thresholds
scene/
manager.ts # Re-export barrel (all scene imports go through here)
types.ts # Shared interfaces: SceneCharacter, FurnitureItem, SceneEvent, etc.
bus.ts # sceneBus EventEmitter + emitChat / emitNpc
characters.ts # join / leave / sync scene characters
config.ts # Channel config CRUD
furniture.ts # Furniture + layer CRUD
library.ts # Per-channel furniture library (builtins + custom templates)
npc.ts # NPC pool loader (6-hour refresh) + per-channel spawn timers
frontend/
js/
renderer.js # Canvas 2D game loop entry point
config.js # Scene configuration constants
character.js # Character sprites, walk-to-sit animation, idle bob
npc.js # NPC walk-through animation
sprites.js # Sprite sheet loading and frame extraction
furniture.js # Furniture rendering + seat reservation
behavior.js # Character behavior state machine (sit / wander / mingle)
battle.js # Turn-based autobattle system
gather.js # Party gather overlay (!fight assembly UI)
drawing.js # Labels, HP bars
sse.js # EventSource client + event routing
assets/
campfire.png # 11-frame bonfire sprite sheet
erDiagram
verified_links {
TEXT twitch_user_id PK
TEXT twitch_login
TEXT twitch_display
TEXT did UK "Bluesky DID"
TEXT handle
TEXT linked_at
TEXT updated_at
}
registered_channels {
TEXT channel PK
TEXT twitch_user_id
TEXT registered_at
}
character_cache {
TEXT did PK
TEXT handle
TEXT display_name
TEXT sprite_url
TEXT sprite_meta "JSON: rows, columns, frames"
TEXT cached_at "pruned after 1 hour"
}
scene_characters {
TEXT channel PK
TEXT twitch_user PK
TEXT did
TEXT handle
TEXT display_name
TEXT sprite_url
INTEGER position "0-11 seat slot"
INTEGER level
TEXT guild_tier
TEXT joined_at
}
channel_config {
TEXT channel PK
INTEGER max_characters "default 12"
INTEGER npc_enabled
INTEGER npc_interval_ms
INTEGER sprite_scale
REAL ground_y
INTEGER show_labels
TEXT behavior "sit | wander | mingle"
TEXT scene_title
INTEGER wander_interval_ms
INTEGER npc_count
INTEGER npc_replace_chance
INTEGER scene_width
INTEGER scene_height
INTEGER gather_duration_ms "default 30000 ms"
}
furniture_library {
INTEGER id PK
TEXT channel
TEXT key UK "unique per channel"
TEXT name
TEXT sprite_key
INTEGER is_seat
REAL radius
INTEGER sit_direction
REAL default_scale
INTEGER sort_order
INTEGER is_builtin
}
npc_blacklist {
TEXT channel PK
TEXT did PK
TEXT handle
TEXT added_at
}
encounter_history {
INTEGER id PK
TEXT channel
TEXT monster_name
INTEGER monster_dr
INTEGER monster_count
TEXT monsters_json
INTEGER party_size
INTEGER median_level
INTEGER suggested_dr
INTEGER victory
TEXT created_at
}
scene_layers {
INTEGER id PK
TEXT channel
TEXT name
INTEGER sort_order
INTEGER is_special "1 = Characters layer (protected)"
}
scene_furniture {
INTEGER id PK
TEXT channel
TEXT sprite_key
REAL x
REAL y
REAL scale
INTEGER is_seat
INTEGER z_index
INTEGER layer_id FK
REAL radius
INTEGER sit_direction
INTEGER anim_cols
INTEGER anim_frame_width
INTEGER anim_frame_height
INTEGER anim_speed
INTEGER anim_row
}
player_stats {
TEXT did PK
INTEGER level
INTEGER exp
INTEGER gold
INTEGER guild_exp
TEXT guild_tier
INTEGER current_hp
INTEGER current_mp
TEXT created_at
TEXT updated_at
}
pending_encounters {
TEXT channel PK
TEXT rewards_json "JSON: monsterXp, monsterGold, monsterGuildXp"
TEXT created_at
}
auth_sessions {
TEXT token PK
TEXT twitch_user_id
TEXT twitch_login
TEXT twitch_display
TEXT did
TEXT handle
TEXT oauth_state
TEXT created_at "1-hour TTL"
}
atproto_oauth_sessions {
TEXT sub PK
TEXT session_data
TEXT updated_at
}
atproto_oauth_states {
TEXT key PK
TEXT state_data
TEXT created_at
}
schema_version {
INTEGER version "current migration version"
}
scene_layers ||--o{ scene_furniture : "layer_id"
scene_characters }o--|| channel_config : "channel"
scene_furniture }o--|| channel_config : "channel"
scene_layers }o--|| channel_config : "channel"
furniture_library }o--|| channel_config : "channel"
npc_blacklist }o--|| channel_config : "channel"
encounter_history }o--|| channel_config : "channel"
verified_links ||--o| player_stats : "did"
registered_channels ||--|| channel_config : "channel"
cp .env.example .env
# Fill in the required variables
bun install
make devDeploys to a remote server via Docker context + Swarm stack with Traefik.
One-time setup:
# Generate AT Protocol OAuth key
bun -e "import { JoseKey } from '@atproto/jwk-jose'; JoseKey.generate(['ES256'], 'guildhall-key-1').then(k => console.log(JSON.stringify(k.privateJwk)))"
# Create .env on the server
make env
ssh $CONTEXT 'vi /root/guildhall/.env'Deploy / redeploy:
make deployOther commands:
| Command | Description |
|---|---|
make build |
Build image on the remote Docker host |
make deploy |
Build + deploy stack |
make destroy |
Remove the stack |
make logs |
Tail service logs |
make ps |
Show running tasks |
| Variable | Required | Default | Description |
|---|---|---|---|
PUBLIC_URL |
yes | -- | Public base URL (https://guildhall.todi.wtf) |
PORT |
no | 3100 |
HTTP port |
DATABASE_PATH |
no | ./data/guildhall.db |
SQLite file path |
TWITCH_BOT_USERNAME |
yes | -- | Bot Twitch account name |
TWITCH_BOT_TOKEN |
yes | -- | Bot OAuth token (oauth:xxx) from twitchapps.com/tmi |
TWITCH_CHANNELS |
yes | -- | Comma-separated channel list |
TWITCH_CLIENT_ID |
yes | -- | Twitch OAuth app client ID |
TWITCH_CLIENT_SECRET |
yes | -- | Twitch OAuth app client secret |
OAUTH_PRIVATE_KEY_JWK |
prod | -- | ES256 JWK private key for AT Protocol OAuth |
| Layer | Choice | Why |
|---|---|---|
| Runtime | Bun | Fast startup, native SQLite, TypeScript without build step |
| HTTP | Hono | Minimal routing, built-in SSE streaming |
| Database | SQLite (WAL) | Single-file, zero-config, enough for per-channel scene state |
| Chat | tmi.js | De facto Twitch IRC library |
| Auth | Twitch OAuth2 + AT Protocol OAuth | Dual identity verification - no impersonation possible |
| Real-time | SSE | Server-to-client only - simpler than WebSocket, works in OBS browser sources |
| Rendering | Canvas 2D | Direct pixel control for sprite animation; transparent background for OBS |
| Identity | AT Protocol | Characters are sovereign - data lives on each player's PDS |
| Deploy | Docker Swarm + Traefik | TLS via Let's Encrypt, deployed from local via Docker context |