| name | game-menu-system |
| description | Build a data-driven, attribute-controlled game menu system with concurrent gameplay and simulation, custom actions, and in-place value updates. |
Game Menu System (Data-Driven + Concurrent)
Build a menu system that is data-driven, attribute-controlled, and non-blocking (gameplay and simulation continue while menus are open). This skill defines patterns used in IdleGames to keep UI consistent, fast, and accessible.
Goals
- Data-driven menus: menus are pure data; handlers are separate.
- Attribute-controlled behavior: each action chooses how the menu behaves.
- Concurrent: menu opens without pausing simulation.
- No DOM re-creation for toggles/adjusters: update values in place and preserve focus/scroll.
- Custom actions: actions can dispatch game logic while staying in-menu.
Core Architecture
1) Menu Definitions
Define all menus in a single MENUS object. Menus are pure data.
Implementation note for larger runtimes:
- Keep menu data/builders in a dedicated module (for example
src/ui/menu-definitions.ts).
- Keep row/item renderers in a separate renderer module (for example
src/ui/menu-render.ts).
- Keep top-level menu orchestration (stack transitions, queue draining, pointer-lock coupling) in the runtime root (for example
src/main.ts).
const MENUS = {
computerRoom: {
isRoot: true,
title: 'COMPUTER',
overview: 'Computer Room systems access',
items: [
{ type: 'action', label: 'System Status', action: () => openMenu('computerSystemStatus') },
{ type: 'action', label: 'Crew Automation', action: () => openMenu('crewCommand') }
],
actions: [{ label: 'Back', behavior: 'back' }]
},
crewCommand: {
isRoot: false,
title: 'CREW COMMAND',
overview: 'Configure crew automation:',
itemBuilder: 'buildCrewCommandItems',
actions: [{ label: 'Back', behavior: 'back' }]
}
};
2) Behavior Attributes
Every action has a behavior attribute that drives navigation:
keep-open: update values in-place, no DOM rebuild
back: pop to previous menu
submenu: open another menu
close: close the panel
close-all: clear stack + close
{ label: 'Toggle', action: toggle('autoRepair'), behavior: 'keep-open' }
{ label: 'Back', behavior: 'back' }
3) Stack-Based Navigation
The menu stack holds the current breadcrumb trail. Root menus replace the stack; submenus push onto it.
if (menuDef.isRoot) {
state.ui.unifiedMenuStack = [stackEntry];
} else {
state.ui.unifiedMenuStack.push(stackEntry);
}
3b) Root vs Submenu Action Convention
Use action buttons consistently:
- Root-level menus should expose Close (
behavior: 'close').
- Submenus should expose Back (
behavior: 'back').
This prevents ambiguous navigation and keeps stack behavior predictable when menus are opened directly from world interactions.
Rendering and Updates
1) Two Update Strategies
refreshUnifiedMenuValues() - Update values in-place without DOM rebuild:
- Use for toggles, counters, status displays
- Updates text content only
- Preserves focus and scroll position
rebuildUnifiedMenuInPlace() - Full DOM rebuild:
- Use when items are added/removed from the menu
- Use when the list structure changes (crew hired/dismissed, contracts completed)
- Rebuilds entire menu but keeps the same menu open
function handleMenuBehavior(behavior, target, menuDef) {
switch (behavior) {
case 'keep-open': {
const current = state.ui.unifiedMenuStack.at(-1);
let items = current.definition.items || [];
if (current.definition.itemBuilder) {
const builder = MENU_HANDLERS[current.definition.itemBuilder];
if (typeof builder === 'function') items = builder();
}
current.items = items;
refreshUnifiedMenuValues(current.definition, items);
break;
}
}
}
2) In-Place Value Refresh
Use data attributes to map items to DOM elements.
function renderUnifiedSettingItem(container, item, menuDef) {
const row = document.createElement('div');
row.className = 'menu-setting-item';
row.dataset.menuLabel = item.label;
const value = document.createElement('div');
value.className = 'menu-setting-value';
value.textContent = item.value || '';
row.appendChild(value);
container.appendChild(row);
}
function refreshUnifiedMenuValues(menuDef, items) {
const menuItemsEl = document.getElementById('panelMenuItems');
if (!menuItemsEl) return;
for (const item of items) {
if (item.type === 'setting') {
const row = [...menuItemsEl.querySelectorAll('.menu-setting-item')]
.find(el => el.dataset.menuLabel === item.label);
if (row) {
const valueEl = row.querySelector('.menu-setting-value');
if (valueEl) valueEl.textContent = item.value || '';
}
}
}
}
2e) Queue-Driven Live Bindings (Recommended)
For frequently changing simulator values (power, fuel, temperatures), prefer a queue-driven, in-place binding approach that never rebuilds the menu DOM while it is visible.
Pattern:
- Add a stable key per live value (for example
liveValueKey).
- During render, bind only the value element node to that key (
Map<key, HTMLElement>).
- Emit lightweight UI snapshot events from sim/event-queue only when values actually change.
- Gate high-frequency stat event emission to when the target menu is visible, and apply a minimum enqueue interval to avoid queue churn.
- On event consume, update bound text nodes directly (no
renderMenu() call).
- Keep
keep-open behavior for toggle actions, but use a fast path that drains queue + applies live bindings and returns without redraw.
if (item.liveValueKey) {
liveMenuValueBindings.set(item.liveValueKey, valueEl);
}
function enqueueStatsIfChanged(stats) {
const nextSig = serialize(stats);
if (nextSig === lastSig) return;
lastSig = nextSig;
queue.push({ type: 'ui/menu-stats', stats });
}
function applyLiveStats(stats) {
if (!isTargetMenuVisible()) return;
for (const [key, element] of liveMenuValueBindings) {
element.textContent = toText(key, stats);
}
}
if (behavior === 'keep-open' && isTargetMenuVisible()) {
drainEvents();
applyLiveStats(currentStats());
return;
}
Why this is preferred:
- Preserves current focused button and keyboard context.
- Preserves scroll position in long/scrollable panels.
- Avoids DOM churn and reduces GC/layout pressure.
- Keeps simulation/event queue as the source of truth, UI as a consumer.
2b) Custom Item Types (Non-Standard Rows)
When you introduce a new item type (for example, a feed entry with metadata + buttons), add a dedicated renderer and wire it in renderUnifiedMenu.
if (item.type === 'spacebook') {
renderUnifiedSpacebookItem(menuItemsEl, item, menuDef);
}
function renderUnifiedSpacebookItem(container, item, menuDef) {
const wrapper = document.createElement('div');
wrapper.className = 'spacebook-post';
container.appendChild(wrapper);
}
2d) Document/Letter Content Pattern
For readable in-world documents (letters, logs, memos), prefer one structured custom item over multiple spacer text rows.
{
type: 'letter',
from: 'Chief Engineer',
to: 'Captain',
subject: 'Routing Update',
dateUtc: '2026-02-14 22:10:00 UTC',
paragraphs: ['Paragraph 1', 'Paragraph 2']
}
Renderer guidance:
- Render metadata (from/to/subject/date) in a header block.
- Render body as paragraph elements from
paragraphs[].
- Set an explicit dark ink text color in the custom row styles; do not rely on global value colors intended for generic menu rows.
2c) Tables With Button Columns
Use a table item when you need rows with button actions. Each row is an array of cells; button cells use { type: 'button', label, action, behavior }.
function buildCrewRosterItems() {
const rows = listRosterEntries().map((entry) => ({
cells: [
entry.typeLabel,
entry.name,
entry.role.toUpperCase(),
entry.onDuty ? 'YES' : 'NO',
{
type: 'button',
label: 'Rename',
action: () => openMenu('crewRosterRename', { uid: entry.uid })
},
{
type: 'button',
label: 'Role',
action: () => openMenu('crewRosterRole', { uid: entry.uid })
}
]
}));
return [
{
type: 'table',
columns: ['Type', 'Name', 'Role', 'On Duty', 'Rename', 'Role'],
rows
}
];
}
Render buttons inside renderUnifiedTableItem and wire behavior through handleMenuBehavior so table actions can keep the menu open or navigate.
3) Full Menu Rebuild for List Changes
When items are added/removed (crew hired, inventory exhausted), rebuild the DOM:
function rebuildUnifiedMenuInPlace() {
if (state.ui.unifiedMenuStack.length === 0) return;
const current = state.ui.unifiedMenuStack[state.ui.unifiedMenuStack.length - 1];
let items = current.definition.items || [];
if (current.definition.itemBuilder) {
const builder = MENU_HANDLERS[current.definition.itemBuilder];
if (typeof builder === 'function') {
items = builder();
}
}
current.items = items;
renderUnifiedMenu(current.definition, items, { _skipPush: true });
}
function buildTransferCrewOutItems(options = {}) {
const npcs = state.npcs.filter(Boolean);
const items = [];
for (const n of npcs) {
items.push({
type: 'action',
label: `Dismiss ${n.name}`,
action: () => {
removeCrewMemberById(n.id);
updateHUD();
markDirty();
rebuildUnifiedMenuInPlace();
},
danger: true,
behavior: 'keep-open'
});
}
return items;
}
Custom Action Patterns
1) Toggle / +/- without DOM Rebuild
CRITICAL: Never call openMenu() within an action to refresh. Let behavior: 'keep-open' handle it.
const toggle = (k) => () => {
state.crewAI[k] = !state.crewAI[k];
markDirty();
};
{ type: 'setting', label: 'Auto Repair', value: state.crewAI.autoRepair ? 'ON' : 'OFF',
actions: [{ label: 'Toggle', action: toggle('autoRepair'), behavior: 'keep-open' }] }
2) List Management (Add/Remove Items)
Use rebuildUnifiedMenuInPlace() when the action changes the list structure:
function buildTransferCrewInItems(options = {}) {
const fee = 30;
const candidates = ['CREW', 'TECH', 'MEDIC', ];
const items = [
{ type: 'setting', label: 'Transport Fee', value: `${fee} credits`, actions: [] },
{ type: 'setting', label: 'Available Credits', value: `${Math.floor(state.credits)}`, actions: [] },
{ type: 'divider' }
];
for (const name of candidates) {
items.push({
type: 'action',
label: `Hire ${name} (have ${have})`,
action: () => {
if (state.credits < fee) {
openMenu('transferCrewInInsufficientCredits');
return;
}
state.credits -= fee;
addCrewMember(name);
updateHUD();
**NEVER call `openMenu()` within an action to refresh** - let `behavior: 'keep-open'` handle it.
- [ ] Use `rebuildUnifiedMenuInPlace()` when items are added/removed from lists.
- [ ] Use `refreshUnifiedMenuValues()` (automatic via keep-open) for value-only updates.
- [ ] Error/status messages use dedicated submenus, not dialog popups.
- [ ] Root menus show **Close**, submenus show **Back**.
- [ ] Focus returns to game canvas on close.
## Common Mistakes to Avoid
1. **Calling `openMenu()` to refresh the current menu**
```js
// ❌ WRONG - creates duplicate menu stack entries
action: () => {
state.credits -= 10;
openMenu('transferCrewIn'); // Don't do this!
}
// ✅ CORRECT - let keep-open handle refresh
action: () => {
state.credits -= 10;
markDirty();
},
behavior: 'keep-open'
-
Using dialog popups instead of submenus
if (state.credits < fee) {
showMessage('QUARTERS', 'Need more credits');
return;
}
if (state.credits < fee) {
openMenu('transferCrewInInsufficientCredits');
return;
}
-
Not rebuilding when list structure changes
action: () => {
removeCrewMemberById(n.id);
}
action: () => {
removeCrewMemberById(n.id);
updateHUD();
markDirty();
rebuildUnifiedMenuInPlace();
}
danger: !affordable,
behavior: 'keep-open'
});
}
return items;
}
### 3) Error/Status Submenus
Create dedicated submenus for errors instead of dialog popups:
```js
// Menu definition
transferCrewInInsufficientCredits: {
isRoot: false,
stationType: 'quarters',
title: 'INSUFFICIENT CREDITS',
overview: 'Cannot afford this hire:',
itemBuilder: 'buildTransferCrewInInsufficientCreditsItems',
actions: [
{ label: 'Back', behavior: 'back' }
]
},
// Item builder
function buildTransferCrewInInsufficientCreditsItems(options = {}) {
const fee = 30;
return [
{ type: 'setting', label: 'Required Credits', value: `${fee}`, actions: [] },
{ type: 'setting', label: 'Available Credits', value: `${Math.floor(state.credits)}`, actions: [] },
{ type: 'setting', label: 'Shortage', value: `${fee - Math.floor(state.credits)}`, actions: [] }
];
}
4) Submenu Navigation
{ type: 'action', label: 'System Status', action: () => openMenu('computerSystemStatus'), behavior: 'submenu' }
5) Root Menu Close vs Back
If isRoot and the action is Back, render it as Close and set behavior to close.
6) Guard Against Same-Gesture Activation
When one click opens a menu, modal, or submenu containing new interactive children, do not let that same gesture activate a newly inserted button. This is how you get ghost/phantom clicks where the wrong submenu opens immediately.
Use all three parts together when the UI swaps in fresh interactive DOM:
- Defer the open one animation frame when the opener itself came from a pointer/click target.
- Mark the new interactive container as guarded for 1-2 frames so its child buttons ignore the opening gesture.
- Fully tear down interactive child DOM on close or before replace-navigation so hidden stale buttons cannot keep event listeners alive.
- When a panel closes, blur any focused control inside it and make the closed overlay structurally absent, not just visually hidden.
const ui = {
pendingMenuFrame: 0,
menuJustOpened: false,
menuInteractionFrame: 0
};
function queueMenuOpen(menuKey, options = {}) {
if (ui.pendingMenuFrame) cancelAnimationFrame(ui.pendingMenuFrame);
ui.pendingMenuFrame = requestAnimationFrame(() => {
ui.pendingMenuFrame = 0;
openMenu(menuKey, options);
});
}
function openMenu(menuKey, options = {}) {
resetMenuDom();
ui.menuJustOpened = true;
panel.hidden = false;
panel.classList.add('open');
menuItems.classList.add('is-guarded');
renderUnifiedMenu(MENUS[menuKey], buildItems(menuKey), options);
ui.menuInteractionFrame = requestAnimationFrame(() => {
ui.menuInteractionFrame = requestAnimationFrame(() => {
ui.menuInteractionFrame = 0;
ui.menuJustOpened = false;
menuItems.classList.remove('is-guarded');
});
});
}
function bindMenuButton(button, action) {
button.addEventListener('click', (event) => {
if (ui.menuJustOpened) {
event.preventDefault();
event.stopPropagation();
return;
}
action();
});
}
function resetMenuDom() {
if (ui.pendingMenuFrame) {
cancelAnimationFrame(ui.pendingMenuFrame);
ui.pendingMenuFrame = 0;
}
if (ui.menuInteractionFrame) {
cancelAnimationFrame(ui.menuInteractionFrame);
ui.menuInteractionFrame = 0;
}
ui.menuJustOpened = false;
menuItems.classList.remove('is-guarded');
menuItems.replaceChildren();
}
Implementation notes:
- If a submenu replaces a child dialog/panel, close the child first and reset its DOM before opening the new one.
- Prefer
replaceChildren() over leaving hidden interactive nodes mounted.
- Do not rely on opacity or off-screen transforms alone for closed overlays. Closed panels should use explicit removal semantics such as
[hidden] { display: none !important; } and element.inert = true so they cannot keep residual click targets or keyboard focus.
- If multiple overlays can coexist in code paths, close the old overlay before opening the new one and blur focus within the old container as part of teardown.
- If the opener is itself created during the same render pass, queue the open instead of opening synchronously from that click handler.
- A CSS guard like
.is-guarded { pointer-events: none; } is useful, but it is not enough on its own; keep the JS menuJustOpened guard too.
Concurrency (Game Continues)
Menus should not pause the simulation. Keep game state updates independent and let the menu update values via refreshUnifiedMenuValues.
Focus/Key Handling
- Use a single menu key handler tied to the panel.
- Avoid stealing focus when closing menus.
- If you close the panel, blur the active element and return focus to the game canvas.
- If you hide or replace an overlay that owns form controls, blur focus inside that overlay before removing it so mobile browsers do not keep a ghost input focus alive.
- Always stop propagation for menu Enter/Escape/Space so input does not leak to gameplay.
if (document.activeElement && document.activeElement !== document.body) {
document.activeElement.blur();
}
const gameCanvas = document.getElementById('gameCanvas');
if (gameCanvas?.focus) gameCanvas.focus();
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
}
Checklist
Use This Skill When
- You need non-blocking in-game menus.
- You want a consistent data-driven menu architecture.
- You need toggles and adjusters that update values without re-creating DOM.
- You need stack-based navigation with root/submenu behavior.