Commit 0c7885650f

0c7885650f01525e22282e237dc543bfb82b8cac

parent: cb83ff3077

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-27 06:17 UTC

Explain every interactable and exit in the side panel

Nothing in a level said what most tiles did before touching them, and
five kill on contact or entry: door d (black arch), effigies, unsafe
pit edges, the crown before bowing, and the crack while carrying gold.

The side panel now opens with:
- here: one LEGEND line per interactable seen in the room, the effigy
  warning when fake relics are present, and the dark-room rule while
  torchless
- exits: every exit with its ROOM_NAMES destination, "(one way)", and
  state-dependent notes: d (death), c (gold: death / costs ward), gate
  (crown and key: go on)
- keys: t drop torch, . wait

In graphics mode each line carries its sprite; door d gets its own
black-arch sprite. The title screen lists g and points to the panel.

validate.js builds floors 1-7 for every seed and fails on any tile,
effigy set, or exit destination without a LEGEND or ROOM_NAMES entry
(room d is skipped: entering it kills).

Layout: unified · split

1mb/data.js +22
@@ -1022,6 +1022,28 @@ const HINT_TEXT={
10221022 crack:"Cracks are fast but costly. Mask or ward makes the crossing safer.",
10231023 beast:"Fire slows pursuit. A dropped torch can buy one clean lane."
10241024}
1025// Side-panel legend: one line per interactable visible in the current room.
1026const LEGEND={
1027 O:"pit edge: kills unless beside a wall or carrying a torch",
1028 T:"torch: light; stops lost steps in the dark, keeps pit edges safe, slows the beast",
1029 M:"mask: eases dark, lets gold through the crack; fire stops slowing the beast",
1030 S:"shrine: raises ward; from floor 3 ward stops the dark stealing steps",
1031 r:"relic: a trade, one edge for one cost (named on pickup)",
1032 effigy:"some relics in this room are false; a false one kills",
1033 F:"brazier: relights or refuels your torch",
1034 x:"crack: burns torch and gives the beast a free move",
1035 z:"curse: raises dread (dark steals more steps) and chips ward",
1036 n:"shade: drains torch and raises dread; in the den it draws the beast",
1037 q:"watcher: beast pursuit jumps; costs torch",
1038 l:"leech: eats ward and torch",
1039 I:"idol: step on the idol, its gold, or its shrine to bow",
1040 g:"gold: quickens the beast; fatal in the crack unless masked",
1041 C:"crown: bow to the idol first or die; counts as gold",
1042 K:"key: take it with the crown back to the gate",
1043 B:"beast: kills on contact; fire and a dropped torch slow it",
1044 dark:"dark: without a torch, dread builds and steps get lost"
1045}
1046const ROOM_NAMES={g:"gate",b:"bone room",e:"beast den",h:"hall",p:"pit room",i:"idol room",c:"crack",v:"vault",d:"black arch"}
10251047const RELICS={
10261048 ember:{name:"ember heart",label:"ember",up:"slower torch drain",down:"beast ignores dropped flame"},
10271049 tooth:{name:"gold tooth",label:"tooth",up:"gold slips the crack",down:"beast enrages sooner"},
1mb/index.html +32 −3
@@ -53,7 +53,7 @@ linear-gradient(180deg,transparent,rgba(0,0,0,.12));mix-blend-mode:screen;opacit
5353#s[data-state="win"]{--accent:#f3dd8f;--panelEdge:#7a6930;--gridEdge:#6e5f2c;--mist:#f3dd8f18}
5454#s[data-state="camp"]{--accent:#c8f2dd;--panelEdge:#376050;--gridEdge:#2f5648;--mist:#8ff0cc18}
5555@media (max-width:780px){#s{justify-content:flex-start;padding:12px 10px 20px}#p{flex-direction:column;align-items:center;width:100%}#g,#i,#g.splash,#l,#u{width:min(92vw,420px);max-width:100%}#i,#g.splash{min-width:0}#g.splash{overflow:auto}#s[data-state="play"] #d{display:grid}}
56#s.gfx{--cs:min(40px,calc((92vw - 26px)/12))}#s.gfx #g:not(.splash){grid-template-columns:repeat(12,var(--cs));gap:0}#s.gfx .t{width:var(--cs);height:var(--cs);font-size:0;text-shadow:none}.t>svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}.t>b{position:relative;font-size:calc(var(--cs)*.6)}.t>b.dl{font-size:calc(var(--cs)*.34);color:var(--exit);top:10%}.B.alert{--eye:#ffa8e4}.B.enraged{--eye:#ff4040}
56#s.gfx{--cs:min(40px,calc((92vw - 26px)/12))}#s.gfx #g:not(.splash){grid-template-columns:repeat(12,var(--cs));gap:0}#s.gfx .t{width:var(--cs);height:var(--cs);font-size:0;text-shadow:none}.t>svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}.t>b{position:relative;font-size:calc(var(--cs)*.6)}.t>b.dl{font-size:calc(var(--cs)*.34);color:var(--exit);top:10%}.lg{white-space:normal;margin-bottom:10px;color:var(--muted)}.lr{display:flex;gap:6px;align-items:flex-start;color:var(--text);margin:1px 0}.lr i{flex:0 0 1.5em;height:1.5em;font-style:normal;text-align:center;color:var(--accent)}.lr i svg{width:100%;height:100%}.B.alert{--eye:#ffa8e4}.B.enraged{--eye:#ff4040}
5757@keyframes flicker{0%,100%{opacity:1;transform:translateY(0)}25%{opacity:.75;transform:translateY(-.5px)}50%{opacity:.94}75%{opacity:.68;transform:translateY(.5px)}}
5858@keyframes pulse{0%,100%{opacity:.84;transform:scale(1)}50%{opacity:1;transform:scale(1.08)}}
5959@keyframes step{0%{transform:scale(.82)}100%{transform:scale(1)}}
@@ -553,6 +553,34 @@ function sidePanel(){
553553 return `${inv()}\n\nrun\n${statRow("title",buildTitle())}\n${statRow("route",z.route)}\n${statRow("style",z.style)}\n${statRow("floor",`${S.level} ${th.name}`)}\n${statRow("mood",th.tag)}\n${statRow("mode",MODES[S.mode||"standard"].label)}\n${statRow("mods",modSummary())}\n${statRow("turns",S.turn)}\n${statRow("beast",beastState())}`
554554}
555555
556function exitNote(dest){
557 if(dest[0]=="d")return " (death)"
558 if(dest[0]=="c"&&S.flags.gold&&!S.flags.mask&&S.flags.relic!="tooth")return S.level>=3&&S.flags.ward?" (gold: costs ward)":" (gold: death)"
559 if(dest[0]=="g"&&S.flags.crown&&S.flags.key)return " (crown and key: go on)"
560 return ""
561}
562function legendRows(){
563 const here={},rows=[]
564 for(let y=0;y<H;y++)for(let x=0;x<W;x++)if(visible(x,y)||seen(x,y))here[tile(x,y)]=1
565 if(S.r[0]=="e"&&visible(S.beast.x,S.beast.y))here.B=1
566 if(here.r&&O.effigies.some(o=>o.on&&o.r==S.r))here.effigy=1
567 if(dark()&&!S.flags.torch)here.dark=1
568 for(const k in LEGEND)if(here[k])rows.push([k,LEGEND[k]])
569 return rows
570}
571function exitRows(){
572 const ex=R[S.r].ex
573 return Object.keys(ex).map(ch=>{const dest=ex[ch].replace("!","");return [ch,`${ROOM_NAMES[dest[0]]}${ex[ch][0]=="!"?" (one way)":""}${exitNote(dest)}`,dest]})
574}
575function legendHTML(){
576 const esc=v=>v.replace(/[&<>]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;"})[c])
577 const row=(icon,text)=>`<div class=lr><i>${icon}</i><span>${esc(text)}</span></div>`
578 const glyph=k=>k=="effigy"?"*":k=="dark"?"":k=="B"?"B":tileGlyph(k,-1,-1,1)
579 const here=legendRows().map(([k,t])=>row(GFX?gfxIcon(k):esc(glyph(k)),t))
580 const exits=exitRows().map(([ch,t,dest])=>row(GFX?gfxIcon(ch==">"?"i":dest[0]=="d"?"Dd":"D"):esc(ch),GFX&&ch!=">"?`${ch} ${t}`:t))
581 return `<div class=lg>${here.length?`here${here.join("")}`:""}exits${exits.join("")}<div class=lr><i></i><span>keys: t drop torch, . wait</span></div></div>`
582}
583
556584function frame(lines,w=31){
557585 const body=lines.map(line=>{
558586 const s=(line||"").slice(0,w)
@@ -1032,7 +1060,7 @@ function render(){
10321060 if(S.gameState=="title"){
10331061 delete G.dataset.intro
10341062 U.innerHTML=`1MB ROGUELIKE<small>single-file campaign | minimalist descent</small>`
1035 I.textContent=`modes\n1 standard ${MODES.standard.desc}\n2 iron ${MODES.iron.desc}\n3 greed ${MODES.greed.desc}\n4 dark ${MODES.dark.desc}\n5 daily ${MODES.daily.desc}\n6 heavy ${MODES.heavy.desc}\n7 swift ${MODES.swift.desc}\n8 warded ${MODES.warded.desc}\n\nkeys\n1-8 begin\nr quick run${hasSave?"\nc continue saved run":""}\nm mute sound\ng graphics / ascii\n\nglyphs\n@ you B beast * relic\nY shrine & brazier t torch\n! crack ^ curse Q watcher\nj leech $ gold > gate`
1063 I.textContent=`modes\n1 standard ${MODES.standard.desc}\n2 iron ${MODES.iron.desc}\n3 greed ${MODES.greed.desc}\n4 dark ${MODES.dark.desc}\n5 daily ${MODES.daily.desc}\n6 heavy ${MODES.heavy.desc}\n7 swift ${MODES.swift.desc}\n8 warded ${MODES.warded.desc}\n\nkeys\n1-8 begin\nr quick run${hasSave?"\nc continue saved run":""}\nm mute sound\ng graphics / ascii\n\nglyphs\n@ you B beast * relic\nY shrine & brazier t torch\n! crack ^ curse Q watcher\nj leech $ gold > gate\n\nin play, the side panel explains\nevery item and exit in the room`
10361064 L.textContent=`Pick a mode, then descend. Flame buys time; wards buy mistakes.${hasSave?" A saved run is waiting — press c to continue.":""}`
10371065 X.textContent="start standard"
10381066 X.onclick=()=>{startRun("standard",1);render()}
@@ -1069,7 +1097,8 @@ function render(){
10691097 remember()
10701098 const th=floorTheme()
10711099 U.innerHTML=`floor ${S.level} | ${th.name}<small>${buildTitle()} | ${th.tag} | ${th.accent} | ${MODES[S.mode||"standard"].label} | seed ${S.seed.toString(36)}</small>`
1072 I.textContent=sidePanel()+(DBG.show?`\n\ndebug\n${variantSummary()}\nsolve : ${R.e?escapeSolve(S.level,V.escape,V.beast,S.level>=3?8:6)?"ok":"fail":"n/a"}\nfloor : ${R.i&&R.v&&R.e?floorSolve(seed,S.level)||"fail":"n/a"}${DBG.out?`\ncheck : ${DBG.out}`:""}\nkeys : v V R j J k K T`:"")
1100 I.innerHTML=legendHTML()
1101 I.insertAdjacentText("beforeend",sidePanel()+(DBG.show?`\n\ndebug\n${variantSummary()}\nsolve : ${R.e?escapeSolve(S.level,V.escape,V.beast,S.level>=3?8:6)?"ok":"fail":"n/a"}\nfloor : ${R.i&&R.v&&R.e?floorSolve(seed,S.level)||"fail":"n/a"}${DBG.out?`\ncheck : ${DBG.out}`:""}\nkeys : v V R j J k K T`:""))
10731102 L.textContent=[...new Set([S.msg,S.hint,roomMsg()])].filter(Boolean).join("\n")
10741103 X.onclick=()=>{reset(1);render()}
10751104 X.hidden=true
1mb/sprites.js +3 −1
@@ -52,6 +52,7 @@ const SPRITES={
5252 F:`<circle cx=16 cy=12 r=14 fill=url(#gGlow) /><path d="M10 28L12.5 20M22 28L19.5 20M16 29V21" stroke=url(#gIron) stroke-width=1.8 stroke-linecap=round /><path d="M6 15H26Q25 22 16 22T6 15Z" fill=url(#gIron) stroke=#0009 stroke-width=.7 /><ellipse cx=16 cy=15 rx=10 ry=2.2 fill=#1a0c05 stroke=#6a6e73 stroke-width=.7 /><ellipse cx=16 cy=15 rx=8 ry=1.4 fill=#ff7a2a opacity=.8 />`+FLAME(12.5,11,.9)+FLAME(19.5,11.5,.85)+FLAME(16,9,1.25),
5353 C:`<circle cx=16 cy=16 r=12 fill=url(#gGlow) opacity=.6 />${SHADOW}<path d="M6 24L5 10 11 15.5 16 7 21 15.5 27 10 26 24Z" fill=url(#gG) stroke=#5a360b stroke-width=.8 stroke-linejoin=round /><rect x=6 y=22 width=20 height=4 rx=1 fill=url(#gG) stroke=#5a360b stroke-width=.7 /><circle cx=5 cy=9.5 r=1.3 fill=#fff1b8 /><circle cx=16 cy=6.5 r=1.5 fill=#fff1b8 /><circle cx=27 cy=9.5 r=1.3 fill=#fff1b8 /><circle cx=11 cy=24 r=1.2 fill=#2e6bd6 /><circle cx=16 cy=24 r=1.4 fill=#c4122a /><circle cx=21 cy=24 r=1.2 fill=#1e9e5a /><path d="M8 13L8.6 20" stroke=#fff8 stroke-width=.8 />`,
5454 D:`<path d="M5 30V13Q5 3 16 3T27 13V30Z" fill=url(#gStone) stroke=#0009 stroke-width=.8 /><path d="M9 30V14Q9 7 16 7T23 14V30Z" fill=#050403 /><path d="M9 30V14Q9 7 16 7T23 14V30Z" fill=none style="stroke:var(--exit)" stroke-width=.8 opacity=.7 /><path d="M10 30H22" style="stroke:var(--exit)" stroke-width=2 opacity=.35 /><path d="M7 12Q8 6 13 4.5" stroke=#fff5 stroke-width=.8 fill=none />`,
55 Dd:`<circle cx=16 cy=18 r=14 fill=url(#gHaz) opacity=.55 /><path d="M5 30V13Q5 3 16 3T27 13V30Z" fill=#15100f stroke=#000 stroke-width=.8 /><path d="M9 30V14Q9 7 16 7T23 14V30Z" fill=#000 /><path d="M9 30V14Q9 7 16 7T23 14V30Z" fill=none style="stroke:var(--hazard)" stroke-width=1 /><path d="M12.5 17.5a3.5 3.5 0 1 1 7 0c0 1.3-.7 2-1.2 2.4V21h-4.6v-1.1c-.5-.4-1.2-1.1-1.2-2.4Z" fill=#d8d0bf /><circle cx=14.6 cy=17.6 r=.9 fill=#000 /><circle cx=17.4 cy=17.6 r=.9 fill=#000 /><path d="M14.5 21v1M16 21v1M17.5 21v1" stroke=#000 stroke-width=.5 />`,
5556 K:`<circle cx=16 cy=16 r=11 fill=url(#gGlow) opacity=.5 /><circle cx=10 cy=12 r=5.5 fill=none stroke=#5a360b stroke-width=3.6 /><circle cx=10 cy=12 r=5.5 fill=none stroke=url(#gG) stroke-width=2.4 /><circle cx=10 cy=12 r=1.6 fill=url(#gG) /><path d="M14 16L25.5 27.5" stroke=#5a360b stroke-width=3.4 stroke-linecap=round /><path d="M14 16L25.5 27.5" stroke=url(#gG) stroke-width=2 stroke-linecap=round /><path d="M21.5 23.5L19 26M24 26L22 28" stroke=url(#gG) stroke-width=2.2 stroke-linecap=round /><path d="M7 9.5Q8.5 8 10.5 8" stroke=#fff stroke-width=.8 fill=none opacity=.8 />`
5657}
5758// Grid-cell kinds from cls() that sit on top of a floor tile.
@@ -60,6 +61,7 @@ let GFX=1
6061try{GFX=localStorage.getItem("mrl_gfx")!=="0"}catch(e){}
6162document.getElementById("s").insertAdjacentHTML("afterbegin",`<svg width=0 height=0 style=position:absolute aria-hidden=true><defs>${GFX_DEFS}${Object.entries(SPRITES).map(([k,v])=>`<symbol id=s-${k} viewBox="0 0 32 32">${v}</symbol>`).join("")}</defs></svg>`)
6263function gfxToggle(){GFX=!GFX;try{localStorage.setItem("mrl_gfx",GFX?"1":"0")}catch(e){}}
64function gfxIcon(k){return k=="dark"?"":`<svg viewBox="0 0 32 32"><use href="#s-${k=="effigy"?"r":k=="O"?"o":k}"/></svg>`}
6365function gfxCell(c,x,y,glyph){
6466 const k=c.split(" ")[1]
6567 if(!k||k=="u")return ""
@@ -68,6 +70,6 @@ function gfxCell(c,x,y,glyph){
6870 if(k=="o")return use("o")
6971 const fl=use("f"+((x*7+y*13)%3))
7072 if(k=="f")return fl
71 if(k!="i"&&R[S.r]&&k in R[S.r].ex)return fl+use("D")+`<b class=dl>${glyph}</b>`
73 if(k!="i"&&R[S.r]&&k in R[S.r].ex)return fl+use(R[S.r].ex[k].replace("!","")[0]=="d"?"Dd":"D")+`<b class=dl>${glyph}</b>`
7274 return fl+(GFX_ON_FLOOR.includes(k)?use(k):`<b>${glyph}</b>`)
7375}
README.org +4 −2
@@ -22,7 +22,8 @@ controls:
2222- c continue a saved run (title screen)
2323#+end_example
2424
25on mobile an on-screen d-pad appears.
25on mobile an on-screen d-pad appears. the side panel lists what
26every item and exit in the current room does.
2627
2728#+begin_example
2829modes (press a number on the title screen):
@@ -35,7 +36,8 @@ win once and a hidden seventh floor opens.
3536budget: the game is the raw files in 1mb/, no build step, and
3637together they stay under 1,048,576 bytes (1 MiB). tools/check.sh is
3738the gate; it prints the byte ledger and proves every escape room
38stays winnable across hundreds of seeds. ledger history in
39stays winnable across hundreds of seeds, and that every item
40that can appear has a side-panel explanation. ledger history in
3941docs/SIZES.txt.
4042
4143#+begin_src sh
docs/SIZES.txt +5 −4
@@ -3,7 +3,8 @@
33Raw bytes of everything in `1mb/`, against the 1,048,576-byte (1 MiB) budget.
44Record a row after each phase of work; `tools/check-size.sh` prints the current numbers.
55
6| Phase | Date | index.html | data.js | Total | % of budget |
7|---|---|---:|---:|---:|---:|
8| Phase 0 — guardrails (no game changes) | 2026-07-12 | 55,225 | 27,015 | 82,240 | 7.8% |
9| Phase 1 — engine enablers (data-driven graph, procgen rooms, full-floor solver) | 2026-07-12 | 61,356 | 28,998 | 90,354 | 8.6% |
6| Phase | Date | index.html | data.js | sprites.js | Total | % of budget |
7|---|---|---:|---:|---:|---:|---:|
8| Phase 0 — guardrails (no game changes) | 2026-07-12 | 55,225 | 27,015 | — | 82,240 | 7.8% |
9| Phase 1 — engine enablers (data-driven graph, procgen rooms, full-floor solver) | 2026-07-12 | 61,356 | 28,998 | — | 90,354 | 8.6% |
10| Graphics — vector tiles, room legend, dark-step cue | 2026-09-27 | 64,192 | 30,340 | 17,943 | 112,475 | 10.7% |
tools/validate.js +29
@@ -39,6 +39,7 @@ function el() {
3939 onclick: null,
4040 classList: { toggle() {} },
4141 insertAdjacentHTML() {},
42 insertAdjacentText() {},
4243 }
4344}
4445
@@ -107,3 +108,31 @@ if (fails.length) {
107108}
108109console.log(`validate: OK — ${checks} full floors solvable (${seedCount} seeds x floors 1-7, ${elapsed}s)`)
109110
111// Every interactable that can appear on floors 1-7 needs a LEGEND line and
112// every exit destination a ROOM_NAMES entry. Room d kills on entry, so its
113// contents are never shown.
114const missing = JSON.parse(vm.runInContext(`JSON.stringify((() => {
115 const miss = {}
116 for (const s0 of ${seedsJson}) {
117 for (const level of [1, 2, 3, 4, 5, 6, 7]) {
118 S = { level, mods: pickMods(s0), flags: {}, drop: null, seen: {} }
119 build(s0)
120 for (const r in R) {
121 if (r[0] == "d") continue
122 for (const ch in R[r].ex) if (!ROOM_NAMES[R[r].ex[ch].replace("!", "")[0]]) miss["exit " + r + ":" + ch] = level
123 for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
124 const t = tile(x, y, r)
125 if (t != "." && t != "#" && t != ">" && !(t in R[r].ex) && !LEGEND[t]) miss["tile " + t] = level
126 }
127 if (O.effigies.some(o => o.r == r) && !LEGEND.effigy) miss.effigy = level
128 }
129 }
130 }
131 return miss
132})())`, ctx, { filename: "validate-legend" }))
133const gaps = Object.keys(missing)
134if (gaps.length) {
135 console.error(`validate: ${gaps.length} interactables without a legend entry: ${gaps.map(g => `${g} (floor ${missing[g]})`).join(", ")}`)
136 process.exit(1)
137}
138console.log("validate: OK — every interactable has a legend entry")