diff --git a/client/index.html b/client/index.html
index 32eece7..20e2c5b 100644
--- a/client/index.html
+++ b/client/index.html
@@ -29,7 +29,7 @@
height="600"
tabindex="0"
class="hidden"
- aria-label="Chat Grid, press arrows to move."
+ aria-label="Chat Grid, press question mark for help."
>
diff --git a/client/public/help.json b/client/public/help.json
index cb9afe3..595e86f 100644
--- a/client/public/help.json
+++ b/client/public/help.json
@@ -4,6 +4,7 @@
"title": "Movement",
"items": [
{ "keys": "Arrow Keys", "description": "Move" },
+ { "keys": "Question Mark", "description": "Open help viewer" },
{ "keys": "C", "description": "Speak coordinates" },
{ "keys": "Escape", "description": "Disconnect/cancel" }
]
diff --git a/client/public/version.js b/client/public/version.js
index 642107e..c2f0443 100644
--- a/client/public/version.js
+++ b/client/public/version.js
@@ -1,5 +1,5 @@
// Maintainer-controlled web client version.
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
-window.CHGRID_WEB_VERSION = "2026.02.21 R105";
+window.CHGRID_WEB_VERSION = "2026.02.21 R106";
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
window.CHGRID_TIME_ZONE = "America/Detroit";
diff --git a/client/src/main.ts b/client/src/main.ts
index 779c5d6..4e2dc0f 100644
--- a/client/src/main.ts
+++ b/client/src/main.ts
@@ -236,6 +236,8 @@ const itemEmitRuntime = new ItemEmitRuntime(audio, resolveIncomingSoundUrl);
let internalClipboardText = '';
let replaceTextOnNextType = false;
let pendingEscapeDisconnect = false;
+let helpViewerLines: string[] = [];
+let helpViewerIndex = 0;
let audioLayers: AudioLayerState = {
voice: true,
item: true,
@@ -313,6 +315,7 @@ function setUpdatesExpanded(expanded: boolean): void {
}
function renderHelp(help: HelpData): void {
+ const lines: string[] = [];
dom.instructions.innerHTML = '';
const heading = document.createElement('h2');
heading.textContent = 'Help';
@@ -321,6 +324,7 @@ function renderHelp(help: HelpData): void {
const sectionHeading = document.createElement('h3');
sectionHeading.textContent = section.title;
dom.instructions.appendChild(sectionHeading);
+ lines.push(section.title);
for (const item of section.items) {
const line = document.createElement('p');
const keys = document.createElement('b');
@@ -328,8 +332,11 @@ function renderHelp(help: HelpData): void {
line.appendChild(keys);
line.append(` ${item.description}`);
dom.instructions.appendChild(line);
+ lines.push(`${item.keys}: ${item.description}`);
}
}
+ helpViewerLines = lines;
+ helpViewerIndex = 0;
}
async function loadHelp(): Promise {
@@ -581,6 +588,18 @@ function itemPropertyLabel(key: string): string {
return key;
}
+function openHelpViewer(): void {
+ if (helpViewerLines.length === 0) {
+ updateStatus('Help unavailable.');
+ audio.sfxUiCancel();
+ return;
+ }
+ state.mode = 'helpView';
+ helpViewerIndex = 0;
+ updateStatus(helpViewerLines[helpViewerIndex]);
+ audio.sfxUiBlip();
+}
+
function getItemsAtPosition(x: number, y: number): WorldItem[] {
return Array.from(state.items.values()).filter((item) => !item.carrierId && item.x === x && item.y === y);
}
@@ -1533,6 +1552,11 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void {
return;
}
+ if (code === 'Slash' && shiftKey) {
+ openHelpViewer();
+ return;
+ }
+
if (code === 'Slash' && !shiftKey) {
state.mode = 'chat';
state.nicknameInput = '';
@@ -1574,6 +1598,45 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void {
}
}
+function handleHelpViewModeInput(code: string): void {
+ if (helpViewerLines.length === 0) {
+ state.mode = 'normal';
+ updateStatus('Help unavailable.');
+ audio.sfxUiCancel();
+ return;
+ }
+
+ if (code === 'ArrowDown') {
+ helpViewerIndex = Math.min(helpViewerLines.length - 1, helpViewerIndex + 1);
+ updateStatus(helpViewerLines[helpViewerIndex]);
+ audio.sfxUiBlip();
+ return;
+ }
+ if (code === 'ArrowUp') {
+ helpViewerIndex = Math.max(0, helpViewerIndex - 1);
+ updateStatus(helpViewerLines[helpViewerIndex]);
+ audio.sfxUiBlip();
+ return;
+ }
+ if (code === 'Home') {
+ helpViewerIndex = 0;
+ updateStatus(helpViewerLines[helpViewerIndex]);
+ audio.sfxUiBlip();
+ return;
+ }
+ if (code === 'End') {
+ helpViewerIndex = helpViewerLines.length - 1;
+ updateStatus(helpViewerLines[helpViewerIndex]);
+ audio.sfxUiBlip();
+ return;
+ }
+ if (code === 'Escape') {
+ state.mode = 'normal';
+ updateStatus('Closed help.');
+ audio.sfxUiCancel();
+ }
+}
+
function handleChatModeInput(code: string, key: string, ctrlKey: boolean): void {
if (code === 'Enter') {
const message = state.nicknameInput.trim();
@@ -2220,6 +2283,8 @@ function setupInputHandlers(): void {
handleChatModeInput(code, event.key, event.ctrlKey);
} else if (state.mode === 'effectSelect') {
handleEffectSelectModeInput(code, event.key);
+ } else if (state.mode === 'helpView') {
+ handleHelpViewModeInput(code);
} else if (state.mode === 'listUsers') {
handleListModeInput(code, event.key);
} else if (state.mode === 'listItems') {
diff --git a/client/src/state/gameState.ts b/client/src/state/gameState.ts
index a7d3f36..bd770a5 100644
--- a/client/src/state/gameState.ts
+++ b/client/src/state/gameState.ts
@@ -25,6 +25,7 @@ export type SelectionContext = 'pickup' | 'drop' | 'delete' | 'edit' | 'use' | '
export type GameMode =
| 'normal'
+ | 'helpView'
| 'nickname'
| 'chat'
| 'effectSelect'
diff --git a/docs/controls.md b/docs/controls.md
index 707642b..b08e7be 100644
--- a/docs/controls.md
+++ b/docs/controls.md
@@ -6,6 +6,7 @@ This document is the authoritative keymap for the client.
### Movement
- `Arrow Keys`: Move
+- `?`: Open help viewer
- `C`: Speak coordinates
- `Escape`: Press once for disconnect prompt, press again to disconnect
@@ -61,3 +62,10 @@ Applies to effect select, user/item list modes, item selection, item property li
- `Enter`: Confirm selection
- `Escape`: Exit/cancel
- First-letter navigation: jump to next matching entry
+
+## Help Viewer Mode
+
+- `ArrowUp` / `ArrowDown`: Previous/next help line
+- `Home` / `End`: First/last help line
+- `Escape`: Exit help viewer
+- No first-letter navigation in this mode