div {
+ max-width: 80ch;
+}
+
.mystery-transcript .system {
color: var(--m-accent-1);
font-weight: bold;
@@ -247,6 +251,7 @@ body {
}
.mystery-transcript .help {
+ position: relative;
color: var(--m-fg);
font-weight: normal;
border: 1px var(--m-divider-style) var(--m-dim);
@@ -254,6 +259,34 @@ body {
margin: 0.75em 0;
}
+.mystery-transcript .help .mystery-help-body {
+ padding-right: 3ch;
+}
+
+.mystery-transcript .help .mystery-help-close {
+ position: absolute;
+ top: 6px;
+ right: 6px;
+ width: 24px;
+ height: 24px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: transparent;
+ color: var(--m-fg);
+ border: 1px solid var(--m-dim);
+ border-radius: 2px;
+ font: inherit;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.mystery-transcript .help .mystery-help-close:hover,
+.mystery-transcript .help .mystery-help-close:focus-visible {
+ border-color: var(--m-fg);
+ outline: none;
+}
+
.mystery-transcript .player {
color: var(--m-accent-2);
}
@@ -331,14 +364,71 @@ body {
50%, 100% { opacity: 0; }
}
+.mystery-controls {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 14px;
+ position: relative;
+ z-index: 2;
+ margin-top: 8px;
+}
+
.mystery-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 6px 0 4px;
- position: relative;
- z-index: 2;
- margin-top: 8px;
+ min-width: 0;
+ flex: 1 1 auto;
+}
+
+.mystery-light-meter {
+ flex: 0 0 auto;
+ width: 98px;
+ min-height: 58px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 4px;
+ color: var(--m-fg);
+ opacity: 0.8;
+}
+
+.mystery-light-meter[data-lit='true'] {
+ color: var(--m-fg);
+ opacity: 1;
+}
+
+.mystery-light-icon {
+ width: 30px;
+ height: 30px;
+ display: block;
+ object-fit: contain;
+ filter: drop-shadow(0 0 2px currentColor);
+}
+
+.mystery-light-leds {
+ width: 100%;
+ display: flex;
+ gap: 4px;
+ justify-content: center;
+}
+
+.mystery-light-segment {
+ width: 8px;
+ height: 8px;
+ border-radius: 999px;
+ background: var(--m-dim);
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15) inset;
+ opacity: 0.45;
+}
+
+.mystery-light-segment[data-segment-state='lit'] {
+ background: currentColor;
+ opacity: 1;
+ box-shadow: 0 0 7px currentColor;
}
.mystery-chip {
@@ -383,6 +473,19 @@ body {
.mystery-chip {
padding: 4px 7px;
}
+
+ .mystery-controls {
+ gap: 10px;
+ }
+
+ .mystery-light-meter {
+ width: 90px;
+ }
+
+ .mystery-light-icon {
+ width: 27px;
+ height: 27px;
+ }
}
[data-mystery-input].ended {
diff --git a/src/ui/terminal.ts b/src/ui/terminal.ts
index 0745395..7ba0808 100644
--- a/src/ui/terminal.ts
+++ b/src/ui/terminal.ts
@@ -1,6 +1,6 @@
import { parse } from '../engine/parser'
import type { ParserContext } from '../engine/parser'
-import { dispatch, initialStateFor, getItemsInRoom } from '../engine/dispatcher'
+import { dispatch, initialStateFor, getItemsInRoom, getLightStatus, LIGHT_TURNS_MAX } from '../engine/dispatcher'
import { saveState, loadState, clearSave } from '../engine/save'
import { world } from '../world'
import type { GameState, TranscriptLine } from '../engine/types'
@@ -11,6 +11,8 @@ import { renderChips } from './chip-render'
const transcriptEl = document.querySelector('[data-mystery-transcript]')
const inputEl = document.querySelector('[data-mystery-input]')
const inputDisplayEl = document.querySelector('[data-mystery-input-display]')
+const lightMeterEl = document.querySelector('[data-mystery-light-meter]')
+const LIGHT_ICON_URL = new URL('../assets/noun-oil-lamp-8301660.svg', import.meta.url).href
const HELP_TEXT = `You arrive at the address, but you do not remember what has happened. The road behind you is gone...
@@ -43,6 +45,40 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
let historyDraft = ''
let idleHintTimer: number | null = null
+ const syncLightMeter = (): void => {
+ if (!lightMeterEl) return
+ const status = getLightStatus(state, world)
+ lightMeterEl.hidden = !status
+ if (!status) {
+ lightMeterEl.innerHTML = ''
+ lightMeterEl.dataset['lit'] = 'false'
+ lightMeterEl.dataset['turnsLeft'] = '0'
+ return
+ }
+
+ lightMeterEl.innerHTML = ''
+ lightMeterEl.dataset['lit'] = 'true'
+ lightMeterEl.dataset['turnsLeft'] = String(status.turnsLeft)
+
+ const icon = document.createElement('img')
+ icon.className = 'mystery-light-icon'
+ icon.src = LIGHT_ICON_URL
+ icon.alt = ''
+ icon.setAttribute('aria-hidden', 'true')
+ lightMeterEl.appendChild(icon)
+
+ const leds = document.createElement('div')
+ leds.className = 'mystery-light-leds'
+ const turnsLeft = Math.max(0, Math.min(LIGHT_TURNS_MAX, status.turnsLeft))
+ for (let i = 0; i < LIGHT_TURNS_MAX; i++) {
+ const segment = document.createElement('span')
+ segment.className = 'mystery-light-segment'
+ segment.dataset['segmentState'] = i < turnsLeft ? 'lit' : 'dim'
+ leds.appendChild(segment)
+ }
+ lightMeterEl.appendChild(leds)
+ }
+
if (!restored) {
// Fresh state already includes the opening narration in its transcript.
} else if (restored.transcript.length === 0) {
@@ -150,13 +186,43 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
clearTransientHelp()
const el = document.createElement('div')
el.className = 'system help'
- el.dataset.transientHelp = 'true'
- el.textContent = HELP_TEXT
+ el.dataset['transientHelp'] = 'true'
+
+ const close = document.createElement('button')
+ close.type = 'button'
+ close.className = 'mystery-help-close'
+ close.dataset['helpClose'] = 'true'
+ close.setAttribute('aria-label', 'Close help')
+ close.textContent = 'x'
+ close.addEventListener('click', (e) => {
+ e.stopPropagation()
+ clearTransientHelp()
+ return
+ })
+
+ const text = document.createElement('div')
+ text.className = 'mystery-help-body'
+ text.textContent = HELP_TEXT
+ el.append(close, text)
transcriptEl.appendChild(el)
transientHelpEl = el
transcriptEl.scrollTop = transcriptEl.scrollHeight
}
+ document.addEventListener('pointerdown', (e) => {
+ if (!transientHelpEl) return
+ const target = e.target as Node | null
+ if (target && transientHelpEl.contains(target)) return
+ clearTransientHelp()
+ })
+
+ const hideHelpOnInput = (): void => {
+ if (!transientHelpEl) return
+ window.setTimeout(() => {
+ if (inputEl.value.trim().length > 0) clearTransientHelp()
+ })
+ }
+
// For UI-originated lines (player input, restart/undo/quit messages, error
// notices). Pushes into state.transcript so they survive reload, then renders.
// Engine-originated lines (from dispatch) are already in state.transcript;
@@ -180,11 +246,13 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
renderAll(state.transcript)
saveState(state)
refreshChips()
+ syncLightMeter()
syncEndedUI()
}
renderAll(state.transcript)
refreshChips()
+ syncLightMeter()
syncEndedUI()
syncCommandLine()
scheduleIdleHint()
@@ -246,6 +314,7 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
appendLines([{ kind: 'system', text: '(undone)' }])
saveState(state)
refreshChips()
+ syncLightMeter()
syncEndedUI()
} else {
appendLines([{ kind: 'system', text: 'There is no further back.' }])
@@ -272,6 +341,7 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
document.dispatchEvent(new CustomEvent('halfstreet-toggle-theme'))
}
refreshChips()
+ syncLightMeter()
syncEndedUI()
} catch (err) {
console.error('[halfstreet] dispatch error', err)
@@ -301,10 +371,16 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
return
}
if (e.key === 'Escape') {
+ if (transientHelpEl) {
+ e.preventDefault()
+ clearTransientHelp()
+ return
+ }
saveState(state)
window.location.href = '/'
}
})
document.addEventListener('halfstreet-restart', restart)
+ inputEl.addEventListener('input', hideHelpOnInput)
}
diff --git a/src/world/TODOs.md b/src/world/TODOs.md
index cef09f1..d3962b9 100644
--- a/src/world/TODOs.md
+++ b/src/world/TODOs.md
@@ -1,18 +1,12 @@
- [x] Need to add help text when user types "help". This should give them some common commands and explain the concepts behind text adventure games. It should also include an exceprt at the beginning from the opening text. "You arrive at the address, but you do not remember what has happened. The road behind you is gone...". The help text should disappear after the user types a new prompt (i.e., it's not persistent).
- [x] Need to add the tiles from mobile to desktop view.
- [x] Enhance tiles with contextual awareness, enabling tiles to appear in rooms when appropriate (e.g. "attack rat").
-- [ ] Create a mechanic that asks "Are you sure?" before taking critical actions like attacking or other game-changing mechanics that might affect the final ending.
- [x] Add a tile for USE
-- [ ] Add contextual awareness and autocomplete. For example a popup that appears above the USE text or when a user types "use". The user is able to autofill the rest of the thing by using the keyboard to toggle through the inv list and tab-complete the option, (or tap on mobile) "e.g. use (matches, light, letter) *on* (lamp)" - the word "on" there being suggested. Suggestions for autocomplete are in italics. This is one modern design element we're going to add.
- [x] Add a footer with "By [Ethan J Lewis](https://ethanjlewis.com) | [Source Code](https://half.st/ejlewis/halfstreet) | [GNU General Public License v3.0](https://half.st/ejlewis/halfstreet/src/branch/main/LICENSE)"
- [x] Add "Half Street" as ASCII Art to the intro text.
- [x] Add logic to make the last sentence in the examined description conditional. This is where we'll list items in the room. (e.g., "The hallway runs further than the house should be wide. The dust on the floor is undisturbed except where you have walked. *The oil lamp is on the side table.*")
-- [ ] Create a new item with a mechanic: whiskey bottle. When the user drinks it they get drunk and are transported to the "drunk rooms" which are a bit of a maze and things get a little topsy-turvy. The player chances to lose an item (returning to its original spot) when they get drunk and wakes up several turns later somewhere else predetermined.
-- [ ] Add lightened descriptions to darkened rooms. About half the rooms should be too dark to see anything (affects ability to move forward, can't see exits or entounters, except for maybe hints at the encounters, like sounds or shapes in the dark) Add frontmatter property to all rooms: (dark: true/false). Make text in darkened rooms a grey color.
- [x] Fix mobile - scrolling issue (page grows as the terminal grows).
- [x] Fix mobile - ascii text art at beginning too big to render
-- [ ] Implement a simple "stealth mechanic", where sometimes it's advantageous to have the light out.
-- [ ] Implement a simple (optional?) minimap in the UI? - Maybe tied to an item? Once you get the map the minimap appears? Can we POC it?
- [x] Feature: Ability to retain console history, e.g., scroll through previous commands with up and down arrows.
- [x] Feature: Grey italicized "type here..." text that appears near the terminal if the user doesn't click into the terminal within 30 seconds of entering the game or click the help button. The text disappears once a user clicks in the terminal, or selects a card.
- [x] Change footer to "Copyright (C) 2026 [Ethan J Lewis](https://ethanjlewis.com) | [GNU 3.0](https://half.st/ejlewis/halfstreet/src/branch/main/LICENSE)| [Source Code](https://half.st/ejlewis/halfstreet)"
@@ -27,9 +21,19 @@
- [x] Feature: Add "Restart" option to option menu
- [x] Bug: gear icon is still wayyyyyyy toooo smallllll it needs to be like 4x larger at least.
- [x] Add a "wait" tile.
-- [ ] Add a mechanic where after the player waits 3 times or moves six times the light goes out and needs to be relit.
-- [ ] Add attack options for most encounters. Rarely this will be a good idea though.
-- [ ] Add a failure condition for attacking at the wrong time. Make the reasons for the failure condition contextual, for example, when they attack the stair sleeper they might trip on the stair and get injured.
- [x] If the user says "light match" or "light match" the response should be "use match with what?"
- [x] If the user says "use match with letter" they should burn the letter.
- [x] There should be a lighter in the smoking room that allows unlimited lighting.
+- [ ] Create a mechanic that asks "Are you sure?" before taking critical actions like attacking or other game-changing mechanics that might affect the final ending.
+- [ ] Create a new item with a mechanic: whiskey bottle. When the user drinks it they get drunk and are transported to the "drunk rooms" which are a bit of a maze and things get a little topsy-turvy. The player chances to lose an item (returning to its original spot) when they get drunk and wakes up several turns later somewhere else predetermined.
+- [ ] Add lightened descriptions to darkened rooms. About half the rooms should be too dark to see anything (affects ability to move forward, can't see exits or entounters, except for maybe hints at the encounters, like sounds or shapes in the dark) Add frontmatter property to all rooms: (dark: true/false). Make text in darkened rooms a grey color.
+- [ ] Implement a simple "stealth mechanic", where sometimes it's advantageous to have the light out.
+- [ ] Implement a simple (optional?) minimap in the UI? - Maybe tied to an item? Once you get the map the minimap appears? Can we POC it?
+- [x] Add a mechanic where after the player waits 3 times or moves six times the light goes out and needs to be relit. Or something along those lines. We need a sense of time. Maybe some situations blow out the light
+- [ ] Add attack options for most encounters. Rarely this will be a good idea though.
+- [ ] Add a failure condition for attacking at the wrong time. Make the reasons for the failure condition contextual, for example, when they attack the stair sleeper they might trip on the stair and get injured.
+- [ ] Add contextual awareness and autocomplete. For example a popup that appears above the USE text or when a user types "use". The user is able to autofill the rest of the thing by using the keyboard to toggle through the inv list and tab-complete the option, (or tap on mobile) "e.g. use (matches, light, letter) *on* (lamp)" - the word "on" there being suggested. Suggestions for autocomplete are in italics. This is one modern design element we're going to add.
+- [ ] Add a Notebook function. Automatically make notes as the game progresses.
+- [ ] Implement a carry mechanic. Decide whether we should have a limited carry ability (only able to carry a few things?) or we night need a full inventory system, where items are assigned to pockets or hand carry and we can only hand carry a couple of items?
+- [ ] Implement a "drop" mechanic
+- [x] We need a light indicator that shows when the light is lit and how much time is left on the light. Use the svg file I dropped in the src/assets folder for the indicator. The indicator should be a 6-segment led that runs in a dotted line underneath the light indicator and burns out right to left. The color of the indicator should be bright when it's lit and dim when it's not. The indicator should be to the right of the tiles and sized appropriately.
diff --git a/src/world/buildWorld.test.ts b/src/world/buildWorld.test.ts
index 608993f..bc1562b 100644
--- a/src/world/buildWorld.test.ts
+++ b/src/world/buildWorld.test.ts
@@ -51,6 +51,24 @@ describe('assembled world', () => {
}
})
+ it('hallway prose names every enabled exit', () => {
+ const hallway = world.rooms['hallway']
+ expect(hallway).toBeDefined()
+ if (!hallway) throw new Error('hallway room is missing')
+ expect(hallway.exits).toEqual({
+ n: 'dining-room',
+ s: 'foyer',
+ e: 'cellar-stair',
+ w: 'smoking-room',
+ u: 'parlor',
+ d: 'music-room',
+ })
+ const prose = `${hallway.descriptions.firstVisit}\n${hallway.descriptions.examined}`.toLowerCase()
+ for (const word of ['north', 'south', 'east', 'west', 'up', 'down']) {
+ expect(prose, `hallway prose should mention ${word}`).toContain(word)
+ }
+ })
+
it('all room item refs resolve to known items', () => {
for (const room of Object.values(world.rooms)) {
for (const itemId of room.items) {
diff --git a/src/world/rooms/hallway.md b/src/world/rooms/hallway.md
index c4e478d..443086f 100644
--- a/src/world/rooms/hallway.md
+++ b/src/world/rooms/hallway.md
@@ -13,10 +13,12 @@ encounter: null
---
## first-visit
-A long hallway, lit by nothing. It runs further than the house should allow. An iron oil lamp sits on a side table. The foyer is south. A stair descends east.
+A long hallway, lit by nothing. It runs further than the house should allow. An iron oil lamp sits on a side table.
+
+The foyer is south. A dining room waits north, a cellar stair descends east, and a smoking room opens west. Two wrong thresholds also present themselves: one climbing up toward a parlor, one dropping down toward music.
## revisit
The long hallway. It has not shortened.
## examined
-The hallway runs further than the house should be wide. The dust on the floor is undisturbed except where you have walked. Doors wait north and west. Two more thresholds sit where the wall should be solid.
+The hallway runs further than the house should be wide. The dust on the floor is undisturbed except where you have walked. The foyer is south. Doors wait north and west. The cellar stair is east. Two more thresholds sit where the wall should be solid: up toward a parlor, and down toward music.