diff --git a/package-lock.json b/package-lock.json
index 1b249bf..32bcb48 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.1.0",
"license": "GPL-3.0-or-later",
"dependencies": {
+ "@sentry/browser": "^10.53.1",
"astro": "^6.1.9",
"yaml": "^2.8.4",
"zod": "^4.4.3"
@@ -1766,6 +1767,81 @@
"win32"
]
},
+ "node_modules/@sentry-internal/browser-utils": {
+ "version": "10.53.1",
+ "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.53.1.tgz",
+ "integrity": "sha512-X4d6y8sBMjmNhcDW4eMBU3ASsNIMz8dqaFkhyIMN/dkYr/yZKnbRZPaVuVUGvHKjnlficPpIH0/HK9KBjrYxPw==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/core": "10.53.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry-internal/feedback": {
+ "version": "10.53.1",
+ "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.53.1.tgz",
+ "integrity": "sha512-vVpTI/aEYN5d9IgZeYJWMqVaN0+iFgidSrYNAsZTh1US5sJUzF/wrl+68KdpmCtFROrN3jiAn1oPSwL5CKvEJA==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/core": "10.53.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry-internal/replay": {
+ "version": "10.53.1",
+ "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.53.1.tgz",
+ "integrity": "sha512-wZNzTBYkgGUPWMuUQv7L64+OJmoCnz7GQNiTrTFK6EVAjJXFBCSsPp/nhif0bLhbk8+0g4xz633uOhpXuQbFdw==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry-internal/browser-utils": "10.53.1",
+ "@sentry/core": "10.53.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry-internal/replay-canvas": {
+ "version": "10.53.1",
+ "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.53.1.tgz",
+ "integrity": "sha512-aueLaf/2prExwA76BGU5/bOXCKWqtt6jQXWA6WJQNrmKpPEtZJB4ypnpsou0McXQCF8tur2Y8U0TEkwQP13yJQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry-internal/replay": "10.53.1",
+ "@sentry/core": "10.53.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry/browser": {
+ "version": "10.53.1",
+ "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.53.1.tgz",
+ "integrity": "sha512-zXF373hzUOGzUOrqd8xb1U3LQi5uYC3mwv+z5OMKUUinQlu30tTWBs7ypy6YTchtix9QlYaHWlayUF8vBZ5UjA==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry-internal/browser-utils": "10.53.1",
+ "@sentry-internal/feedback": "10.53.1",
+ "@sentry-internal/replay": "10.53.1",
+ "@sentry-internal/replay-canvas": "10.53.1",
+ "@sentry/core": "10.53.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry/core": {
+ "version": "10.53.1",
+ "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.53.1.tgz",
+ "integrity": "sha512-XG4ezlkyuAPjBC5+9kXC94rXXuqYTw9NRhfaDHssbTFaGnqBR8vQX2UUgZfY7ucbeelRDGfBu1sywoU+mB04uA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@shikijs/core": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.2.tgz",
diff --git a/package.json b/package.json
index 83da3f8..f585800 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
"test:watch": "vitest"
},
"dependencies": {
+ "@sentry/browser": "^10.53.1",
"astro": "^6.1.9",
"yaml": "^2.8.4",
"zod": "^4.4.3"
diff --git a/src/pages/index.astro b/src/pages/index.astro
index bd8736b..d3c5f17 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -7,6 +7,10 @@ const ui = world.ui
const footerLinks = ui?.footer.links ?? []
const firstFooterLink = footerLinks[0]
const remainingFooterLinks = footerLinks.slice(1)
+const bugReport = ui?.bugReport
+const bugpinConfig = bugReport?.enabled ? bugReport.bugpin : undefined
+const bugsinkDsn =
+ bugReport?.enabled && bugReport.bugsink?.enabled ? bugReport.bugsink.dsn : ''
---
@@ -23,7 +27,7 @@ const remainingFooterLinks = footerLinks.slice(1)
-
+
@@ -106,6 +110,20 @@ const remainingFooterLinks = footerLinks.slice(1)
{link.label}
>
))}
+ {bugpinConfig && (
+ <>
+
|
+
+
+ >
+ )}
diff --git a/src/ui/bug-report.ts b/src/ui/bug-report.ts
new file mode 100644
index 0000000..a4da374
--- /dev/null
+++ b/src/ui/bug-report.ts
@@ -0,0 +1,126 @@
+interface BugPinAPI {
+ init: (config: { apiKey: string, serverUrl: string }) => Promise
| void
+ open: () => void
+ close: () => void
+}
+
+declare global {
+ interface Window {
+ BugPin?: BugPinAPI
+ }
+}
+
+const launcherSelector = [
+ '.fixed.bottom-5.right-5',
+ '.fixed.bottom-5.left-5',
+ '.fixed.top-5.right-5',
+ '.fixed.top-5.left-5',
+].join(',')
+const button = document.querySelector('[data-bug-report-trigger]')
+let loadPromise: Promise | null = null
+let initialized = false
+let launcherObserver: MutationObserver | null = null
+
+function setStatus(message: string): void {
+ const targetId = button?.getAttribute('aria-describedby')
+ const target = targetId ? document.getElementById(targetId) : null
+ if (target) target.textContent = message
+}
+
+function buildWidgetSrc(serverUrl: string): string {
+ return `${serverUrl.replace(/\/$/, '')}/widget.js`
+}
+
+function loadWidget(serverUrl: string): Promise {
+ if (window.BugPin) return Promise.resolve()
+ if (loadPromise) return loadPromise
+
+ loadPromise = new Promise((resolve, reject) => {
+ const script = document.createElement('script')
+ script.src = buildWidgetSrc(serverUrl)
+ script.async = true
+ script.addEventListener('load', () => resolve())
+ script.addEventListener('error', () => {
+ loadPromise = null
+ reject(new Error(`Failed to load BugPin widget from ${script.src}`))
+ })
+ document.head.appendChild(script)
+ })
+
+ return loadPromise
+}
+
+function hideBugPinLauncher(): void {
+ const host = document.getElementById('bugpin-widget')
+ const root = host?.shadowRoot
+ if (!root) return
+
+ for (const launcher of root.querySelectorAll(launcherSelector)) {
+ launcher.hidden = true
+ launcher.style.display = 'none'
+ launcher.setAttribute('aria-hidden', 'true')
+ }
+}
+
+function watchBugPinLauncher(): void {
+ const host = document.getElementById('bugpin-widget')
+ const root = host?.shadowRoot
+ if (!root || launcherObserver) return
+
+ hideBugPinLauncher()
+ launcherObserver = new MutationObserver(() => hideBugPinLauncher())
+ launcherObserver.observe(root, { childList: true, subtree: true })
+}
+
+function waitForBugPinEffects(): Promise {
+ return new Promise((resolve) => {
+ requestAnimationFrame(() => setTimeout(resolve, 0))
+ })
+}
+
+async function openBugReport(serverUrl: string, apiKey: string): Promise {
+ await loadWidget(serverUrl)
+
+ if (!window.BugPin) {
+ throw new Error('BugPin widget loaded, but window.BugPin is unavailable')
+ }
+
+ if (!initialized) {
+ await window.BugPin.init({ apiKey, serverUrl })
+ initialized = true
+ watchBugPinLauncher()
+ await waitForBugPinEffects()
+ }
+
+ hideBugPinLauncher()
+ window.BugPin.open()
+ hideBugPinLauncher()
+}
+
+if (button) {
+ const serverUrl = button.dataset.bugpinServer
+ const apiKey = button.dataset.bugpinKey
+
+ if (serverUrl && apiKey) {
+ button.addEventListener('click', async (event) => {
+ event.preventDefault()
+ setStatus('')
+ button.disabled = true
+
+ try {
+ await openBugReport(serverUrl, apiKey)
+ } catch (err) {
+ console.error('[bug-report]', err)
+ setStatus("Couldn't open bug reporter - try refreshing.")
+ } finally {
+ button.disabled = false
+ }
+ })
+ }
+}
+
+if (window.BugPin) {
+ requestAnimationFrame(() => watchBugPinLauncher())
+}
+
+export {}
diff --git a/src/ui/crt.css b/src/ui/crt.css
index 93bfa40..a1fd381 100644
--- a/src/ui/crt.css
+++ b/src/ui/crt.css
@@ -89,11 +89,35 @@ body {
text-align: center;
}
-.mystery-footer a {
+.mystery-footer a,
+.mystery-footer-bug-report {
color: var(--m-fg);
text-decoration: none;
}
+.mystery-footer-bug-report {
+ background: none;
+ border: none;
+ padding: 0;
+ margin: 0;
+ font: inherit;
+ cursor: pointer;
+}
+
+.mystery-footer-bug-report:hover,
+.mystery-footer-bug-report:focus-visible {
+ text-decoration: underline;
+}
+
+.mystery-footer-bug-report:disabled {
+ cursor: progress;
+ opacity: 0.55;
+}
+
+.mystery-footer-status {
+ color: var(--m-fg);
+}
+
.mystery-footer span {
margin: 0 0.5ch;
}
diff --git a/src/ui/error-tracking.ts b/src/ui/error-tracking.ts
new file mode 100644
index 0000000..3d71d3a
--- /dev/null
+++ b/src/ui/error-tracking.ts
@@ -0,0 +1,12 @@
+import * as Sentry from '@sentry/browser'
+
+const dsn = document.body?.dataset.bugsinkDsn ?? ''
+
+if (dsn) {
+ Sentry.init({
+ dsn,
+ tracesSampleRate: 0,
+ replaysSessionSampleRate: 0,
+ replaysOnErrorSampleRate: 0,
+ })
+}
diff --git a/src/world/TODOs.md b/src/world/TODOs.md
index 8c2965b..d472fea 100644
--- a/src/world/TODOs.md
+++ b/src/world/TODOs.md
@@ -42,7 +42,7 @@
- [ ] FEATURE: Add item Revolver, which can be used to kill the thing at the end. It comes with no bullets, so players need to defeat the safe-cracking minjgame. Place the revolver somewhere it takes the player some effort to find.
- [ ] Feature: A faceless voice that speaks in a whisper in the smoking room, which demands whiskey and dispenses riddles if the player finds a bottle of whiskey (but hasn't drunk it yet). The riddles are randomly chosen from a list of 25 difficult riddles (source them). If the riddle is answered correctly there needs to be some reward and maybe a major plot point is revealed.
- [x] Add item Whiskey bottle, half full of something smoky. In the kitchen. Drinking it gets the player drunk, which causes them to unlock the drunk rooms, which are a series of rooms where they can go many directions, but somehow end up in seemingly back in the same spot. They are essentially a maze of doors and hallways, ladders and levels. There should be boundaries established though, it shouldn't be endless. After 20 or so moves the player passes out and wakes up back in the foyer, with the whiskey bottle returned to the kitchen, somehow still half full. Add a random encounter in the dark rooms, a creaking floorboard helps you find a secret door that opens with a faceless man inside who gives you major plot info. After this encounter you pass out and wake up in the lobby as before. When the player is "drunk" the text on the terminal should appear blurry, like the user has double vision.
-- [ ] Set up BugPin as a self-hosted visual bug reporter for the site, then have incoming reports create markdown files under `src/world/bugs/` via a webhook or API bridge so bugs can be tracked in git alongside the game content. Include screenshot/annotation metadata in the markdown and decide whether these bug docs stay outside the world loader or get their own loader later.
+- [ ] Add a "Report a Bug" footer link backed by Bugpin (widget at bugpin.half.st -> forwards to GitHub Issues). Add Bugsink (@sentry/browser -> bugsink.half.st) for automatic JS error capture. Mark complete after manual verification: a test report appears in the Bugpin portal AND a GitHub issue is created AND a thrown error appears in the Bugsink portal.
- [ ] BUG: It says the door closes behind you when you enter the lobby, but you can still exit S to the gate.
- [x] FEATURE: Add a short "typed" effect to the text. Make it look like it's being typed out, if that makes sense, one character at a time. The effect should be brief.
- [x] FEATURE: Whenever you change rooms, scroll the text so the name of the room you're in is at the top. Users can scroll up to see the history. This should be an effect where the old text slides up to make room for the new text, and this should happen before the "typed" effect.
diff --git a/src/world/schema.test.ts b/src/world/schema.test.ts
index d28222e..5e484d1 100644
--- a/src/world/schema.test.ts
+++ b/src/world/schema.test.ts
@@ -117,6 +117,65 @@ describe('uiFrontmatterSchema', () => {
}
expect(() => uiFrontmatterSchema.parse(data)).toThrow()
})
+
+ it('accepts a bugReport block with bugpin and bugsink subconfigs', () => {
+ const data = {
+ pageTitle: 'Halfstreet',
+ description: 'A gothic mystery.',
+ footer: {
+ copyright: '© 2026 Ethan J Lewis',
+ links: [],
+ },
+ bugReport: {
+ enabled: true,
+ label: 'Report a Bug',
+ bugpin: {
+ serverUrl: 'https://bugpin.half.st',
+ apiKey: 'proj_07df4bf91f12445b8ef8c723e865ed7b',
+ },
+ bugsink: {
+ enabled: true,
+ dsn: 'https://231ef18b6b4f426ca249778cfddf821c@bugsink.half.st/1',
+ },
+ },
+ }
+ expect(() => uiFrontmatterSchema.parse(data)).not.toThrow()
+ })
+
+ it('accepts ui config with no bugReport block at all', () => {
+ const data = {
+ pageTitle: 'Halfstreet',
+ description: 'A gothic mystery.',
+ footer: { copyright: '© 2026 Ethan J Lewis', links: [] },
+ }
+ expect(() => uiFrontmatterSchema.parse(data)).not.toThrow()
+ })
+
+ it('rejects a bugpin block with a non-url serverUrl', () => {
+ const data = {
+ pageTitle: 'Halfstreet',
+ description: 'A gothic mystery.',
+ footer: { copyright: '© 2026 Ethan J Lewis', links: [] },
+ bugReport: {
+ enabled: true,
+ bugpin: { serverUrl: 'not-a-url', apiKey: 'proj_x' },
+ },
+ }
+ expect(() => uiFrontmatterSchema.parse(data)).toThrow()
+ })
+
+ it('rejects a bugsink block with a non-url dsn', () => {
+ const data = {
+ pageTitle: 'Halfstreet',
+ description: 'A gothic mystery.',
+ footer: { copyright: '© 2026 Ethan J Lewis', links: [] },
+ bugReport: {
+ enabled: true,
+ bugsink: { dsn: 'whatever' },
+ },
+ }
+ expect(() => uiFrontmatterSchema.parse(data)).toThrow()
+ })
})
describe('lightMechanicFrontmatterSchema', () => {
diff --git a/src/world/schema.ts b/src/world/schema.ts
index 927332d..ccf2f5b 100644
--- a/src/world/schema.ts
+++ b/src/world/schema.ts
@@ -71,6 +71,18 @@ export const uiFrontmatterSchema = z.object({
typedEffect: true,
roomScroll: true,
}),
+ bugReport: z.object({
+ enabled: z.boolean().default(false),
+ label: z.string().trim().min(1).default('Report a Bug'),
+ bugpin: z.object({
+ serverUrl: z.url(),
+ apiKey: z.string().trim().min(1),
+ }).optional(),
+ bugsink: z.object({
+ enabled: z.boolean().default(true),
+ dsn: z.url(),
+ }).optional(),
+ }).optional(),
})
export type UiFrontmatter = z.infer
diff --git a/src/world/types.ts b/src/world/types.ts
index f5ab155..744af87 100644
--- a/src/world/types.ts
+++ b/src/world/types.ts
@@ -82,6 +82,18 @@ export interface UiConfig {
typedEffect: boolean
roomScroll: boolean
}
+ bugReport?: {
+ enabled: boolean
+ label: string
+ bugpin?: {
+ serverUrl: string
+ apiKey: string
+ }
+ bugsink?: {
+ enabled: boolean
+ dsn: string
+ }
+ }
}
export type LightBurnTrigger = 'move' | 'wait'
diff --git a/src/world/ui.md b/src/world/ui.md
index b14c70e..f526e06 100644
--- a/src/world/ui.md
+++ b/src/world/ui.md
@@ -18,6 +18,15 @@ features:
lightMeter: true
typedEffect: true
roomScroll: true
+bugReport:
+ enabled: true
+ label: "Report a Bug"
+ bugpin:
+ serverUrl: "https://bugpin.half.st"
+ apiKey: "proj_07df4bf91f12445b8ef8c723e865ed7b"
+ bugsink:
+ enabled: true
+ dsn: "https://231ef18b6b4f426ca249778cfddf821c@bugsink.half.st/1"
---
# UI