Commit 3aa049457a

3aa049457a4e03ed3b198775d8a0f58b42cf76e3

parent: 6b744e0e7b

Unsigned

cmc <hello@cleberg.net> · 2026-09-27 23:41 UTC

Vector tile graphics and an in-room legend (!2)

- Vector tile graphics (`1mb/sprites.js`, 25 SVG symbols themed by each floor's CSS variables). `g` toggles graphics/ASCII; default on.
- Side panel lists what every visible interactable does and where every exit leads, with warnings for the five instant deaths (door d, effigies, unsafe pit edges, unbowed crown, crack with gold).
- `tools/validate.js` fails if any tile, effigy, or exit destination that can appear on floors 1-7 has no legend entry.
- Status line no longer prints the room text twice; a step lost to darkness dims and shakes the grid.
- Thin flame: torch pickup and brazier cap get `THIN_FUEL_BONUS` (10). Seed 8q10qn could not finish floor 1 without it.
- `tools/playthrough.js`: full-run simulator through the real engine, wins replayed through `key()`. 17/20 seeds won; the 3 misses are thin-flame seeds that exhausted the search budget. Not part of `check.sh` (1-3 min per seed). Headless loader moved to `tools/engine.js`.

Size: 112,564 B (10.7% of budget), ledger row in `docs/SIZES.txt`.

`tools/check.sh`: 1400/1400 floors solvable, legend coverage OK.

Layout: unified · split

1mb/data.js +23 −1
@@ -1,6 +1,6 @@
11// Game data — edit this file to mod the run (floors, relics, hazards, balance).
22// Loaded before the engine in index.html.
3const W=12,H=12,FUEL=16
3const W=12,H=12,FUEL=16,THIN_FUEL_BONUS=10
44const BEAST_CALM_RADIUS=4,BEAST_ENRAGED_SPEED=2,TORCH_DRAIN_BASE=1,TORCH_DRAIN_HAZARD=2,RELIC_SLOTS_LOW=2,RELIC_SLOTS_MID=3,RELIC_SLOTS_HIGH=4,RELIC_SLOTS_RICH_BONUS=1
55const P={g:[5,2],b:[2,2],e:[2,9],h:[2,9],p:[2,2],i:[2,2],c:[2,2],v:[2,2],d:[2,2]}
66const PITS=[[
@@ -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 +44 −8
@@ -39,7 +39,7 @@ linear-gradient(180deg,transparent,rgba(0,0,0,.12));mix-blend-mode:screen;opacit
3939.warded{box-shadow:inset 0 0 0 1px var(--wardGlow);background:radial-gradient(circle at 50% 50%,#ffffff08,transparent 72%)}
4040.haz{filter:brightness(1.14)}
4141.fog1{opacity:.9}.fog2{opacity:.72}.fog3{opacity:.55}
42#g.hit{transform:translateX(2px);box-shadow:inset 0 0 0 1px #000,0 0 24px #0008,0 0 14px color-mix(in srgb,var(--hazard) 70%,#000)}#g.win{box-shadow:inset 0 0 0 1px #000,0 0 24px #0008,0 0 14px var(--glow)}#g.shift{transform:scale(1.02)}#g.ward{animation:wardbreak .2s ease-out}#g.relic{animation:relicpulse .24s ease-out}
42#g.hit{transform:translateX(2px);box-shadow:inset 0 0 0 1px #000,0 0 24px #0008,0 0 14px color-mix(in srgb,var(--hazard) 70%,#000)}#g.win{box-shadow:inset 0 0 0 1px #000,0 0 24px #0008,0 0 14px var(--glow)}#g.shift{transform:scale(1.02)}#g.ward{animation:wardbreak .2s ease-out}#g.relic{animation:relicpulse .24s ease-out}#g.dark{animation:darkstep .3s ease-out}
4343#u.dead,#u.win,#u.camp{letter-spacing:.12em;text-transform:uppercase}
4444#s[data-floor="1"]{--bg0:#0a130d;--bg1:#020302;--panel:#08100bdd;--panelEdge:#29442e;--gridEdge:#213929;--accent:#9fe2ab;--accent2:#f0cc76;--fog:#dbf5df;--mist:#88c6911c;--glow:#9be8a055;--gridGlow:#9be8a020;--floor:#132217;--memory:#667f6a;--wall:#6c786f;--pit:#ad584a;--exit:#f3d487;--torch:#ffbf67;--shrine:#9df1df;--relic:#ffe28f;--hazard:#ff8b77;--shade:#a7c8ff;--watcher:#ffd298;--leech:#8ef0db;--beast:#ff81d9;--player:#fbfff8}
4545#s[data-floor="2"]{--bg0:#170d08;--bg1:#040202;--panel:#130b08e1;--panelEdge:#5c3520;--gridEdge:#4d2d1e;--accent:#ffb07a;--accent2:#ffd18f;--fog:#ffd9c0;--mist:#ff985415;--glow:#ff9d6260;--gridGlow:#ff9d6224;--floor:#24130d;--memory:#8a665b;--wall:#8f6d63;--pit:#dd6a55;--exit:#ffd48a;--torch:#ffaf59;--shrine:#ffd0a6;--relic:#ffe08e;--hazard:#ff7766;--shade:#d3c0ff;--watcher:#ffd0ad;--leech:#f1b68f;--beast:#ff7eb4;--player:#fff7f1}
@@ -53,13 +53,16 @@ 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%}.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}
5657@keyframes flicker{0%,100%{opacity:1;transform:translateY(0)}25%{opacity:.75;transform:translateY(-.5px)}50%{opacity:.94}75%{opacity:.68;transform:translateY(.5px)}}
5758@keyframes pulse{0%,100%{opacity:.84;transform:scale(1)}50%{opacity:1;transform:scale(1.08)}}
5859@keyframes step{0%{transform:scale(.82)}100%{transform:scale(1)}}
5960@keyframes wardbreak{0%{filter:brightness(1.35)}100%{filter:brightness(1)}}
61@keyframes darkstep{0%{filter:brightness(.3);transform:translateX(-3px)}35%{transform:translateX(3px)}70%{transform:translateX(-1px)}100%{filter:brightness(1);transform:none}}
6062@keyframes relicpulse{0%{transform:scale(1)}50%{transform:scale(1.035)}100%{transform:scale(1)}}
6163</style>
6264<div id=s><div id=u></div><div id=p><div id=g></div><div id=i></div></div><div id=l></div><div id=d><button data-k=ArrowUp class=pu>^</button><button data-k=ArrowLeft class=pl>&lt;</button><button data-k=. class=pw>*</button><button data-k=ArrowRight class=pr>&gt;</button><button data-k=ArrowDown class=pd>v</button></div><button id=r hidden>restart</button></div>
65<script src=sprites.js></script>
6366<script src=data.js></script>
6467<script>
6568const SEl=document.getElementById("s"),U=document.getElementById("u"),G=document.getElementById("g"),I=document.getElementById("i"),L=document.getElementById("l"),X=document.getElementById("r"),D=document.getElementById("d")
@@ -550,6 +553,34 @@ function sidePanel(){
550553 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())}`
551554}
552555
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
553584function frame(lines,w=31){
554585 const body=lines.map(line=>{
555586 const s=(line||"").slice(0,w)
@@ -834,7 +865,7 @@ function touch(t,x,y){
834865 if(t=="O"&&!pitSafe(x,y))return die("You step into the dark and do not find the edge.")
835866 if(S.r=="e"&&x==S.beast.x&&y==S.beast.y)return die("The beast tears you open.")
836867 if(S.drop&&S.drop.r==S.r&&S.drop.x==x&&S.drop.y==y&&S.drop.fuel){S.flags.torch=S.drop.fuel;S.drop=null;S.flags.ash=0;return setMsg("Recover the torch.")}
837 if(t=="T"&&!S.flags.torch){S.flags.torch=FUEL;S.flags.ash=0;showHint("torch");setMsg("Take the torch.")}
868 if(t=="T"&&!S.flags.torch){S.flags.torch=FUEL+(hasMod("thin")?THIN_FUEL_BONUS:0);S.flags.ash=0;showHint("torch");setMsg("Take the torch.")}
838869 if(t=="M"&&!S.flags.mask){S.flags.mask=1;setMsg("Take the funeral mask. Dark eases, but flame stops slowing the beast.")}
839870 if(t=="S"){
840871 const wardBase=S.level>=3?8:4
@@ -855,7 +886,7 @@ function touch(t,x,y){
855886 }
856887 }
857888 if(t=="F"){
858 const cap=FUEL+(S.flags.relic=="wick"?4:0)
889 const cap=FUEL+(S.flags.relic=="wick"?4:0)+(hasMod("thin")?THIN_FUEL_BONUS:0)
859890 const fill=S.flags.relic=="ash"?10:S.flags.relic=="wick"?8:6
860891 const gain=S.flags.relic=="ash"?6:4
861892 if(S.flags.ash||!S.flags.torch){S.flags.torch=fill;S.flags.ash=0;setMsg("Light the torch from the brazier.")}
@@ -923,7 +954,7 @@ function step(dx,dy){
923954 const nx=S.x+dx,ny=S.y+dy,t=tile(nx,ny)
924955 if(t=="#")return setMsg(roomMsg())
925956 S.turn++
926 if(shadow()){setMsg("Darkness twists the room around you.");burnTorch();beastTurn();return}
957 if(shadow()){setMsg("Darkness twists the room around you.");S.fx="dark";burnTorch();beastTurn();return}
927958 let ex=R[S.r].ex[t]
928959 if(ex){
929960 if(ex[0]=="!"){delete R[S.r].ex[t];ex=ex.slice(1)}
@@ -969,6 +1000,7 @@ function testFloor(){
9691000
9701001function key(e){
9711002 if(e.key=="m"){muted=!muted;render();return}
1003 if(e.key=="g"){gfxToggle();render();return}
9721004 if(S.gameState=="win"&&(e.key=="p"||e.key=="7")){startPostgame();render();return}
9731005 if(S.gameState=="title"){
9741006 if("12345678".includes(e.key)){startRun(MODE_IDS[+e.key-1],1);render();return}
@@ -1020,6 +1052,7 @@ function cls(x,y){
10201052}
10211053
10221054function render(){
1055 SEl.classList.toggle("gfx",!!GFX)
10231056 U.className=S.gameState
10241057 SEl.dataset.state=S.gameState
10251058 SEl.dataset.floor=S.level||1
@@ -1027,7 +1060,7 @@ function render(){
10271060 if(S.gameState=="title"){
10281061 delete G.dataset.intro
10291062 U.innerHTML=`1MB ROGUELIKE<small>single-file campaign | minimalist descent</small>`
1030 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\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`
10311064 L.textContent=`Pick a mode, then descend. Flame buys time; wards buy mistakes.${hasSave?" A saved run is waiting — press c to continue.":""}`
10321065 X.textContent="start standard"
10331066 X.onclick=()=>{startRun("standard",1);render()}
@@ -1064,8 +1097,9 @@ function render(){
10641097 remember()
10651098 const th=floorTheme()
10661099 U.innerHTML=`floor ${S.level} | ${th.name}<small>${buildTitle()} | ${th.tag} | ${th.accent} | ${MODES[S.mode||"standard"].label} | seed ${S.seed.toString(36)}</small>`
1067 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`:"")
1068 L.textContent=[S.msg,S.hint,roomMsg()].filter(Boolean).join("\n")
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`:""))
1102 L.textContent=[...new Set([S.msg,S.hint,roomMsg()])].filter(Boolean).join("\n")
10691103 X.onclick=()=>{reset(1);render()}
10701104 X.hidden=true
10711105 if(S.intro)G.dataset.intro=INTRO_TEXT
@@ -1074,10 +1108,12 @@ function render(){
10741108 G.className=S.fx=="hit"?"hit":S.fx=="win"?"win":S.fx=="shift"?"shift":""
10751109 if(S.fx=="ward")G.className="ward"
10761110 if(S.fx=="relic")G.className="relic"
1111 if(S.fx=="dark"){G.className="";void G.offsetWidth;G.className="dark"}
10771112 for(let y=0;y<H;y++)for(let x=0;x<W;x++){
10781113 const d=document.createElement("div")
10791114 d.className="t "+cls(x,y)
1080 d.textContent=draw(x,y)
1115 if(GFX)d.innerHTML=gfxCell(d.className,x,y,draw(x,y))
1116 else d.textContent=draw(x,y)
10811117 G.appendChild(d)
10821118 }
10831119 SFX.play(S.fx)
1mb/sprites.js added +75
@@ -0,0 +1,75 @@
1// Vector tile art. Symbols are 32x32 and pull colors from the floor theme's CSS variables.
2// Loaded after data.js; the engine calls gfxCell() per grid cell when graphics are on.
3const GFX_DEFS=`
4<linearGradient id=gF x1=0 y1=0 x2=0 y2=1><stop offset=0 style="stop-color:color-mix(in srgb,var(--floor) 62%,#46463e)"/><stop offset=1 style="stop-color:color-mix(in srgb,var(--floor) 65%,#2c2c27)"/></linearGradient>
5<linearGradient id=gW x1=0 y1=0 x2=0 y2=1><stop offset=0 style="stop-color:color-mix(in srgb,var(--wall) 92%,#fff)"/><stop offset=1 style="stop-color:color-mix(in srgb,var(--wall) 62%,#000)"/></linearGradient>
6<linearGradient id=gWF x1=0 y1=0 x2=0 y2=1><stop offset=0 style="stop-color:color-mix(in srgb,var(--wall) 55%,#000)"/><stop offset=1 style="stop-color:color-mix(in srgb,var(--wall) 22%,#000)"/></linearGradient>
7<radialGradient id=gPit r=.75><stop offset=0 style="stop-color:color-mix(in srgb,var(--pit) 32%,#000)"/><stop offset=1 style="stop-color:color-mix(in srgb,var(--pit) 14%,#000)"/></radialGradient>
8<linearGradient id=gStep x1=0 y1=0 x2=0 y2=1><stop offset=0 style="stop-color:color-mix(in srgb,var(--exit) 55%,#555)"/><stop offset=1 stop-color=#050403 /></linearGradient>
9<radialGradient id=gFl cx=.5 cy=.7 r=.6><stop offset=0 stop-color=#fffbe0 /><stop offset=.35 stop-color=#ffd35a /><stop offset=.75 style="stop-color:var(--torch)"/><stop offset=1 stop-color=#c2381a /></radialGradient>
10<radialGradient id=gGlow><stop offset=0 style="stop-color:var(--torch);stop-opacity:.55"/><stop offset=1 style="stop-color:var(--torch);stop-opacity:0"/></radialGradient>
11<linearGradient id=gG x1=0 y1=0 x2=0 y2=1><stop offset=0 stop-color=#fff3b0 /><stop offset=.45 stop-color=#e8b43c /><stop offset=1 stop-color=#7d4f12 /></linearGradient>
12<linearGradient id=gP x1=0 y1=0 x2=1 y2=1><stop offset=0 style="stop-color:color-mix(in srgb,var(--player) 80%,#fff)"/><stop offset=1 style="stop-color:color-mix(in srgb,var(--player) 45%,#000)"/></linearGradient>
13<radialGradient id=gB cx=.45 cy=.3 r=.8><stop offset=0 style="stop-color:color-mix(in srgb,var(--beast) 75%,#fff)"/><stop offset=.45 style="stop-color:color-mix(in srgb,var(--beast) 70%,#200)"/><stop offset=1 style="stop-color:color-mix(in srgb,var(--beast) 18%,#000)"/></radialGradient>
14<linearGradient id=gStone x1=0 y1=0 x2=0 y2=1><stop offset=0 stop-color=#b9b4a6 /><stop offset=1 stop-color=#4a463e /></linearGradient>
15<linearGradient id=gCry x1=0 y1=0 x2=1 y2=1><stop offset=0 stop-color=#fff /><stop offset=.4 style="stop-color:var(--shrine)"/><stop offset=1 style="stop-color:color-mix(in srgb,var(--shrine) 40%,#000)"/></linearGradient>
16<radialGradient id=gHalo><stop offset=0 style="stop-color:var(--shrine);stop-opacity:.6"/><stop offset=1 style="stop-color:var(--shrine);stop-opacity:0"/></radialGradient>
17<radialGradient id=gShade cx=.5 cy=.35 r=.65><stop offset=0 style="stop-color:color-mix(in srgb,var(--shade) 60%,#fff);stop-opacity:.95"/><stop offset=.7 style="stop-color:var(--shade);stop-opacity:.55"/><stop offset=1 style="stop-color:var(--shade);stop-opacity:0"/></radialGradient>
18<radialGradient id=gEye cx=.4 cy=.35 r=.7><stop offset=0 stop-color=#fff /><stop offset=.7 stop-color=#e8dccb /><stop offset=1 stop-color=#8d6f5e /></radialGradient>
19<radialGradient id=gIris><stop offset=0 style="stop-color:color-mix(in srgb,var(--watcher) 50%,#fff)"/><stop offset=.6 style="stop-color:var(--watcher)"/><stop offset=1 style="stop-color:color-mix(in srgb,var(--watcher) 35%,#000)"/></radialGradient>
20<linearGradient id=gL x1=0 y1=0 x2=0 y2=1><stop offset=0 style="stop-color:color-mix(in srgb,var(--leech) 75%,#fff)"/><stop offset=1 style="stop-color:color-mix(in srgb,var(--leech) 30%,#000)"/></linearGradient>
21<linearGradient id=gMask x1=0 y1=0 x2=1 y2=1><stop offset=0 stop-color=#fbf6ea /><stop offset=.6 stop-color=#cfc6b3 /><stop offset=1 stop-color=#7d7465 /></linearGradient>
22<linearGradient id=gWood x1=0 y1=0 x2=1 y2=0><stop offset=0 stop-color=#8a5b33 /><stop offset=1 stop-color=#3d2412 /></linearGradient>
23<linearGradient id=gIron x1=0 y1=0 x2=0 y2=1><stop offset=0 stop-color=#8b8f94 /><stop offset=1 stop-color=#23262a /></linearGradient>
24<radialGradient id=gHaz><stop offset=0 style="stop-color:var(--hazard);stop-opacity:.7"/><stop offset=1 style="stop-color:var(--hazard);stop-opacity:0"/></radialGradient>
25<radialGradient id=gRelic><stop offset=0 stop-color=#fff /><stop offset=.5 style="stop-color:var(--relic)"/><stop offset=1 style="stop-color:color-mix(in srgb,var(--relic) 30%,#600)"/></radialGradient>`
26const SHADOW=`<ellipse cx=16 cy=28.5 rx=8 ry=2.5 fill=#000 opacity=.45 />`
27const FLAME=(x,y,s)=>`<circle cx=${x} cy=${y} r=${7*s} fill=url(#gGlow) /><path transform="translate(${x} ${y}) scale(${s})" d="M0-6C3-3 4 0 3 2.5 2 4.5-2 4.5-3 2.5-4 0-2-2 0-6Z" fill=url(#gFl) /><path transform="translate(${x} ${y+1}) scale(${s})" d="M0-2.5C1.3-1 1.6.6 1 1.6.4 2.4-.4 2.4-1 1.6-1.6.6-1.2-1 0-2.5Z" fill=#fffbe6 />`
28const STONES=(rs)=>rs.map(([x,y,w,h])=>`<rect x=${x} y=${y} width=${w} height=${h} rx=1.5 fill=url(#gF) stroke=#0004 stroke-width=.5 /><path d="M${x+1.5} ${y+.8}H${x+w-1.5}" stroke=#ffffff14 stroke-width=.7 />`).join("")
29const FLOOR_BASE=`<rect width=32 height=32 style="fill:color-mix(in srgb,var(--floor) 70%,#16160f)"/>`
30const BRICKS=(y0,rows,grad)=>{let s="";for(let r=0;r<rows;r++){const y=y0+r*8,o=r%2?-6:0;for(let x=o;x<32;x+=12)s+=`<rect x=${x+.5} y=${y+.5} width=11 height=7 rx=1 fill=url(#${grad}) stroke=#0007 stroke-width=.6 /><path d="M${x+1.5} ${y+1.3}H${x+10.5}" stroke=#fff3 stroke-width=.7 />`}return s}
31const SPRITES={
32 f0:FLOOR_BASE+STONES([[1,1,14,9],[17,1,14,9],[1,12,9,8],[12,12,19,8],[1,22,18,9],[21,22,10,9]]),
33 f1:FLOOR_BASE+STONES([[1,1,10,12],[13,1,18,6],[13,9,9,11],[24,9,7,11],[1,15,10,16],[13,22,18,9]]),
34 f2:FLOOR_BASE+STONES([[1,1,19,8],[22,1,9,14],[1,11,9,9],[12,11,8,9],[22,17,9,14],[1,22,19,9]])+`<circle cx=11 cy=21 r=.9 fill=#0006 /><path d="M4 26l3 1 2-1.5" stroke=#0008 stroke-width=.6 fill=none />`,
35 w:`<rect width=32 height=32 style="fill:color-mix(in srgb,var(--wall) 30%,#000)"/>`+BRICKS(0,4,"gW"),
36 wf:`<rect width=32 height=32 style="fill:color-mix(in srgb,var(--wall) 20%,#000)"/>`+BRICKS(0,1,"gW")+BRICKS(8,3,"gWF")+`<rect y=8 width=32 height=2 fill=#0006 /><rect y=29 width=32 height=3 fill=#000 opacity=.55 />`,
37 o:`<rect width=32 height=32 fill=url(#gPit) /><path d="M0 3Q8 1 16 3T32 2" stroke=#000 stroke-width=3 opacity=.5 fill=none /><circle cx=11 cy=14 r=.8 style="fill:var(--pit)" opacity=.9 /><circle cx=21 cy=19 r=.6 fill=#ffb070 opacity=.8 /><circle cx=17 cy=10 r=.5 style="fill:var(--pit)" opacity=.7 />`,
38 i:`<rect x=4 y=3 width=24 height=26 rx=2 fill=#060504 style="stroke:var(--exit)" stroke-width=1.4 /><rect x=6 y=7 width=20 height=5 fill=url(#gStep) /><rect x=8 y=13 width=16 height=5 fill=url(#gStep) opacity=.8 /><rect x=10 y=19 width=12 height=4 fill=url(#gStep) opacity=.55 /><rect x=12 y=24 width=8 height=3 fill=url(#gStep) opacity=.3 /><path d="M5 4H27" style="stroke:var(--exit)" stroke-width=1 opacity=.8 /><rect x=4 y=3 width=24 height=26 rx=2 fill=none style="stroke:var(--exit)" stroke-width=3 opacity=.18 />`,
39 p:SHADOW+`<path d="M22 20L26.5 13" stroke=url(#gWood) stroke-width=2 stroke-linecap=round />`+FLAME(26.8,11,.75)+`<path d="M9.5 28.5Q8.5 19 12 15H20Q23.5 19 22.5 28.5Z" fill=url(#gP) stroke=#0008 stroke-width=.7 /><path d="M16 15V28" stroke=#0004 stroke-width=.8 /><rect x=11 y=21 width=10 height=1.6 fill=#3a2716 /><rect x=15 y=20.7 width=2 height=2.2 fill=#d8b35a /><path d="M10 12.5Q10 4.5 16 4.5T22 12.5L20.5 16H11.5Z" fill=url(#gP) stroke=#0008 stroke-width=.7 /><ellipse cx=16 cy=11.5 rx=4 ry=4.2 fill=#e3b98f /><path d="M12 10Q16 5.5 20 10" style="fill:color-mix(in srgb,var(--player) 40%,#000)"/><rect x=13.8 y=11.2 width=1.3 height=1.8 rx=.5 fill=#1b1410 /><rect x=17 y=11.2 width=1.3 height=1.8 rx=.5 fill=#1b1410 /><path d="M22.5 20.5L20 22" stroke=#e3b98f stroke-width=2 stroke-linecap=round />`,
40 B:SHADOW+`<path d="M11 11Q7 7 6 2Q10 5 13 9Z" fill=#dcd2bb stroke=#0008 stroke-width=.6 /><path d="M21 11Q25 7 26 2Q22 5 19 9Z" fill=#dcd2bb stroke=#0008 stroke-width=.6 /><path d="M4.5 28.5C3 20 7 9.5 16 9.5S29 20 27.5 28.5Z" fill=url(#gB) stroke=#000a stroke-width=.8 /><path d="M8 18Q11 16 13 19M24 18Q21 16 19 19" stroke=#0006 stroke-width=.8 fill=none /><path d="M9.5 14.5L14 16.5 13 17.8 9.5 16Z" style="fill:var(--eye,#ffe066)"/><path d="M22.5 14.5L18 16.5 19 17.8 22.5 16Z" style="fill:var(--eye,#ffe066)"/><circle cx=11.8 cy=16.2 r=3 style="fill:var(--eye,#ffe066)" opacity=.25 /><circle cx=20.2 cy=16.2 r=3 style="fill:var(--eye,#ffe066)" opacity=.25 /><path d="M10.5 22H21.5L20 25.5H12Z" fill=#1a0508 /><path d="M11 22L12.2 24 13.4 22 14.6 24 15.8 22 17 24 18.2 22 19.4 24 20.6 22" fill=#f4ecd8 /><path d="M5 28.5l1.5-2 1 2 1.5-2 1 2M22 28.5l1.5-2 1 2 1.5-2 1 2" stroke=#e8dfc8 stroke-width=.8 fill=none />`,
41 S:`<circle cx=16 cy=13 r=13 fill=url(#gHalo) /><path d="M9 29L11 21H21L23 29Z" fill=url(#gStone) stroke=#0008 stroke-width=.6 /><rect x=10 y=19 width=12 height=2.5 rx=.5 fill=#a7a193 stroke=#0008 stroke-width=.5 /><path d="M16 3L21 11 16 19 11 11Z" fill=url(#gCry) stroke=#0006 stroke-width=.5 /><path d="M16 3L16 19M11 11H21" stroke=#fff5 stroke-width=.5 /><path d="M13.5 8L15 6" stroke=#fff stroke-width=.9 stroke-linecap=round />`,
42 r:`<circle cx=16 cy=17 r=11 fill=url(#gGlow) opacity=.7 /><path d="M9 6Q16 14 23 6" stroke=url(#gG) stroke-width=1.2 fill=none stroke-dasharray="1.5 .8" /><circle cx=16 cy=18 r=7.5 fill=url(#gG) stroke=#5a360b stroke-width=.8 /><circle cx=16 cy=18 r=5.5 fill=none stroke=#fff4 stroke-width=.6 stroke-dasharray="1 1.2" /><circle cx=16 cy=18 r=3.6 fill=url(#gRelic) stroke=#4a2a08 stroke-width=.6 /><circle cx=14.8 cy=16.8 r=1 fill=#fff opacity=.9 /><path d="M25 7l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6Z" fill=#fff />`,
43 x:`<circle cx=16 cy=16 r=13 fill=url(#gHaz) opacity=.6 /><path d="M4 7L10 12 8 16 14 18 13 23 19 26 22 30M14 18L20 15 24 17 28 12M10 12L7 11" stroke=#000 stroke-width=3.2 fill=none stroke-linejoin=round /><path d="M4 7L10 12 8 16 14 18 13 23 19 26 22 30M14 18L20 15 24 17 28 12" style="stroke:var(--hazard)" stroke-width=1 fill=none stroke-linejoin=round />`,
44 z:`<circle cx=16 cy=16 r=13 fill=url(#gHaz) opacity=.5 /><circle cx=16 cy=16 r=11 fill=none style="stroke:var(--hazard)" stroke-width=1 opacity=.85 /><circle cx=16 cy=16 r=8.5 fill=none style="stroke:var(--hazard)" stroke-width=.5 stroke-dasharray="2 1.5" /><path d="M16 7.5L23.4 20.3H8.6Z" fill=none style="stroke:var(--hazard)" stroke-width=1.2 /><path d="M16 12V17M16 18.5V19.5" style="stroke:var(--hazard)" stroke-width=1.4 stroke-linecap=round />`,
45 n:`<ellipse cx=16 cy=29 rx=6 ry=1.6 style="fill:var(--shade)" opacity=.2 /><path d="M7 26Q7 5 16 5T25 26Q23 23.5 21.5 26.5 19.5 23.5 17.5 27 16 24 14 27 12 23.5 10.5 26.5 9 23.5 7 26Z" fill=url(#gShade) /><ellipse cx=12.8 cy=13.5 rx=1.8 ry=2.6 fill=#0a0f1e /><ellipse cx=19.2 cy=13.5 rx=1.8 ry=2.6 fill=#0a0f1e /><ellipse cx=16 cy=19.5 rx=1.6 ry=2.2 fill=#0a0f1e opacity=.8 />`,
46 q:`<ellipse cx=16 cy=29 rx=6 ry=1.8 fill=#000 opacity=.4 /><path d="M3 15Q16 1 29 15Q16 29 3 15Z" fill=url(#gEye) stroke=#2a1810 stroke-width=1 /><path d="M7 11.5Q10 13 11 15M25 11.5Q22 13 21 15M8 19Q11 17 12 16" stroke=#c0392b stroke-width=.4 fill=none opacity=.7 /><circle cx=16 cy=15 r=6.2 fill=url(#gIris) stroke=#1a0f08 stroke-width=.6 /><ellipse cx=16 cy=15 rx=1.4 ry=4.6 fill=#050303 /><circle cx=13.7 cy=12.6 r=1.4 fill=#fff opacity=.9 /><path d="M3 15Q16 1 29 15" fill=none stroke=#0006 stroke-width=2.5 />`,
47 l:`<ellipse cx=16 cy=26 rx=11 ry=2.4 fill=#000 opacity=.4 /><path d="M4 22Q3 16 8 16Q10 13 14 14Q17 11 21 13Q26 12 28 17Q30 22 26 24H7Q4 24 4 22Z" fill=url(#gL) stroke=#0009 stroke-width=.7 /><path d="M10 16.5Q11 20 10 24M15 14.5Q16.5 19 15.5 24M21 13.5Q22 18.5 21 24" stroke=#0005 stroke-width=.8 fill=none /><path d="M9 17Q12 15 14 15.5M18 14Q21 13 24 14.5" stroke=#fff6 stroke-width=.8 fill=none stroke-linecap=round /><circle cx=26 cy=19.5 r=3.2 fill=#3a0e12 stroke=#0009 stroke-width=.5 /><path d="M24 18l.8 1.2.8-1.3.8 1.3.8-1.2M24 21l.8-1 .8 1.1.8-1.1.8 1" stroke=#f2e6cf stroke-width=.5 fill=none />`,
48 g:`<ellipse cx=16 cy=27 rx=10 ry=2.2 fill=#000 opacity=.45 /><ellipse cx=10 cy=24 rx=5 ry=2 fill=#7d4f12 /><ellipse cx=10 cy=23 rx=5 ry=2 fill=url(#gG) stroke=#5a360b stroke-width=.5 /><ellipse cx=21 cy=24.5 rx=5 ry=2 fill=#7d4f12 /><ellipse cx=21 cy=23.5 rx=5 ry=2 fill=url(#gG) stroke=#5a360b stroke-width=.5 />`+[20,17,14,11].map(y=>`<ellipse cx=15.5 cy=${y+1} rx=5 ry=2 fill=#7d4f12 /><ellipse cx=15.5 cy=${y} rx=5 ry=2 fill=url(#gG) stroke=#5a360b stroke-width=.5 />`).join("")+`<ellipse cx=15.5 cy=11 rx=2.8 ry=.9 fill=none stroke=#fff7 stroke-width=.5 /><path d="M25 8l.5 1.3 1.3.5-1.3.5-.5 1.3-.5-1.3-1.3-.5 1.3-.5Z" fill=#fff />`,
49 I:`<circle cx=16 cy=12 r=12 fill=url(#gGlow) opacity=.5 /><path d="M8 29H24L23 25H9Z" fill=url(#gStone) stroke=#0008 stroke-width=.6 /><path d="M11 25Q10 17 13 14H19Q22 17 21 25Z" fill=url(#gG) stroke=#5a360b stroke-width=.6 /><path d="M11.5 18Q8 17 8.5 13M20.5 18Q24 17 23.5 13" stroke=url(#gG) stroke-width=1.8 fill=none stroke-linecap=round /><circle cx=16 cy=9.5 r=5 fill=url(#gG) stroke=#5a360b stroke-width=.6 /><path d="M11 7L12.5 3 14.5 6 16 2.5 17.5 6 19.5 3 21 7" fill=url(#gG) stroke=#5a360b stroke-width=.5 /><circle cx=14.2 cy=9.8 r=.9 fill=#c4122a /><circle cx=17.8 cy=9.8 r=.9 fill=#c4122a /><path d="M14 20H18" stroke=#5a360b stroke-width=.6 />`,
50 M:SHADOW+`<path d="M16 4C9 4 7.5 9 7.5 14 7.5 21 11.5 27 16 27S24.5 21 24.5 14C24.5 9 23 4 16 4Z" fill=url(#gMask) stroke=#3a342b stroke-width=.8 /><path d="M10 13.5Q12 11.5 14.2 13.5 12 15.3 10 13.5ZM22 13.5Q20 11.5 17.8 13.5 20 15.3 22 13.5Z" fill=#0b0907 /><path d="M16 14.5V19.5L14.8 20.5M13 23Q16 24.5 19 23" stroke=#5e564a stroke-width=.8 fill=none stroke-linecap=round /><path d="M9.5 9Q11 5.5 15 5" stroke=#fff stroke-width=1 fill=none opacity=.7 stroke-linecap=round /><path d="M7.5 13L4 12M24.5 13L28 12" stroke=#7a2e2e stroke-width=1 />`,
51 T:`<ellipse cx=15 cy=27 rx=7 ry=1.8 fill=#000 opacity=.4 /><path d="M9 27L19 12" stroke=url(#gWood) stroke-width=3 stroke-linecap=round /><path d="M17 15.5L20.5 10.5M16 14.5L19.5 9.5" stroke=#b99b6c stroke-width=1.2 />`+FLAME(20.5,8,1),
52 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),
53 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 />`,
54 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 />`,
56 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 />`
57}
58// Grid-cell kinds from cls() that sit on top of a floor tile.
59const GFX_ON_FLOOR="pBiSrxznqlgIMTFCK"
60let GFX=1
61try{GFX=localStorage.getItem("mrl_gfx")!=="0"}catch(e){}
62document.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>`)
63function 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>`}
65function gfxCell(c,x,y,glyph){
66 const k=c.split(" ")[1]
67 if(!k||k=="u")return ""
68 const use=id=>`<svg viewBox="0 0 32 32"><use href="#s-${id}"/></svg>`
69 if(k=="w")return use(y==H-1||tile(x,y+1)=="#"?"w":"wf")
70 if(k=="o")return use("o")
71 const fl=use("f"+((x*7+y*13)%3))
72 if(k=="f")return fl
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>`
74 return fl+(GFX_ON_FLOOR.includes(k)?use(k):`<b>${glyph}</b>`)
75}
README.org +16 −4
@@ -17,11 +17,13 @@ controls:
1717- space or . wait a turn
1818- t drop torch
1919- m mute / unmute
20- g graphics / ascii tiles
2021- r restart
2122- c continue a saved run (title screen)
2223#+end_example
2324
24on 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.
2527
2628#+begin_example
2729modes (press a number on the title screen):
@@ -31,17 +33,27 @@ modes (press a number on the title screen):
3133
3234win once and a hidden seventh floor opens.
3335
34budget: the game is the two raw files in 1mb/, no build step, and
36budget: the game is the raw files in 1mb/, no build step, and
3537together they stay under 1,048,576 bytes (1 MiB). tools/check.sh is
3638the gate; it prints the byte ledger and proves every escape room
37stays 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
3841docs/SIZES.txt.
3942
4043#+begin_src sh
4144tools/check.sh
4245#+end_src
4346
44run it before each commit:
47tools/playthrough.js plays full runs (floors 1-7, camps, finale)
48through the real engine and replays each win through the key handler.
49it takes 1-3 minutes per seed, so it is not part of check.sh:
50
51#+begin_src sh
52node tools/playthrough.js 20
53node tools/playthrough.js --seeds 8q10qn
54#+end_src
55
56run check.sh before each commit:
4557
4658#+begin_src sh
4759ln -s ../../tools/pre-commit .git/hooks/pre-commit
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, thin-flame fuel | 2026-09-27 | 64,262 | 30,359 | 17,943 | 112,564 | 10.7% |
tools/engine.js added +67
@@ -0,0 +1,67 @@
1"use strict"
2// Loads the real, unmodified 1MB game into a stubbed browser environment:
3// sprites.js, data.js, then the inline engine script from index.html.
4// Returns the vm context; engine globals (S, R, O, step, build, ...) live in it.
5const fs = require("fs")
6const path = require("path")
7const vm = require("vm")
8
9function load(gameDir) {
10 const html = fs.readFileSync(path.join(gameDir, "index.html"), "utf8")
11 const dataSrc = fs.readFileSync(path.join(gameDir, "data.js"), "utf8")
12 const spriteSrc = fs.readFileSync(path.join(gameDir, "sprites.js"), "utf8")
13
14 // The engine is the inline <script> block that follows the data.js include.
15 const m = html.match(/<script src=data\.js><\/script>\s*<script>([\s\S]*?)<\/script>/)
16 if (!m) {
17 console.error("engine: could not find the inline engine <script> after the data.js include in index.html")
18 process.exit(2)
19 }
20 const engineSrc = m[1]
21
22 // Minimal inert DOM node — enough surface for the engine's load-time render().
23 function el() {
24 return {
25 dataset: {},
26 textContent: "",
27 innerHTML: "",
28 className: "",
29 hidden: false,
30 appendChild() {},
31 onclick: null,
32 classList: { toggle() {} },
33 insertAdjacentHTML() {},
34 insertAdjacentText() {},
35 }
36 }
37
38 const sandbox = {
39 document: {
40 getElementById: () => el(),
41 createElement: () => el(),
42 },
43 addEventListener() {},
44 localStorage: {
45 _mem: Object.create(null),
46 getItem(k) { return k in this._mem ? this._mem[k] : null },
47 setItem(k, v) { this._mem[k] = String(v) },
48 removeItem(k) { delete this._mem[k] },
49 },
50 window: { AudioContext: function () { throw new Error("SFX must not run headless") } },
51 console,
52 }
53 const ctx = vm.createContext(sandbox)
54
55 try {
56 vm.runInContext(spriteSrc, ctx, { filename: "1mb/sprites.js" })
57 vm.runInContext(dataSrc, ctx, { filename: "1mb/data.js" })
58 vm.runInContext(engineSrc, ctx, { filename: "1mb/index.html#engine" })
59 } catch (e) {
60 console.error("engine: game code failed to evaluate headless — improve the stubs in this harness, do not touch the game.")
61 console.error(e && e.stack || e)
62 process.exit(2)
63 }
64 return ctx
65}
66
67module.exports = { load }
tools/playthrough.js added +167
@@ -0,0 +1,167 @@
1#!/usr/bin/env node
2// Headless playthrough for the 1MB roguelike.
3// Usage: node tools/playthrough.js [seedCount] [mode] [gameDir]
4// node tools/playthrough.js --seeds <base36,...> [mode] [gameDir]
5//
6// Plays full runs through the real engine: floors 1-5 with a camp choice
7// between each, the finale (floor 6), then the postgame floor 7. Each floor
8// is split into goals (torch, bow, crown + key into the beast den, leave)
9// and each goal is a best-first search over real game states (most torch
10// first), keeping the best end state per flag combination and backtracking
11// when a later goal fails. Every winning
12// input sequence is then replayed from a fresh run through key(), the real
13// input handler, and must end in a win on floor 7.
14"use strict"
15const path = require("path")
16const vm = require("vm")
17
18const only = process.argv[2] == "--seeds" ? process.argv.splice(2, 2)[1].split(",").map(v => parseInt(v, 36)) : null
19const seedCount = only ? only.length : Math.max(1, parseInt(process.argv[2], 10) || 50)
20const mode = process.argv[3] || "standard"
21const gameDir = process.argv[4] || path.join(__dirname, "..", "1mb")
22const ctx = require("./engine").load(gameDir)
23
24vm.runInContext(`
25const BOT_ACTS={U:()=>step(0,-1),D:()=>step(0,1),L:()=>step(-1,0),R:()=>step(1,0),".":()=>wait(),t:()=>dropTorch()}
26const BOT_KEYS={U:"ArrowUp",D:"ArrowDown",L:"ArrowLeft",R:"ArrowRight",".":".",t:"t"}
27const LEG_LIMIT=2000000
28let botBudget=0,botDeep=[0,0]
29const GOAL_NAMES=["torch","bow","crown and key into the den","leave"]
30function botBits(){let b="";for(const k in O)for(const o of O[k])b+=o.on?1:0;return b}
31// Position, flags, overlays, and the turn parity and beast only where they act; torch, ward, dread and chase are compared by dominance.
32function botKey(){const f=S.flags,d=S.drop;return [S.r,S.x,S.y,dark()&&!f.torch?S.turn&3:"",S.gameState,S.level,!!f.torch,f.ash,f.idol,f.gold,f.crown,f.key,f.mask,f.relic,f.relicPlus,S.r=="e"?S.beast.x+","+S.beast.y:"",d?[d.r,d.x,d.y,d.fuel].join():"",botBits()].join("|")}
33function botRes(){return [S.flags.torch,S.flags.ward,-S.dread,-S.chase]}
34// Records the state; false when a recorded state with the same key is at least as good in every resource.
35function botNew(seen){
36 const k=botKey(),v=botRes(),list=seen.get(k)
37 if(!list){seen.set(k,[v]);return true}
38 if(list.some(o=>o.every((x,i)=>x>=v[i])))return false
39 list.push(v);return true
40}
41function botSnap(){return JSON.stringify(S)+"\\u0000"+botBits()}
42function botRestore(p,world){
43 R=world.R;O=world.O;V=world.V
44 const i=p.lastIndexOf("\\u0000"),b=p.slice(i+1)
45 S=JSON.parse(p.slice(0,i))
46 let j=0;for(const k in O)for(const o of O[k])o.on=b[j++]=="1"
47}
48function botFull(){return JSON.stringify({S,R,O,V})}
49function botLoad(f){const x=JSON.parse(f);S=x.S;R=x.R;O=x.O;V=x.V}
50// Distinct end states by route-relevant flags, keeping the most torch for each.
51function botSig(){const f=S.flags;return [S.gameState,S.level,S.r,f.gold,f.mask,f.ward>0,f.relic].join()}
52function botLeg(goal,last){
53 if(goal())return [[botFull(),""]]
54 const world={R,O,V},start=botSnap(),seen=new Map(),buckets=[],ends=new Map()
55 const push=(p,path)=>{const t=Math.min(S.flags.torch,63);(buckets[t]=buckets[t]||{h:0,a:[]}).a.push([p,path])}
56 botNew(seen);push(start,"")
57 let n=0
58 for(;;){
59 let b=buckets.length-1;while(b>=0&&!(buckets[b]&&buckets[b].h<buckets[b].a.length))b--
60 if(b<0||n>=LEG_LIMIT)break
61 const bk=buckets[b],[p,path]=bk.a[bk.h];bk.a[bk.h++]=null
62 for(const a in BOT_ACTS){
63 botRestore(p,world)
64 if((a=="."||a=="t")&&S.r!="e")continue
65 BOT_ACTS[a]();n++
66 if(!S.alive||S.gameState=="dead")continue
67 if(!botNew(seen))continue
68 // Only the last goal (the escape) may wander the beast den.
69 const hit=goal()
70 if(!last&&!hit&&S.r=="e")continue
71 if(hit){
72 const g=botSig(),e=ends.get(g)
73 if(!e||S.flags.torch>e[2])ends.set(g,[botFull(),path+a,S.flags.torch])
74 continue
75 }
76 push(botSnap(),path+a)
77 }
78 }
79 botBudget-=n
80 botRestore(start,world)
81 return [...ends.values()].sort((a,b)=>b[2]-a[2])
82}
83function botGoals(L){
84 const done=()=>S.gameState!="play"||S.level!=L
85 return L==6?[done]:[()=>S.flags.torch>0,()=>S.flags.idol,()=>S.flags.crown&&S.flags.key&&(S.r=="e"||!R.e),done]
86}
87function botSolve(goals,i,cont){
88 if(botBudget<0)return null
89 if(i==goals.length)return cont()
90 const at=[S.level,goals.length==1?3:i];if(at[0]>botDeep[0]||at[0]==botDeep[0]&&at[1]>botDeep[1])botDeep=at
91 for(const [f,path] of botLeg(goals[i],i==goals.length-1)){
92 botLoad(f)
93 const rest=botSolve(goals,i+1,cont)
94 if(rest!=null)return path+rest
95 }
96 return null
97}
98function botRun(){
99 const L=S.level
100 return botSolve(botGoals(L),0,()=>{
101 if(S.gameState=="win"){
102 if(S.level>=7)return ""
103 startPostgame()
104 const r=botRun()
105 return r==null?null:"|p"+r
106 }
107 if(S.gameState=="camp"){
108 const f=botFull()
109 for(const c of "1423"){
110 botLoad(f);applyCampChoice(c)
111 const r=botRun()
112 if(r!=null)return "|"+c+r
113 }
114 return null
115 }
116 if(S.level==6&&S.gameState=="play")return botRun()
117 return null
118 })
119}
120function botPlay(s,mode,budget){
121 seed=s;startRun(mode,0);botBudget=budget;botDeep=[0,0]
122 const moves=botRun()
123 return {moves,left:botBudget,stuck:"floor "+botDeep[0]+" "+GOAL_NAMES[botDeep[1]]}
124}
125function botReplay(s,mode,moves){
126 muted=1
127 seed=s;startRun(mode,0)
128 for(let i=0;i<moves.length;i++){
129 const c=moves[i]
130 if(c=="|"){key({key:moves[++i]});continue}
131 key({key:BOT_KEYS[c]})
132 }
133 return {state:S.gameState,level:S.level,turns:moves.replace(/\\|./g,"").length}
134}
135`, ctx, { filename: "playthrough-bot" })
136
137const seeds = only || JSON.parse(vm.runInContext(
138 `JSON.stringify((() => { const r = rng(1), out = []; for (let i = 0; i < ${seedCount}; i++) out.push(Math.floor(r() * 1e9)); return out })())`,
139 ctx, { filename: "playthrough-seeds" }))
140
141const t0 = Date.now()
142const fails = []
143let totalMoves = 0
144for (const s of seeds) {
145 const res = JSON.parse(vm.runInContext(`JSON.stringify(botPlay(${s}, ${JSON.stringify(mode)}, 30000000))`, ctx))
146 if (res.moves == null) {
147 fails.push({ seed: s, why: `${res.left < 0 ? "search budget exhausted" : "no winning line found"}, deepest: ${res.stuck}` })
148 console.log(` seed ${s.toString(36)}: NOT WON, ${fails[fails.length - 1].why}`)
149 continue
150 }
151 const rep = JSON.parse(vm.runInContext(`JSON.stringify(botReplay(${s}, ${JSON.stringify(mode)}, ${JSON.stringify(res.moves)}))`, ctx))
152 if (rep.state != "win" || rep.level != 7) {
153 fails.push({ seed: s, why: `replay ended ${rep.state} on floor ${rep.level}` })
154 console.log(` seed ${s.toString(36)}: NOT WON, ${fails[fails.length - 1].why}`)
155 continue
156 }
157 totalMoves += rep.turns
158 console.log(` seed ${s.toString(36)}: won in ${rep.turns} moves`)
159}
160const elapsed = ((Date.now() - t0) / 1000).toFixed(1)
161const won = seeds.length - fails.length
162if (fails.length) {
163 console.error(`playthrough: ${fails.length}/${seeds.length} ${mode} runs NOT WON (${elapsed}s)`)
164 for (const f of fails.slice(0, 20)) console.error(` seed ${f.seed.toString(36)}: ${f.why}`)
165 process.exit(1)
166}
167console.log(`playthrough: OK — ${won}/${seeds.length} ${mode} runs won floors 1-7 and replayed through key() (avg ${Math.round(totalMoves / won)} moves, ${elapsed}s)`)
tools/validate.js +30 −50
@@ -8,62 +8,13 @@
88// each full floor completable (gate -> torch -> bow -> vault -> escape
99// room -> gate) — the same check the in-game validateSeeds() runs.
1010"use strict"
11const fs = require("fs")
1211const path = require("path")
1312const vm = require("vm")
1413
1514const seedCount = Math.max(1, parseInt(process.argv[2], 10) || 200)
1615const gameDir = process.argv[3] || path.join(__dirname, "..", "1mb")
1716
18const html = fs.readFileSync(path.join(gameDir, "index.html"), "utf8")
19const dataSrc = fs.readFileSync(path.join(gameDir, "data.js"), "utf8")
20
21// The engine is the inline <script> block that follows the data.js include.
22const m = html.match(/<script src=data\.js><\/script>\s*<script>([\s\S]*?)<\/script>/)
23if (!m) {
24 console.error("validate: could not find the inline engine <script> after the data.js include in index.html")
25 process.exit(2)
26}
27const engineSrc = m[1]
28
29// Minimal inert DOM node — enough surface for the engine's load-time render().
30function el() {
31 return {
32 dataset: {},
33 textContent: "",
34 innerHTML: "",
35 className: "",
36 hidden: false,
37 appendChild() {},
38 onclick: null,
39 }
40}
41
42const sandbox = {
43 document: {
44 getElementById: () => el(),
45 createElement: () => el(),
46 },
47 addEventListener() {},
48 localStorage: {
49 _mem: Object.create(null),
50 getItem(k) { return k in this._mem ? this._mem[k] : null },
51 setItem(k, v) { this._mem[k] = String(v) },
52 removeItem(k) { delete this._mem[k] },
53 },
54 window: { AudioContext: function () { throw new Error("SFX must not run headless") } },
55 console,
56}
57const ctx = vm.createContext(sandbox)
58
59try {
60 vm.runInContext(dataSrc, ctx, { filename: "1mb/data.js" })
61 vm.runInContext(engineSrc, ctx, { filename: "1mb/index.html#engine" })
62} catch (e) {
63 console.error("validate: game code failed to evaluate headless — improve the stubs in this harness, do not touch the game.")
64 console.error(e && e.stack || e)
65 process.exit(2)
66}
17const ctx = require("./engine").load(gameDir)
6718
6819// Runs in the same context, so it sees the engine's top-level let/const
6920// bindings (S, V) and functions (build, pickMods, escapeSolve) directly.
@@ -102,3 +53,32 @@ if (fails.length) {
10253 process.exit(1)
10354}
10455console.log(`validate: OK — ${checks} full floors solvable (${seedCount} seeds x floors 1-7, ${elapsed}s)`)
56
57// Every interactable that can appear on floors 1-7 needs a LEGEND line and
58// every exit destination a ROOM_NAMES entry. Room d kills on entry, so its
59// contents are never shown.
60const missing = JSON.parse(vm.runInContext(`JSON.stringify((() => {
61 const miss = {}
62 for (const s0 of ${seedsJson}) {
63 for (const level of [1, 2, 3, 4, 5, 6, 7]) {
64 S = { level, mods: pickMods(s0), flags: {}, drop: null, seen: {} }
65 build(s0)
66 for (const r in R) {
67 if (r[0] == "d") continue
68 for (const ch in R[r].ex) if (!ROOM_NAMES[R[r].ex[ch].replace("!", "")[0]]) miss["exit " + r + ":" + ch] = level
69 for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
70 const t = tile(x, y, r)
71 if (t != "." && t != "#" && t != ">" && !(t in R[r].ex) && !LEGEND[t]) miss["tile " + t] = level
72 }
73 if (O.effigies.some(o => o.r == r) && !LEGEND.effigy) miss.effigy = level
74 }
75 }
76 }
77 return miss
78})())`, ctx, { filename: "validate-legend" }))
79const gaps = Object.keys(missing)
80if (gaps.length) {
81 console.error(`validate: ${gaps.length} interactables without a legend entry: ${gaps.map(g => `${g} (floor ${missing[g]})`).join(", ")}`)
82 process.exit(1)
83}
84console.log("validate: OK — every interactable has a legend entry")