diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 9865398..fbda544 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -205,6 +205,12 @@ above, including:
- **The to-do list is a kandown board.** `NOTES.md` became one task file per note in
`tasks/` (Todo for tasks, Backlog for discussions and ideas, Review for "done, please check").
The owner wanted a task tool built for AI agents.
+- **Wall and mold materials have a menu (F).** It lists every wall material with what you have,
+ the cost and the heat it stands; R still cycles. The owner chose F to match the filter menu.
+- **The camera's drift toward the pointer is a setting** ("Camera looks toward the pointer", on
+ by default). The owner doesn't like it.
+- **Fog of war reveals by line of sight.** Unexplored ground is black with a soft edge; the sky
+ and the surface start seen. The owner chose line of sight over a plain circle around the bot.
## Ideas not built yet
diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md
index 7ad11c8..9a6f78d 100644
--- a/docs/PROJECT_MAP.md
+++ b/docs/PROJECT_MAP.md
@@ -393,14 +393,14 @@ The notebook (N): milestones, market, wallet, blueprints, controls.
The tool hotbar and what is selected.
- functions: renderHotbar, toolId, steering, modShift, grainAt, selectTool, bagMaterials, mixList, ensureDropMat, piece
-### src/ui/build-menu.js (101 lines)
+### src/ui/build-menu.js (102 lines)
The build menu along the bottom: categories, piece tiles and their icons.
- functions: pieceIcon, catOf, selectBuildCat, cycleBuildCat, buildVariantChips, renderBuildBar, setupBuildBar
- variables: BUILD_CATS, BUILD_ORDER, PIECE_ICONS, lastBuildKey, lastBuildMenu
-### src/ui/selection.js (236 lines)
+### src/ui/selection.js (252 lines)
The selected tool and piece: R / Q / E, drill width, filter picker, the selection card, the bag panel.
-- functions: pieceUsesWall, setDrillWidth, tuneUnderCursor, cycleItem, cycleWall, rotate, toggleCollect, pickFilterUnderCursor, toggleFilterMat, toggleCollectUnderCursor, setBrush, pieceV, pieceExtra, filterChoices, matList, filterParts, filterSentence, applyFilterRun, openFilterPicker, filterBlockAt, chipCost, pieceCostNow, renderSelCard, renderBag
+- functions: pieceUsesWall, setDrillWidth, tuneUnderCursor, cycleItem, cycleWall, rotate, toggleCollect, pickFilterUnderCursor, toggleFilterMat, toggleCollectUnderCursor, setBrush, pieceV, pieceExtra, filterChoices, matList, filterParts, filterSentence, applyFilterRun, openFilterPicker, openWallPicker, filterBlockAt, chipCost, pieceCostNow, renderSelCard, renderBag
- variables: lastBagKey
### src/ui/info.js (170 lines)
@@ -412,7 +412,7 @@ Keyboard and mouse listeners.
- functions: isTyping
- variables: canvas
-### src/ui/actions.js (242 lines)
+### src/ui/actions.js (243 lines)
What input does: overlays, F, pick, copy / cut / paste, undo, building and tool use each frame. Which overlays are on, shown as a coloured frame round the screen and a named badge
- functions: updateOverlayUI, toggleLens, doInteract, pickUnderCursor, aimBlock, stampOrigin, selModeHeld, startPaste, stopSelectAndPaste, plural, piecesIn, doUndo, endRmbRecord, dragDirFor, blockLabel, primaryPress, primaryRelease, secondaryPress, toolTick
- variables: ovShown
diff --git a/docs/agent-log.md b/docs/agent-log.md
index d82166e..e3efa17 100644
--- a/docs/agent-log.md
+++ b/docs/agent-log.md
@@ -4,6 +4,19 @@ What agents have done, newest first. Add an entry for every piece of work: the d
note or request it was for, what changed, the files touched, the tests run, and anything left
open or not verified. Keep each entry short; the git history has the details.
+## 2026-10-24: t5 Material selection menu (Claude Code, Codex)
+
+- Decided with the owner: F opens it for walls and molds, R keeps cycling.
+- `src/ui/selection.js`: `openWallPicker` (a dialog of every wall material: what you have, the
+ cost for this piece, the heat it stands; owned first, unaffordable dimmed; glass says whether
+ it is from shards or bars); `pieceCostNow(P, m)` takes a material.
+- `src/ui/actions.js`: F with a wall or mold piece opens it. `src/ui/build-menu.js`: the material
+ chip reads "· R · F" and opens it on click; placement help mentions F. `src/ui/notebook.js`:
+ Controls and Blueprints text. `src/styles/style.css`: `.wpick`.
+- `test/play.py`: F and the chip open it, Brick picks RBRICK, R still cycles, molds have their own
+ menu and cost, it fits at 1280×800 and 900×700 (screenshots checked).
+- Tests: `run_all.py --browser` pass, apart from the `t_smooth` half-resolution flake.
+
## 2026-10-24: t10 World name box for a new world (Claude Code, Codex)
- `src/shell.html`, `src/styles/style.css`: a Name box across the top of the New world panel.
diff --git a/src/styles/style.css b/src/styles/style.css
index 97329c8..1c268b4 100644
--- a/src/styles/style.css
+++ b/src/styles/style.css
@@ -315,6 +315,16 @@ input.text { background: var(--soot-0); border: 1px solid var(--soot-4); border-
@media (prefers-reduced-motion: reduce) {
.toast { animation: none; }
}
+/* wall material picker */
+.wpick { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; max-height: min(60vh, 490px); overflow-y: auto; padding: 2px; }
+.wpick .wp { display: flex; align-items: flex-start; gap: 8px; text-align: left; padding: 8px; border-radius: 6px; border: 1px solid var(--soot-4); background: var(--soot-2); color: var(--bone); font: inherit; font-size: 12px; cursor: pointer; min-width: 0; }
+.wpick .wp > span:last-child { min-width: 0; overflow-wrap: anywhere; }
+.wpick .wp span span { display: block; color: var(--ash); margin-top: 3px; }
+.wpick .wp.short { opacity: 0.55; }
+.wpick .wp:hover { background: #2d2724; }
+.wpick .wp.on { border-color: #e59a52; background: #33261c; }
+.wpick .wp:focus-visible, [data-wall-picker]:focus-visible { outline: 2px solid #e59a52; outline-offset: 1px; }
+[data-wall-picker] { cursor: pointer; }
/* filter picker */
.fpick { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 6px; max-height: min(52vh, 420px); overflow-y: auto; padding: 2px; }
.fpick .fp { display: flex; align-items: center; gap: 8px; text-align: left; padding: 7px 9px; border-radius: 6px; border: 1px solid var(--soot-4); background: var(--soot-2); color: var(--bone); font: inherit; font-size: 13px; cursor: pointer; }
diff --git a/src/ui/actions.js b/src/ui/actions.js
index 12d76d1..0f1cb25 100644
--- a/src/ui/actions.js
+++ b/src/ui/actions.js
@@ -35,6 +35,7 @@ function doInteract() {
GAME.editRun = run;
return;
}
+ if (toolId() === 'build' && pieceUsesWall(piece())) { openWallPicker(); return; }
if (toolId() === 'build' && piece().filter) { openFilterPicker(Object.assign({}, GAME.filterCfg, { dir: GAME.pieceDir.filter || 0 }), f => { GAME.filterCfg = { m: f.m, inv: f.inv }; GAME.pieceDir.filter = f.dir; }, 'New filters'); return; }
let msg = '';
outer: for (let r = 0; r <= 2; r++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
diff --git a/src/ui/build-menu.js b/src/ui/build-menu.js
index 4551ac7..71912d2 100644
--- a/src/ui/build-menu.js
+++ b/src/ui/build-menu.js
@@ -48,7 +48,7 @@ function buildVariantChips(P) {
const dirChip = !P.dirs ? '' : dragging ? `${glyph(P.dirs[GAME.dragDir])} ${esc(P.dirs[GAME.dragDir])} · from your drag` : `${glyph(dir)} ${esc(dir)} · R`;
const fc = GAME.filterCfg;
const sws = ms => ms.slice(0, 6).map(m => ``).join('') + (ms.length > 6 ? `+${ms.length - 6}` : '');
- return pieceUsesWall(P) ? `${esc(MD[WALL_OF[GAME.wallMat]].name.toLowerCase())} · R`
+ return pieceUsesWall(P) ? ``
: P.filter ? `${fc.inv ? 'carries' : 'drops'} ${fc.m.length ? sws(fc.m) : 'nothing'} · F${dirChip}`
: dir ? dirChip : '';
}
@@ -78,7 +78,7 @@ function renderBuildBar() {
if (!tip) return;
const size = tip.kind === 'machine' ? `${tip.bw}×${tip.bh} blocks` : tip.kind === 'mold' ? `${MOLDS[tip.mold].bw}×${MOLDS[tip.mold].bh} blocks` : '1 block';
pop.innerHTML = `
How to place ${tip.kind === 'block' ? 'Drag for a line, Shift-drag for a rectangle; out of reach becomes ghosts.' : 'Click to place.'}${tip.dirs && tip.kind === 'block' ? ' A dragged line runs the way you drag it; a single click uses R.' : tip.dirs ? ' R turns it.' : pieceUsesWall(tip) ? ' R picks the material.' : ''}
+
${esc(tip.desc)}
How to place ${tip.kind === 'block' ? 'Drag for a line, Shift-drag for a rectangle; out of reach becomes ghosts.' : 'Click to place.'}${tip.dirs && tip.kind === 'block' ? ' A dragged line runs the way you drag it; a single click uses R.' : tip.dirs ? ' R turns it.' : pieceUsesWall(tip) ? ' R cycles materials; F opens the material menu.' : ''}
${tip.filter ? `
New ones will ${esc(filterSentence(GAME.filterCfg))}. F sets materials and mode (or click materials in the bag, or T over one); F on a placed filter changes its whole row.
` : ''}
hold G replace · Ctrl+Z undo · right click take back · middle mouse pick · hold C/X copy/cut · V paste
`;
const tile = bar.querySelector(`[data-piece="${tip.id}"]`); if (!tile) { pop.hidden = true; return; }
@@ -86,6 +86,7 @@ function renderBuildBar() {
pop.style.left = `${Math.max(8, Math.min(innerWidth - w - 8, r.left + r.width / 2 - w / 2)) - br.left}px`;
}
function setupBuildBar() {
+ $('selcard').addEventListener('click', e => { if (e.target.closest('[data-wall-picker]')) openWallPicker(); });
const bar = $('buildbar'); if (!bar) return;
bar.addEventListener('click', e => {
const t = e.target.closest('[data-cat],[data-piece]'); if (!t) return;
diff --git a/src/ui/notebook.js b/src/ui/notebook.js
index 50a33af..d780bd0 100644
--- a/src/ui/notebook.js
+++ b/src/ui/notebook.js
@@ -41,7 +41,7 @@ function renderNotebook() {
let h = '
Everything you build snaps to a grid of 4×4-grain blocks. Pieces only move, hold, blow or sense material; every change to the material itself comes from heat. Select pieces with the Build tool (3). Hover a tile for its cost, full description and placement help. Your bot flies straight through anything you build.
';
for (const P of PIECES) {
if (P.hidden) continue;
- const cost = P.kind === 'mold' ? `${MOLDS[P.mold].walls} grains of wall material (R picks it)` : P.code === BP_WALL ? '16 grains of rubble, lumps or chunks, or 2 bars' : costText(P.cost, 1);
+ const cost = P.kind === 'mold' ? `${MOLDS[P.mold].walls} grains of wall material (R cycles, F opens the menu)` : P.code === BP_WALL ? '16 grains of rubble, lumps or chunks, or 2 bars' : costText(P.cost, 1);
const afford = P.kind === 'mold' || P.code === BP_WALL ? true : canAfford(P.cost, 1);
const size = P.kind === 'machine' ? `${P.bw}×${P.bh}` : P.kind === 'mold' ? `${MOLDS[P.mold].bw}×${MOLDS[P.mold].bh}` : '1×1';
h += `
`;
@@ -169,7 +169,7 @@ function copyCode(code) {
function controlsHtml() {
const kb = [
['Fly', 'W A S D'], ['Boost while flying', 'Shift'], ['Tool modifier (while not flying)', 'Shift'], ['Use tool', 'Left mouse'], ['Second action', 'Right mouse'],
- ['Tools', '1 – 4'], ['Cycle what you blow · piece', 'Q / E'], ['Switch build tab', 'Shift + Q / E'], ['Variant: material · direction · flip', 'R'],
+ ['Tools', '1 – 4'], ['Cycle what you blow · piece', 'Q / E'], ['Switch build tab', 'Shift + Q / E'], ['Variant: material · direction · flip', 'R'], ['Wall or mold material menu (Build)', 'F'],
['Tick/untick what is under the cursor (Vacuum)', 'R or T'], ['Nozzle or brush size', '[ ] or Shift + wheel'],
['Replace what is built (Build)', 'Hold G'], ['Copy a box of what you built', 'Hold C, drag'], ['Cut a box (refunded, paste it back)', 'Hold X, drag'], ['Paste again', 'V'], ['Undo building', 'Ctrl + Z'], ['Blueprint library', 'B'], ['Zoom', 'Mouse wheel'], ['Interact (gates, molds, fans, filters)', 'F'], ['Thermal lens', 'H'], ['Info overlay: filters, belt and tube directions', 'I'], ['Bot lamp', 'L'],
['Pick the piece under the cursor (with its direction and settings)', 'Middle mouse'], ['Notebook', 'N'], ['Clear all blueprint ghosts', 'Delete'], ['Save', 'Ctrl + S'], ['Pause · menu', 'Esc'], ['Mute', 'M'],
diff --git a/src/ui/selection.js b/src/ui/selection.js
index 274ec76..067b608 100644
--- a/src/ui/selection.js
+++ b/src/ui/selection.js
@@ -138,11 +138,27 @@ function openFilterPicker(cur, onDone, title) {
box.querySelectorAll('[data-m]').forEach(b => b.onclick = () => { const m = +b.dataset.m; if (sel.has(m)) sel.delete(m); else sel.add(m); playSfx('ui'); sync(); });
sync();
}
+function openWallPicker() {
+ const P = piece(), title = P.kind === 'mold' ? `${P.name} material` : 'Wall material';
+ const list = WALL_LIST.filter(m => bag[m] > 0).concat(WALL_LIST.filter(m => !bag[m]));
+ const body = `
${list.map(m => {
+ const d = MD[WALL_OF[m]], cost = pieceCostNow(P, m), on = m === GAME.wallMat;
+ // two sources build the same wall (glass from shards or from bars): say which
+ const name = WALL_LIST.filter(k => WALL_OF[k] === WALL_OF[m]).length > 1 ? `${d.name} from ${MD[m].name.toLowerCase()}` : d.name;
+ const heat = d.ign ? `burns at ${fmtT(d.ign)}` : d.melt ? `melts at ${fmtT(d.melt)}` : "won't melt";
+ return ``;
+ }).join('')}
`;
+ dialog(title, body, [{ label: 'Cancel' }]);
+ $('dlg-body').querySelectorAll('[data-wall]').forEach(b => b.onclick = () => {
+ GAME.wallMat = +b.dataset.wall; closeModal('m-dialog'); playSfx('ui'); hudDirty = true; lastBagKey = '';
+ setStatus(`${P.kind === 'mold' ? P.name : 'Walls'}: ${MD[WALL_OF[GAME.wallMat]].name.toLowerCase()}.`, 'info');
+ });
+}
function filterBlockAt(x, y) { if (x < 0 || y < 0 || x >= W || y >= H) return -1; const b = ((y / BS) | 0) * BW + ((x / BS) | 0); return blockP[b] === BP_FILTER ? b : -1; }
function chipCost(cost) { const ok = canAfford(cost, 1); return `${esc(costText(cost, 1))}`; }
-function pieceCostNow(P) {
- if (P.kind === 'mold') return wallCost(GAME.wallMat, MOLDS[P.mold].walls);
- if (P.kind === 'block' && P.code === BP_WALL) return wallCost(GAME.wallMat, BS * BS);
+function pieceCostNow(P, m = GAME.wallMat) {
+ if (P.kind === 'mold') return wallCost(m, MOLDS[P.mold].walls);
+ if (P.kind === 'block' && P.code === BP_WALL) return wallCost(m, BS * BS);
return P.cost;
}
function renderSelCard() {
@@ -176,7 +192,7 @@ function renderSelCard() {
h = '
Build
'
+ (chips ? `
${chips}
` : '')
+ '
Place pieces from the menu below. Drag for a line, Shift-drag for a box; out of reach becomes ghosts.
'
- + '
Q/E piece · Shift+Q/E tab · R turn or material · hold G replace · right click take back · hold C/X copy/cut · V paste · Ctrl+Z undo
';
+ + '
Q/E piece · Shift+Q/E tab · R turn or material · F material menu · hold G replace · right click take back · hold C/X copy/cut · V paste · Ctrl+Z undo
';
} else if (t === 'vacuum') {
ensureDropMat();
const m = GAME.dropMat, f = GAME.lmb ? GAME.vacFilter : 0;
diff --git a/tasks/t5_material_selection_menu.md b/tasks/t5_material_selection_menu.md
index 0244df6..24ce5d5 100644
--- a/tasks/t5_material_selection_menu.md
+++ b/tasks/t5_material_selection_menu.md
@@ -1,9 +1,16 @@
---
id: t5
title: Material selection menu
-status: Todo
+status: Review
created: 2026-10-24
-updated: 2026-10-24T04:41:45Z
+updated: 2026-10-24T05:26:29Z
+order: 0
+report: |
+ (agent) New material menu for walls and molds. With the Build tool on a wall block or a mold,
+ press F (or click the material chip on the Build card) to open it. Each material shows how
+ much you have, what this piece costs in it and the heat it stands (melts at / burns at /
+ won't melt). Ones you have come first; ones you can't afford are dimmed. R still cycles.
+ To check: Build (3), Wall block, F, pick Clay. Then a mold piece, F again.
---
## Context
diff --git a/test/play.py b/test/play.py
index 3e29684..a1e3bc6 100644
--- a/test/play.py
+++ b/test/play.py
@@ -342,6 +342,23 @@ with sync_playwright() as p:
check('the build tool card describes the build tool, not the piece', pg.inner_text('#selcard .name') == 'Build' and 'menu below' in card and ev('piece().name') not in card and ev("costText(pieceCostNow(piece()), 1) || 'free'") not in card and ev('piece().desc') not in card, card[:160])
check('the build menu has no status row', pg.locator('#buildbar .bb-status').count() == 0)
check('the build menu has no short description row', pg.locator('.bb-tip').count() == 0)
+ pg.keyboard.press('f'); settle(120)
+ check('F opens all wall materials', pg.is_visible('#m-dialog') and 'material' in pg.inner_text('#dlg-title') and pg.locator('.wp').count() == ev('WALL_LIST.length'))
+ check('wall material selection and costs match the build card', ev("[...document.querySelectorAll('.wp')].every(b => b.getAttribute('aria-checked') === String(+b.dataset.wall === GAME.wallMat) && b.classList.contains('short') === !canAfford(pieceCostNow(piece(), +b.dataset.wall), 1) && b.textContent.includes(costText(pieceCostNow(piece(), +b.dataset.wall), 1)))"))
+ check('owned wall materials come first', ev("[...document.querySelectorAll('.wp')].map(b => +b.dataset.wall).join() === WALL_LIST.filter(m => bag[m] > 0).concat(WALL_LIST.filter(m => !bag[m])).join()"))
+ for width, height, suffix in [(1280, 800, ''), (900, 700, '-900')]:
+ pg.set_viewport_size({'width': width, 'height': height}); settle(120)
+ check(f'wall picker fits at {width}', ev("$('dlg-body').scrollWidth <= $('dlg-body').clientWidth && document.querySelector('.wpick').scrollWidth <= document.querySelector('.wpick').clientWidth"))
+ pg.screenshot(path=str(out / f'wall-picker{suffix}.png'))
+ pg.set_viewport_size({'width': 1280, 'height': 800})
+ pg.click(f'.wp[data-wall="{ev("RBRICK")}"]'); settle(120)
+ check('choosing Brick closes the material menu', ev("GAME.wallMat === RBRICK && $('m-dialog').hidden"))
+ pg.keyboard.press('r'); settle(120)
+ check('R still cycles after picking a material', ev('GAME.wallMat !== RBRICK'))
+ pg.click('#selcard [data-wall-picker]'); settle(120)
+ check('the material chip opens the menu', pg.is_visible('#m-dialog'))
+ pg.click('#dlg-foot button'); settle(120)
+ ev('GAME.wallMat = RSTONE; hudDirty = true')
for id in ['wall', 'grate']:
pg.hover(f'#buildbar [data-piece="{id}"]'); settle(120)
check(f'hover shows the full {id} description and name', pg.is_visible('#bb-pop') and pg.inner_text('#bb-pop .bb-title') == ev(f'PIECE_BY_ID.{id}.name') and pg.inner_text('#bb-pop .bb-desc') == ev(f'PIECE_BY_ID.{id}.desc'))
@@ -405,6 +422,9 @@ with sync_playwright() as p:
# 6. ingot mold: click to place, pour, bars drop below, vacuum them
ok = select('3', "piece().id === 'ingotmold'")
check('E cycles to the ingot mold', ok)
+ pg.keyboard.press('f'); settle(120)
+ check('molds have a named material menu and their own cost', pg.inner_text('#dlg-title') == ev("piece().name + ' material'") and ev("[...document.querySelectorAll('.wp')].every(b => b.textContent.includes(costText(wallCost(+b.dataset.wall, MOLDS[piece().mold].walls), 1)))"))
+ pg.click('#dlg-foot button'); settle(120)
spot = ev(f'''(() => {{ for (let dx = -2; dx <= 10; dx++) for (let dy = 6; dy < 14; dy++) {{ const ox = {bbx} + dx, oy = {bby} - dy; if (!canPlaceMold('ingot', ox - 1, oy) && [0, 1, 2].every(k => entFreeAt((ox - 1) * 4 + 1 + k * 4, oy * 4 + MOLDS.ingot.h)) && inReach(ox * 4 + 8, oy * 4 + 2)) return [ox, oy]; }} return null; }})()''')
check('found a spot for the mold', spot is not None)
mx, my = blk(spot[0], spot[1])
@@ -417,6 +437,7 @@ with sync_playwright() as p:
check('bars set and drop on their own', ev("ents.length === 3 && molds[0].state === 'short' && molds[0].ready.length === 0"), ev('[molds[0].state, ents.length]'))
pg.screenshot(path=str(out / '05-mold-ready.png'))
check('three bars drop below the mold without F', ev('ents.every(e => e.m === BARCU && e.iy >= molds[0].y + molds[0].h)'), ev('ents.map(e => [e.ix, e.iy])'))
+ pg.keyboard.press('2'); settle(60)
ev("GAME.status = ''; GAME.statusT = 0"); n_toasts = ev("$('toasts').children.length")
pg.mouse.move(*cell_to_screen(ev('molds[0].x') + 3, ev('molds[0].y') + 3)); settle(150); pg.keyboard.press('f'); settle(150)
check('F on an ingot mold says nothing', ev('GAME.status') == '' and ev("$('toasts').children.length") == n_toasts, ev('GAME.status'))