Skip to content

CrashTestButter/rpg.actor-guildhall

Repository files navigation

rpg-actor-guildhall

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


How It Works

  1. Streamer adds guildhall.todi.wtf/scene/:channel as OBS browser source (1920x1080, transparent)
  2. Viewer visits guildhall.todi.wtf/auth and links their Twitch + Bluesky accounts (dual OAuth)
  3. Viewer types !join in Twitch chat - their rpg.actor character walks in and sits by the fire
  4. NPCs from the rpg.actor registry wander across periodically
  5. Encounters can be triggered from the admin panel - turn-based autobattle using real RMMZ stats

Chat Commands

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

URLs

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

Identity Verification

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.


Architecture

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
Loading

Project Structure

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

Database Schema

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"
Loading

Running

Local Development

cp .env.example .env
# Fill in the required variables
bun install
make dev

Deploy to Production

Deploys 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 deploy

Other 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

Environment Variables

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

Tech Stack

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

About

Hub-zone for rpg.actor characters to hang out and optionally fight monsters. Integrates with Twitch and bluesky for validation and interaction

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors