Skip to content

Repository files navigation

gantt-quest

A team-comedic, engineering-themed dungeon-crawl that runs in Slack and in a companion web dashboard, sharing a single state. Cloudflare Workers + D1 + Workers AI + Durable Objects. No real coworkers as NPCs β€” names are generated.

The repo is named slack-quest on GitHub for historical reasons; the bot ships as Gantt Quest via the BOT_NAME env var. Rename it for your team β€” the Cloudflare Worker name, D1 database name, and Slack app display name can all be changed without touching engine code.


Architecture at a glance

Two Cloudflare Workers and a small set of Durable Objects share one D1 database:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  apps/slack         β”‚      β”‚  apps/web            β”‚
β”‚  (Slack worker)     β”‚      β”‚  (Web worker + SPA)  β”‚
β”‚                     β”‚      β”‚                      β”‚
β”‚  β€’ slash commands   β”‚      β”‚  β€’ dashboard + town  β”‚
β”‚  β€’ interactive btns β”‚      β”‚  β€’ web combat (WS)   β”‚
β”‚  β€’ LobbyManager DO  β”‚      β”‚  β€’ QuestRoom DO      β”‚
β”‚    (alarm timing)   β”‚      β”‚  β€’ LobbyRoom DO      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚                            β”‚
           β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                      β”‚
           β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
           β”‚  D1 (one database)  β”‚
           β”‚  + R2 (art cache)   β”‚
           β”‚  + Workers AI       β”‚
           β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
  • Slack worker handles /sq … slash commands, interactive button payloads, and the singleton LobbyManager DO that schedules lobby auto-start alarms and delayed "your turn!" notifications.
  • Web worker serves the React dashboard, exposes the JSON + WebSocket combat API, and hosts the QuestRoom (per-quest live combat) and LobbyRoom (per-quest live lobby + chat) Durable Objects. Both DOs use the Hibernation API so idle connections cost nothing.
  • Cross-binding: the Slack worker holds DO bindings into the web worker (QUEST_ROOM, LOBBY_ROOM) so Slack actions instantly push updates to web clients without polling.
  • D1 is the system of record. DO in-memory caches always rehydrate from D1 after hibernation; nothing critical lives only in DO storage.

Design pillars

  • Soft death by default. At 0 HP on a standard quest you're "downed," lose 25% gold + a random unequipped inventory item, and can't quest for 12h. Elite quests (/sq quest elite) opt into perma-death β€” the character row is deleted and survivors fight on.
  • Mechanics deterministic, flavor AI-written. Damage rolls, status procs, drop rates, and XP math run on a pure engine (packages/core). Workers AI only writes the prose that wraps the result (opening scenes, hit flavor, item names, dungeon room descriptions). If an AI call fails, the deterministic line is shown.
  • Single source of truth across surfaces. Slack /sq attack and the web Attack button hit the same engine via QuestRoom. Lobby accept/decline from Slack pushes to the web LobbyView in real time.
  • Two-surface visibility. Most narration stays in the quest thread. Big beats (joins, gauntlet wave transitions, boss phase 2, perma-death, victories, expedition treasure reveals, lobby start) use Slack's reply_broadcast to surface in the channel and toast the web dashboard.
  • No GitHub integration. Quests are AI-flavored from generic prompts.

Characters & classes

/sq roll creates a character with a random class + AI-generated name. One character per Slack user. Classes share five primary stats (STR / INT / VIT / AGI / DEX) β€” you spend points at level-up via /sq spend <stat> (and from the web stat-allocator). Attack mod = floor((STRβˆ’5)/2); magic mod = floor((INTβˆ’5)/2).

DevOps Mage β€” 22 HP | STR 4, INT 9, VIT 5, AGI 6, DEX 6

  • Passive - Mana Font - +1 mana every 3 turns
  • Active - Fireball - 2 mana, 1 turn cooldown. magicΓ—d6 fire AoE to all enemies
  • Active - Lightning Bolt - 1 mana. d20+mag to hit, magicΓ—d8 lightning damage
  • Active - Mage Armor - 1 mana. 3d6+mag shield to an ally
  • Active - Containerize - 2 mana, 1 turn cooldown. Stun the monster; each stunned turn it has a 30% cumulative chance to break free (guaranteed on the fourth turn)

QA Paladin β€” 28 HP | STR 9, INT 4, VIT 9, AGI 4, DEX 4

  • Passive - Holy Rage - Whenever you or an ally takes damage, your next attack deals +10% of that damage (stacks)
  • Active - Smite - 1 mana, 1 turn cooldown. 1d6+atk+wpn+2d8 damage; enemy deals βˆ’50% on their next swing
  • Active - Shield of Faith - 2 mana. +5 AC party-wide for 3 rounds
  • Active - Lay on Hands - 1 mana. 1d6+mag/2+vit/2 heal to an ally; if the target is your protected ally, also heal yourself for the same amount
  • Active - Protect - 0 mana, 2 turn cooldown. Target an ally: they take half damage, you absorb the other half. Target yourself instead to gain 2d6+mag/2+vit/2 shield

Backend Druid β€” 24 HP | STR 6, INT 7, VIT 6, AGI 5, DEX 6

  • Passive - Primal Strikes - Magic mod boosts attack to-hit and damage; dealing attack damage heals you for 2Γ—mag+attack
  • Active - Wildgrowth - 2 mana, 2 turn cooldown. 3d6+mag+atk AoE + entangle (βˆ’4 to-hit) for 2 rounds
  • Active - Regeneration - 1 mana. mag HP/turn heal-over-time on an ally for 4 rounds
  • Active - Barkskin - 1 mana. +5 AC on an ally for 2 rounds
  • Active - Animal Form - 2 mana. Transform for 4 rounds β€” STR, VIT, AGI, and DEX each increase by mag + 25% of their current value

Frontend Bard β€” 20 HP | STR 4, INT 9, VIT 5, AGI 6, DEX 6

  • Passive - Bardic Aura - Party deals +(1+⌊lv/5βŒ‹) bonus damage while you're alive
  • Active - Crescendo - 1 mana. d20+mag to hit; 1d6+mag+partyΓ—2+wpn damage on hit
  • Active - Verse - 0 mana, 2 turn cooldown. Mock an enemy (disadvantage) or encourage an ally (advantage) on their next 2 rolls
  • Active - Battle Hymn - 2 mana. Bardic aura surges for 3 rounds (+2+magic bonus damage on top of the base aura) and restores mana to all allies
  • Active - Serenade - 2 mana. 2d6+mag heal + small shield on a chosen ally

Staff Sage β€” 26 HP | STR 4, INT 10, VIT 6, AGI 5, DEX 5

  • Passive - Foretell - Always see monster damage rolls and next intended targets
  • Active - Ray of Frost - 1 mana. Spell attack (+mag to hit), magd4 frost damage with a 25% chance to freeze
  • Active - Blizzard - 2 mana, 3 turn cooldown. AoE frost dealing 1d6+mag to all enemies at the end of each of your turns for 3 turns (10% freeze chance per hit)
  • Active - Good Fortune - 1 mana, 1 turn cooldown. Heal an ally for 1d4+mag; a delayed heal for double that amount triggers on your next turn
  • Active - Ill Omen - 1 mana, 1 turn cooldown. Curse an enemy; tracks all damage it takes and deals 50% of the total back to it after 3 of its turns

Refactor Rogue β€” 18 HP | STR 7, INT 4, VIT 4, AGI 7, DEX 8

  • Passive - Lethal Strikes - Crits apply 2+⌊lv/2βŒ‹ bleed stacks
  • Active - Backstab - 0 mana, 2 turn cooldown. Attack with advantage; roll damage twice and take the higher roll
  • Active - Vanish - 2 mana. Untargetable for 2 rounds; attacks from Vanish auto-crit on hit
  • Active - Envenom Weapon - 1 mana. Next 2 hits each apply 2+lv poison stacks
  • Active - Debilitate - 1 mana, 3 turn cooldown. Stun for 1 round + 20% vulnerability for 2 rounds

SRE Warden β€” 30 HP | STR 9, INT 4, VIT 10, AGI 4, DEX 3

  • Passive - Thorns - Reflect 25% of your armor value back to any attacker that hits you
  • Passive - Armor Up - Regenerate 2+⌊lv/4βŒ‹ shield at the start of each of your turns
  • Passive - Resilient - Each successful attack hit grants a Resilient stack (lasts 4 rounds). Each active stack raises your effective armor and shield cap by 2+⌊vit/4βŒ‹, boosting Thorns, Brace, and the maximum shield you can hold
  • Active - Bulwark Strike - 0 mana, 2 turn cooldown. d20+atk to hit; 1d10+atk+50% current shield damage
  • Active - Taunt - 2 mana. Force all monsters to target you for 2 swings; all incoming damage routes through your armor for 2 turns
  • Active - Brace - 0 mana, 4 turn cooldown. Restore 50% of your effective armor as shield + βˆ’20% incoming damage for 2 turns

Data Warlock β€” 22 HP | STR 4, INT 10, VIT 5, AGI 5, DEX 6

  • Passive - Sinister Queries - Dealing any damage applies 1+⌊lv/5βŒ‹ bleed stacks to the target
  • Active - Hex - 1 mana. βˆ’25% monster damage; hexed target gains 3 bleed stacks on each damage received
  • Active - Leech Life - 1 mana. 2d6+mag damage + heal 50% of damage dealt
  • Active - Forbidden SQL - 2 mana. Consume all bleeds for (2+⌊mag/4βŒ‹)Γ—stacks damage
  • Active - Summon Imp - 2 mana, 2 turn cooldown. Summon an imp whose attacks deal damage equal to your magic modifier

(Xm) = mana cost; (Xcd) = cooldown in turns after use. Active abilities cost mana. Mana refills between quests and at level-up. Magic-type drop items grant +1/+2/+3 max mana when consumed (capped at 5).


Quest types

Started via /sq quest <variant> [elite] (or the Job Board for posted contracts):

  • standard β€” single (or multi-monster pack) fight.
  • boss (L3+) β€” single tougher monster with 2 phases at 50% HP. 2Γ— rewards.
  • gauntlet (L5+) β€” 3 monsters back-to-back, no flee, party locked at start. 3Γ— rewards, guaranteed drop on the final kill.
  • dungeon (L4+) β€” grid-shaped expedition. AI generates the room theme + per-room scene art, and a graph of encounters / loot / lockboxes / traps / NPCs / merchants / boss room. Navigate via /sq move <dir> (or the web compass). 2.5Γ— rewards.
  • bounty_pack β€” multi-monster brawl. Each monster carries its own weakness/resistance profile.

elite is a modifier that composes with any variant β€” turns on perma-death.

The Job Board (/sq board or web town β†’ Job Board) lists 3 posted-contract quests at any time. Accepting one starts that variant with a small reward bonus (from_job_board flag at victory).


Lobby system

Every quest opens in a lobby before combat starts. Slack and web stay in sync via the LobbyRoom DO (WebSocket push from the web side, RPC from Slack).

  • Invites & ready-up β€” the creator invites teammates by @user. Each invitee Accepts or Declines; accepted players Ready Up. When all accepted are ready, the quest auto-starts.
  • Force Start β€” creator skips remaining invites. Pending invitees are dropped (no party HP scaling).
  • Auto-start alarm β€” 5 minutes after creation via the LobbyManager DO. Same flow as Force Start.
  • πŸ”’ Lock β€” creator toggles the chest icon to reject new invites + new /sq join calls. Already-pending invitees can still accept.
  • πŸ—‘ Cancel β€” creator can abort.
    • Pre-combat: quest row + quest_party cascade deleted.
    • Mid-combat reinforcement lobby: only the pending invitees are dropped; the active fight continues untouched.
  • πŸ†˜ Reinforcement lobbies β€” a creator on an active quest can click "Call Reinforcements" on the active-quest card. New invitees see a single "Join the Fight!" button that atomically accepts + refills mana + scales monster HP + pulls them into the live combat state via QuestRoom.notifyFighterJoined. No "wait for the rest to ready up" gate.
  • Real-time chat β€” ephemeral per-quest chat lives in LobbyRoom's in-memory ring buffer (50-message cap, dropped on hibernate, never persisted to D1). Disappears when combat starts.

Combat

Turn-based, dice-driven, engine-pure. The same engine (packages/core) drives both Slack and web combat β€” actions are deserialized into a TurnAction, applied with step(), and the resulting state + events are broadcast.

Actions

  • /sq attack β€” 1d6 + weapon power (halved) + STR-derived bonus; crit Γ—2 on a nat 6.
  • /sq signature β€” class damage signature; costs 1 mana (see class table).
  • /sq ability <id> β€” class utility/support ability (Taunt, Vanish, Lay on Hands, etc.). Healing and support are entirely ability-driven β€” there is no universal /sq heal or /sq cast. Each class's kit covers its role.
  • /sq revive <id> @user β€” consume a revive item to bring a downed partymate back at 50/75/100% HP (rarity-tiered). Can't self-revive.
  • /sq mark <target> β€” focus-fire tag: party attacks get a bonus until expiry. Self-attacks by the marker DON'T get the bonus.
  • /sq position <front|back> β€” swap rows. Front row eats hits first; back row gets reduced damage from melee but can be targeted by ranged.
  • /sq flee β€” 1d2 self-escape. Quest fails only if you were the last.
  • /sq use <item> β€” combat-usable items (consumable / tool / scroll). Damage tools cap at monster_hp - 1 so they never deliver the killing blow.

Damage typing

Every attack has a damage_type: physical | magic | fire | ice | lightning.

  • Physical routes through your armor pool first (depletes shield, then HP). The pool's max is floor(armor_power / 2) where armor_power comes from equipped gear (body + helmet/2 + pants/4 + shield).
  • Magic / fire / ice / lightning bypass the armor pool entirely and hit HP directly. Your resist_<type> stat (from gear stat_bonus) reduces the final damage by that percent.

Monster attack types are visible on the in-combat MonsterCard and the Active Quest preview ("πŸ”₯ FIRE ATTACKS" pill) so you can plan loadouts before clicking Open Combat.

Status effects

  • Burning β€” DoT, ticks at turn start.
  • Frozen β€” your next turn is skipped (turn_skip event + particle burst).
  • Shocked β€” incoming damage is amplified (Γ—1.30 mag 1, Γ—1.45 mag 2).
  • Poisoned / Bleeding β€” DoT variants from tools or weapon procs.
  • Regen β€” HoT from heal items or Druid passive.

Sources:

  • Player weapons with an element proc statuses on monsters (rate by rarity).
  • Monsters with fire/ice/lightning attack_damage_type roll a 25% base chance Γ— (1 βˆ’ resist/100) on a successful hit to inflict the matching status on the player. Lightning hits also arc to same-row allies β€” each ally rolls independently with their own resist.

Status pills render on every fighter and monster card with matching color

  • icon + turn-remaining suffix.

Visual feedback

  • Dust puffs burst from a fighter card on every landed hit (Wile-E- Coyote tan/brown clouds, drift up + out).
  • Particle bursts (Web Animations API) fire on element procs, hits, frozen / victory.
  • Hit flash + slash streak on the target card.
  • Toast when a status proc lands on the local player ("πŸ”₯ You're now burning! (3t)").

Position rows & multi-monster packs

Fighters occupy a front or back row. Multi-monster fights (bounty_pack, gauntlet waves) render a horizontal strip of MonsterCards β€” each one is individually targetable (click to mark, then /sq attack).


Town

The web dashboard renders Town as a hub of locations; Slack mirrors most of these as slash subcommands.

  • πŸͺ Shop β€” 5 AI-generated rotating items + always-in-stock staples (potions). Stock is channel-scoped and restocks every 6 hours. Haggle is a free action (per item, per cycle). Bards / Sages / Rogues get a bonus on the haggle d6.
  • πŸ”¨ Smithy β€” sharpens weapons (caps at SHARPEN_CAP per item) and repairs the armor pool to max for gold. Also sells rotating armor-only stock (channel-scoped, mirrors shop semantics). Sage's Foresee doesn't apply here β€” smithy items are open inventory.
  • πŸ›οΈ Inn β€” paid alternative to long rest. Skips the 24h cooldown by spending gold. Restores HP / mana / armor pool.
  • 🍺 Pub β€” buy drinks for between-quest combat buffs (Tavern Ale, Espresso, Whiskey, etc.). Capped at 2 drinks between quests; resets on joining/starting a quest. Pub also hosts a 1v1 Stone-Parchment-Dagger betting game.
  • πŸ“‹ Job Board β€” 3 posted-contract quests at any time. Accept one for a reward bonus at victory.
  • πŸ§ͺ Apothecary β€” buy consumables / scrolls / revive items at fixed prices. Inventory rotates daily.
  • 🌲 Outskirts (Hunt) β€” start a custom-tier hunt quest. Pick a tier and monster count.
  • πŸ’€ Rest β€” short rest (10-min cooldown, +50% missing HP) or long rest (24h cooldown, full HP + mana + armor pool). Mid-quest rest is blocked.

Dungeons (grid format)

Dungeon quests generate a small grid of rooms β€” entry, encounters, lockboxes, traps, NPCs, merchants, loot rooms, and a boss chamber. Navigate via the compass (web) or /sq move <dir> (Slack). All grid loot is AI-flavored at dungeon-start time (boss treasure, lockbox options, npc offers, merchant stock all get proper names + flavor blurbs).

Chests (two-step flow)

  1. Closed β€” chest shows lock tier; spend a matching key (bronze/silver/ gold) to open. Higher-tier keys open lower-tier locks. Staff Sages see a "Foresee" preview of contents while the chest is still closed (others see only "N mystery items await").
  2. Open β€” every party member can claim any unclaimed item. First click per slot wins; multiple players can claim from the same chest, or one player can grab everything.
  3. Resolved β€” auto-resolves when all items are claimed, or anyone can "Close chest" to walk away leaving spoils behind.

Traps

Each trap room offers three skill checks (STR / DEX / INT). Pass the d20 to bypass; fail to take 4 + level HP damage. Trap rewards vary by skill (INT pass restores mana, etc.).


Equipment & inventory

  • Slots: main_hand, off_hand, body, helmet, pants, boots, ring, amulet.
  • Weapon ranges: melee, ranged, focus. Focus weapons boost heal/shield rolls and grant +1 max mana while equipped (doesn't add to attack/cast damage).
  • Element-tagged weapons proc burning/frozen/shocked on monsters at a rarity-scaled rate.
  • Stat bonuses β€” gear can carry stat_bonus like {str: 2, resist_fire: 30}. Bonuses sum across equipped slots; resist values cap at 100% (immunity).
  • Inventory β€” /sq inventory lists items. /sq equip <id> / /sq unequip <id>, /sq give <id> @user (transfers as unequipped β€” and fires a 🎁 toast in the recipient's web dashboard).
  • Level gates β€” every drop carries a level_req (defaults to ceil(power / 3)). Surfaced in tile UIs so you don't claim something you can't equip.

XP & gold rewards

Victory rewards split using a two-pool formula:

  • 40% of the pool is split equally across alive party members (participation share).
  • 60% is proportional to each fighter's contribution score: damage_dealt + 0.75 Γ— healing_done + 0.5 Γ— shielding_done.

So a heavy damage-dealer still tops the table, but supports get a meaningful slice. Pool size scales with monster tier Γ— multipliers (boss Γ—2, elite Γ—1.5, party size bonus). Level-up rolls 1d6 to add max HP and refills the bar.

Loot drops roll independently per fighter at 35% + 5% per monster tier (cap 70%). Each drop rolls slot, rarity, and power; the AI generates the name + flavor.


Tools & scrolls (catalog items)

Curated single-shot offensive consumables with fixed names. Each maps item_name β†’ effect in packages/core/src/flavor.ts. The AI writes per-drop flavor text. Power rolls at create time and does not scale β€” buy a Caffeine Bomb at L1 and it stays L1-tier forever.

Type Item Effect
🧨 tool Caffeine Bomb 2 + tier dmg, ignores armor
πŸ”₯ tool Hotfix Grenade 6 + tierΓ—2 dmg, ignores armor
β˜• tool Espresso Shot Regen self for 5 turns
πŸ§ͺ tool Poison Vial / Venom Vial Apply poisoned status, 4 ticks
🩹 tool Regen Draft Self-regen, 3 turns
βš” tool Battle Elixir +25% damage, 3 turns
πŸ”„ scroll Rebase Scroll Free action: full party mana refill, no retaliation
πŸ’₯ scroll Production Outage Non-boss instakill / Boss βˆ’30% HP
πŸ— tool Crowbar of Last Resort Force-pick a dungeon lockbox

Notifications

Web dashboard fetches /api/notifications/pending on initial load and on every tab focus. Currently surfaces:

  • 🎁 item received β€” when another player gives you an item.

Notifications are stored in D1 (notifications table) and delete-on-read β€” each toast fires exactly once across tabs/devices.


File map

apps/slack/                  β€” Slack worker
  src/index.ts               β€” Hono entrypoint, signature verify, LobbyManager DO
  src/commands.ts            β€” slash sub-command dispatch + handlers
  src/ai.ts                  β€” Workers AI scene generation + parser
  src/slack.ts               β€” Slack Web API helpers

apps/web/                    β€” Web worker + SPA
  src/worker.ts              β€” Hono server, REST + WS endpoints, QuestRoom DO,
                               LobbyRoom DO
  src/App.tsx                β€” dashboard root, town, party, character, inventory
  src/CombatPage.tsx         β€” standalone combat (outskirts/boss/gauntlet/standard)
  src/GridDungeonView.tsx    β€” embedded combat + dungeon navigation (graph dungeons)
  src/CombatShared.tsx       β€” shared combat UI (HpBar, ItemPicker, HitDust, etc.)
  src/CombatParticles.tsx    β€” Web-Animations-API particle bursts
  src/LobbyView.tsx          β€” sliding lobby drawer with chat
  src/icons.tsx              β€” icon component + class portrait helpers
  src/main.tsx               β€” entrypoint + Toaster mount

packages/core/               β€” pure engine (no DB / no network)
  src/combat_machine.ts      β€” TurnAction β†’ CombatState reducer (`step()`)
  src/combat.ts              β€” legacy combat helpers (pre-engine paths)
  src/flavor.ts              β€” class table, signature/ability registry,
                               item catalog
  src/*.test.ts              β€” Vitest suite

packages/db/                 β€” D1 query helpers + shared types
  src/db.ts                  β€” characters, quests, lobby, party, inventory,
                               shop/smithy stock, notifications, …
  src/dungeon_grid.ts        β€” pure grid generator for dungeon variants

migrations/                  β€” D1 SQL migrations (numbered)

Setup

1. Install + create the D1 database

pnpm install
pnpm db:create

Copy the database_id Wrangler prints into apps/slack/wrangler.jsonc and apps/web/wrangler.jsonc (both workers share the same DB).

2. Apply migrations

pnpm db:migrate:local      # local development
pnpm db:migrate:remote     # against the deployed D1

3. Create the Slack app

Use this manifest at https://api.slack.com/apps?new_app=1 β†’ "From an app manifest". /sq is the convention but any unreserved trigger works β€” Slack reserves /dnd. The bot reads its own command name from the slash payload, so help text reflects whatever you choose.

display_information:
  name: Gantt Quest
features:
  bot_user:
    display_name: Gantt Quest
    always_online: true
  slash_commands:
    - command: /sq
      url: https://your-slack-worker.workers.dev/slack/commands
      description: Roll a character, start a quest, check your sheet
      usage_hint: roll | me | quest [elite] | help
      should_escape: false
  interactivity:
    is_enabled: true
    request_url: https://your-slack-worker.workers.dev/slack/interactions
oauth_config:
  scopes:
    bot:
      - chat:write
      - chat:write.public
      - commands
      - im:write           # required for cross-surface DMs
      - users:read         # required for invite lookups
settings:
  org_deploy_enabled: false
  socket_mode_enabled: false
  token_rotation_enabled: false

Install to your workspace, copy the bot token (xoxb-...) and signing secret.

4. Secrets

Both workers need their own secret set. For the Slack worker:

cd apps/slack
npx wrangler secret put SLACK_SIGNING_SECRET
npx wrangler secret put SLACK_BOT_TOKEN
npx wrangler secret put ALLOWED_CHANNEL_ID   # optional channel allowlist
npx wrangler secret put IMAGE_BASE_URL       # public URL of the deployed web worker

For the web worker:

cd apps/web
npx wrangler secret put SLACK_CLIENT_ID
npx wrangler secret put SLACK_CLIENT_SECRET
npx wrangler secret put SLACK_BOT_TOKEN      # for cross-surface notifications

The web worker uses Slack OAuth for sign-in; the bot token lets it post "Player joined from the web" messages to the quest thread.

5. DO bindings + cross-worker wiring

Both wrangler.jsonc files need their DO bindings configured:

  • apps/web/wrangler.jsonc registers QUEST_ROOM and LOBBY_ROOM as owned classes (new_sqlite_classes migrations).
  • apps/slack/wrangler.jsonc registers LOBBY_MANAGER as owned, plus cross-bound stubs for QUEST_ROOM and LOBBY_ROOM (script_name pointing at the web worker name).

Deploy the web worker first so the DO classes exist before the Slack worker tries to bind them.

6. Local dev environment vars

Two files control local dev behaviour. Both are gitignored β€” create them by hand after cloning.

apps/web/.dev.vars β€” injected into the web worker runtime by Wrangler / @cloudflare/vite-plugin. Also parsed by vite.config.ts at startup for build-time flags.

# apps/web/.dev.vars

# Gates all dev-only features in the web worker. When set to "local":
#   - POST /api/dev/login is enabled (powers the "Dev login" button)
#   - POST /api/dev/* tool endpoints (heal, mana, gold, revive, level,
#     cooldowns, combat-heal, combat-mana) are enabled
#   - Workers AI art generation is disabled (avoids "must run remotely" errors)
#   - hunt/shop/smithy fall back to a synthetic "local-dev" channel so they
#     work without a real Slack channel in the DB
# Set to "production" (or omit the file) to match deployed behaviour.
ENVIRONMENT=local

# Controls whether the Cloudflare Vite plugin uses remote CF bindings (AI,
# R2, etc.) or local Miniflare stubs. Read by vite.config.ts at Vite startup;
# has no effect on the worker runtime itself.
#   false β€” use local Miniflare stubs (default for local dev; AI won't work)
#   true  β€” call real Cloudflare services (requires auth; lets you test AI art)
# Set to "true" if you want to test AI art generation locally.
REMOTE_BINDINGS=false

apps/slack/.dev.vars β€” injected into the Slack worker runtime only.

# apps/slack/.dev.vars

# Same ENVIRONMENT flag as the web worker. When "local", no behaviour is
# currently gated in the Slack worker, but the flag is wired up for future
# use and keeps the two workers consistent.
ENVIRONMENT=local

# Public base URL where Slack fetches images (banners, monster art). Overrides
# the production URL hardcoded in wrangler.jsonc. Point at the local Vite dev
# server so image routes resolve during local development.
IMAGE_BASE_URL=http://localhost:5173

Production values live in the vars block of each wrangler.jsonc (ENVIRONMENT=production), so dev endpoints are always unreachable in deployed builds regardless of what .dev.vars says.

7. Run / deploy

Both workers must run simultaneously for cross-worker Durable Object bindings (QUEST_ROOM, LOBBY_ROOM) to connect. Wrangler's local dev registry wires them together automatically when both processes are up.

pnpm dev                   # Slack worker dev server
pnpm --filter web dev      # web worker dev server (Vite + Wrangler) β€” run alongside pnpm dev

For prod (CI handles this automatically β€” see below):

pnpm --filter web build    # build the SPA into apps/web/dist
pnpm --filter web deploy   # deploy the web worker (serves built assets)
pnpm deploy                # deploy the Slack worker

Important: always build the web SPA before deploying the web worker. A stale dist/ ships old UI with new server code β€” a frequent regression source.

8. CI/CD (GitHub Actions)

Two workflows in .github/workflows/ run automatically:

  • ci.yml β€” runs pnpm test (Vitest) on every pull request. PRs must pass before merging.
  • deploy.yml β€” triggers on every push to main. Deploys the web worker first, then the Slack worker (order matters so DO classes exist before the Slack worker binds them). Wrangler configs are injected from GitHub Secrets (WRANGLER_WEB_CONFIG, WRANGLER_SLACK_CONFIG, CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID) β€” no config files are committed.

Optional: rename the bot

BOT_NAME env var (in apps/slack/wrangler.jsonc vars block, or via npx wrangler secret put BOT_NAME). Defaults to "Gantt Quest". Used in /sq help, /sq rules, and channel-restriction error messages.


Tests

pnpm test                  # one-shot Vitest across packages
pnpm test:watch            # core package only, watch mode
pnpm typecheck             # tsc across all packages

Engine tests (packages/core/src/*.test.ts) cover the deterministic math: combat damage, dice rolls, drop tables, class lookup, scar generation, shop pricing, status effect ticking, status proc rates, position modifiers, critical hit rules, and the full step() reducer for each action kind.

Engine code is the source of truth β€” anything mechanical lives there and is covered by tests. Network / DO / AI code is intentionally untested; the engine surface is small enough that integration bugs are rare and obvious.


Credits

Icons come from game-icons.net β€” a CC BY 3.0 library of monochrome SVGs by Lorc, Delapouite, and others. They power the entire combat UI (weapon icons, status effect pills, slot indicators, class portraits' fallback glyphs, dungeon room markers, etc.).

The relevant set is gitignored under lib/icons/ per project convention; to use a new icon, copy the SVG into apps/web/public/icons/ and register the name in apps/web/src/icons.tsx.

About

A Slack bot that runs a comedic, engineering-themed mini dungeon-crawl in a single channel. Cloudflare Workers + D1 + Workers AI.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages