0
GAMES#1f25433a
ECHOGRAVE
@Owner·deposited 1w ago·updated 1w ago·24 views
GAMES#1f25433a
ECHOGRAVE
OW
@Owner
24Views
0Comments
0Forks
0Saves
SHARE · REMIX
ECHOGRAVE — a HTML Games widget by @Owner.
CONTROLS
No comments yet. Be the first!
✦ Remix with AI
SDK in this widgetNo Vibes SDK features detected yet
Generated prompt
You are helping me modify a vibe-coded widget from itjustvibes.com.
[VIBE CODE: "ECHOGRAVE" by @Owner]
Source: https://itjustvibes.com/Owner/echograve
Type: HTML
--- SOURCE CODE ---
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>ECHOGRAVE</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=VT323&display=swap" rel="stylesheet">
<style>
:root{
--gold:#d9a441; --parch:#e8dcc0; --ink:#0d0a12; --stone:#211b2a;
--ember:#ff7b3d; --storm:#4fd6ff; --grave:#c08bff;
}
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
html,body{height:100%;background:#080610;overflow:hidden;font-family:'VT323',monospace;color:var(--parch);}
body{display:flex;align-items:center;justify-content:center;
background:
radial-gradient(circle at 50% 30%, #1a1420 0%, #0a0710 60%, #050309 100%);
touch-action:none;}
#wrap{position:relative;width:100%;max-width:560px;aspect-ratio:4/3;}
#game{width:100%;height:100%;display:block;image-rendering:pixelated;image-rendering:crisp-edges;
border:3px solid #2c2436; border-radius:4px;
box-shadow:0 0 0 2px #110d18, 0 14px 60px -10px #000, inset 0 0 60px #000;
background:#0a0710; cursor:crosshair;}
/* vignette + grain over the canvas */
#wrap::after{content:"";position:absolute;inset:3px;border-radius:3px;pointer-events:none;
box-shadow:inset 0 0 90px 10px rgba(0,0,0,.85);
background:repeating-linear-gradient(0deg,rgba(255,255,255,.012) 0 1px,transparent 1px 3px);
mix-blend-mode:overlay;}
/* HUD */
#hud{position:absolute;top:8px;left:10px;right:10px;display:flex;justify-content:space-between;
align-items:flex-start;pointer-events:none;font-family:'Press Start 2P';font-size:9px;
text-shadow:2px 2px 0 #000;z-index:5;}
.hpwrap{display:flex;flex-direction:column;gap:5px;}
.hearts{display:flex;gap:3px;}
.heart{width:11px;height:11px;background:
conic-gradient(from 0deg,#ff3b5b,#b3122f);clip-path:polygon(50% 100%,0 35%,20% 0,50% 25%,80% 0,100% 35%);
filter:drop-shadow(1px 1px 0 #000);}
.heart.empty{background:#39303f;}
#runinfo{text-align:right;color:var(--gold);line-height:1.8;}
#relicTag{color:var(--parch);font-size:8px;}
#muteBtn{position:absolute;top:8px;right:10px;}/* placeholder */
/* overlay screens */
#overlay{position:absolute;inset:3px;display:flex;flex-direction:column;align-items:center;
justify-content:center;text-align:center;padding:18px;z-index:20;border-radius:3px;
background:radial-gradient(circle at 50% 40%,rgba(20,14,28,.86),rgba(6,4,12,.97));
backdrop-filter:blur(2px);}
#overlay.hidden{display:none;}
.title{font-family:'Press Start 2P';font-size:clamp(20px,6vw,34px);letter-spacing:2px;
color:var(--parch);text-shadow:0 0 18px rgba(192,139,255,.5),3px 3px 0 #000;margin-bottom:4px;
animation:flick 4s infinite;}
@keyframes flick{0%,97%,100%{opacity:1}98%{opacity:.7}99%{opacity:.92}}
.subtitle{font-size:21px;color:var(--gold);letter-spacing:3px;margin-bottom:18px;opacity:.9;}
.blurb{font-size:19px;max-width:380px;line-height:1.25;color:#bcae93;margin-bottom:22px;}
.blurb b{color:var(--grave);font-weight:normal;}
.relicRow{display:flex;gap:12px;flex-wrap:wrap;justify-content:center;margin-bottom:8px;}
.relicCard{width:120px;padding:12px 10px;border:2px solid #38304a;border-radius:6px;cursor:pointer;
background:linear-gradient(180deg,#1d1726,#120d1b);transition:transform .12s,border-color .12s,box-shadow .12s;}
.relicCard:hover{transform:translateY(-4px);border-color:var(--ac);box-shadow:0 0 22px -4px var(--ac);}
.relicCard .ic{font-family:'Press Start 2P';font-size:22px;color:var(--ac);
text-shadow:0 0 12px var(--ac);margin-bottom:8px;}
.relicCard .nm{font-family:'Press Start 2P';font-size:9px;margin-bottom:7px;letter-spacing:.5px;}
.relicCard .ds{font-size:16px;line-height:1.15;color:#9c8fb0;}
.btn{font-family:'Press Start 2P';font-size:11px;color:var(--ink);background:var(--gold);
border:none;padding:13px 22px;border-radius:5px;cursor:pointer;letter-spacing:1px;
box-shadow:0 4px 0 #8a6418;transition:transform .08s,box-shadow .08s;}
.btn:active{transform:translateY(3px);box-shadow:0 1px 0 #8a6418;}
.hint{font-size:17px;color:#7a6f8c;margin-top:16px;line-height:1.4;}
.deathline{font-size:20px;color:#bcae93;margin-bottom:6px;}
.graveline{font-size:18px;color:var(--grave);margin-bottom:18px;}
/* touch controls */
#touch{position:absolute;inset:3px;z-index:8;pointer-events:none;display:none;}
#stick{position:absolute;left:18px;bottom:18px;width:108px;height:108px;border-radius:50%;
background:radial-gradient(circle,rgba(255,255,255,.06),rgba(255,255,255,.02));
border:2px solid rgba(255,255,255,.12);pointer-events:auto;}
#knob{position:absolute;left:34px;top:34px;width:40px;height:40px;border-radius:50%;
background:radial-gradient(circle at 40% 35%,#d8c9a8,#7a6f56);border:2px solid #000;}
.tbtn{position:absolute;width:74px;height:74px;border-radius:50%;pointer-events:auto;
font-family:'Press Start 2P';font-size:10px;color:#000;display:flex;align-items:center;
justify-content:center;border:3px solid #000;text-shadow:none;}
#atkBtn{right:20px;bottom:34px;background:radial-gradient(circle at 40% 35%,#ff9a63,#c0451f);}
#dashBtn{right:104px;bottom:20px;width:58px;height:58px;background:radial-gradient(circle at 40% 35%,#7fe4ff,#1f7fb0);}
.tbtn:active{filter:brightness(1.3);}
</style>
</head>
<body>
<div id="wrap">
<canvas id="game"></canvas>
<div id="hud">
<div class="hpwrap">
<div class="hearts" id="hearts"></div>
<div id="relicTag" style="font-size:8px;"></div>
</div>
<div id="runinfo">
<div id="runNum">RUN 1</div>
<div id="foeNum" style="font-size:8px;color:#bcae93;">FOES 0</div>
</div>
</div>
<div id="touch">
<div id="stick"><div id="knob"></div></div>
<div class="tbtn" id="atkBtn">ATK</div>
<div class="tbtn" id="dashBtn">DSH</div>
</div>
<div id="overlay">
<div class="title">ECHOGRAVE</div>
<div class="subtitle">-- A SLICE --</div>
<div class="blurb">A gravekeeper who can't stay dead. Clear the crypt. But every build you lose returns as an <b>Echo</b> -- a ghost of your past self that hunts you next run.</div>
<button class="btn" id="startBtn">DESCEND</button>
<div class="hint">MOVE wasd / arrows · ATTACK click / space<br>DASH shift · aim with mouse</div>
</div>
</div>
<script>
(()=>{
"use strict";
// ---------- virtual resolution ----------
const VW=320, VH=240;
const cv=document.getElementById('game');
const ctx=cv.getContext('2d');
// offscreen low-res buffer
const buf=document.createElement('canvas'); buf.width=VW; buf.height=VH;
const b=buf.getContext('2d');
b.imageSmoothingEnabled=false; ctx.imageSmoothingEnabled=false;
function resize(){
const r=cv.getBoundingClientRect();
const dpr=Math.min(2,window.devicePixelRatio||1);
cv.width=Math.floor(r.width*dpr); cv.height=Math.floor(r.height*dpr);
ctx.imageSmoothingEnabled=false;
}
window.addEventListener('resize',resize);
// ---------- audio ----------
let AC=null, master=null, muted=false, droneNode=null;
function initAudio(){
if(AC) return;
AC=new (window.AudioContext||window.webkitAudioContext)();
master=AC.createGain(); master.gain.value=0.5; master.connect(AC.destination);
// ambient drone
const o=AC.createOscillator(); o.type='sine'; o.frequency.value=46;
const o2=AC.createOscillator(); o2.type='triangle'; o2.frequency.value=69;
const g=AC.createGain(); g.gain.value=0.06;
const lp=AC.createBiquadFilter(); lp.type='lowpass'; lp.frequency.value=300;
o.connect(g); o2.connect(g); g.connect(lp); lp.connect(master);
o.start(); o2.start(); droneNode=g;
}
function sfx(type){
if(!AC||muted) return;
const t=AC.currentTime;
const g=AC.createGain(); g.connect(master);
if(type==='swing'){
const o=AC.createOscillator(); o.type='sawtooth';
o.frequency.setValueAtTime(420,t); o.frequency.exponentialRampToValueAtTime(120,t+.12);
g.gain.setValueAtTime(.12,t); g.gain.exponentialRampToValueAtTime(.001,t+.13);
o.connect(g); o.start(t); o.stop(t+.14);
}else if(type==='hit'){
const o=AC.createOscillator(); o.type='square';
o.frequency.setValueAtTime(180,t); o.frequency.exponentialRampToValueAtTime(60,t+.1);
const n=noiseBurst(t,.08,.18);
g.gain.setValueAtTime(.22,t); g.gain.exponentialRampToValueAtTime(.001,t+.12);
o.connect(g); o.start(t); o.stop(t+.12);
}else if(type==='zap'){
const o=AC.createOscillator(); o.type='square';
o.frequency.setValueAtTime(900,t); o.frequency.exponentialRampToValueAtTime(280,t+.09);
g.gain.setValueAtTime(.1,t); g.gain.exponentialRampToValueAtTime(.001,t+.1);
o.connect(g); o.start(t); o.stop(t+.1);
}else if(type==='die'){
const o=AC.createOscillator(); o.type='sawtooth';
o.frequency.setValueAtTime(220,t); o.frequency.exponentialRampToValueAtTime(40,t+.4);
g.gain.setValueAtTime(.25,t); g.gain.exponentialRampToValueAtTime(.001,t+.45);
o.connect(g); o.start(t); o.stop(t+.46);
}else if(type==='hurt'){
const o=AC.createOscillator(); o.type='triangle';
o.frequency.setValueAtTime(300,t); o.frequency.exponentialRampToValueAtTime(90,t+.18);
g.gain.setValueAtTime(.3,t); g.gain.exponentialRampToValueAtTime(.001,t+.2);
o.connect(g); o.start(t); o.stop(t+.2);
}else if(type==='pickup'){
[600,900,1350].forEach((f,i)=>{
const o=AC.createOscillator(); o.type='square'; o.frequency.value=f;
const gg=AC.createGain(); gg.connect(master);
gg.gain.setValueAtTime(.0001,t+i*.05); gg.gain.linearRampToValueAtTime(.12,t+i*.05+.01);
gg.gain.exponentialRampToValueAtTime(.001,t+i*.05+.12);
o.connect(gg); o.start(t+i*.05); o.stop(t+i*.05+.13);
});
}else if(type==='dash'){
const n=noiseBurst(t,.14,.18);
}else if(type==='echo'){
const o=AC.createOscillator(); o.type='sine';
o.frequency.setValueAtTime(120,t); o.frequency.exponentialRampToValueAtTime(420,t+.5);
g.gain.setValueAtTime(.0001,t); g.gain.linearRampToValueAtTime(.14,t+.1);
g.gain.exponentialRampToValueAtTime(.001,t+.6);
o.connect(g); o.start(t); o.stop(t+.62);
}
}
function noiseBurst(t,dur,vol){
const n=AC.createBufferSource(); const buf2=AC.createBuffer(1,AC.sampleRate*dur,AC.sampleRate);
const d=buf2.getChannelData(0); for(let i=0;i<d.length;i++) d[i]=(Math.random()*2-1)*Math.pow(1-i/d.length,2);
n.buffer=buf2; const g=AC.createGain(); g.gain.value=vol;
const hp=AC.createBiquadFilter(); hp.type='highpass'; hp.frequency.value=800;
n.connect(hp); hp.connect(g); g.connect(master); n.start(t); return n;
}
// ---------- relics ----------
const RELICS={
ember:{name:'EMBERBRAND', ic:'\u2694', color:'#ff7b3d', css:'--ember',
desc:'Wide cleaving arc. Heavy, slow, brutal.', kind:'melee', cd:0.42},
storm:{name:'STORMCOIL', ic:'\u26a1', color:'#4fd6ff', css:'--storm',
desc:'Rapid bolts. Fragile but relentless.', kind:'ranged', cd:0.16},
grave:{name:'GRAVEWAIL', ic:'\u2620', color:'#c08bff', css:'--grave',
desc:'Orbiting bone shards. Pulse to repel.', kind:'orbit', cd:0.9},
};
const RELIC_KEYS=Object.keys(RELICS);
// ---------- game state ----------
let state='menu'; // menu, relicSelect, play, dead, cleared
let run=1, graveyard=[]; // each: {relic, color}
let player=null, enemies=[], projectiles=[], particles=[], dmgNums=[], pickups=[], slashFx=[], echoIntros=[];
let shake=0, hitstop=0, flash=0, time=0;
const keys={};
let mouse={x:VW/2,y:VH/2,down:false};
let moveVec={x:0,y:0}, facing={x:0,y:1};
let touchMode=('ontouchstart' in window)||navigator.maxTouchPoints>0;
const ARENA={x:18,y:24,w:VW-36,h:VH-42};
function dist(a,b){return Math.hypot(a.x-b.x,a.y-b.y);}
function clamp(v,lo,hi){return v<lo?lo:v>hi?hi:v;}
function rnd(a,b){return a+Math.random()*(b-a);}
// ---------- entities ----------
function makePlayer(relicKey){
return {x:VW/2,y:ARENA.y+ARENA.h-24,r:6,hp:5,maxhp:5,relic:relicKey,
iframe:0,atkCd:0,dashCd:0,dashing:0,flashHit:0,swingDir:1,orbitA:0};
}
function spawnWisp(){
const edge=Math.floor(rnd(0,4));
let x,y;
if(edge===0){x=ARENA.x+8;y=rnd(ARENA.y,ARENA.y+ARENA.h);}
else if(edge===1){x=ARENA.x+ARENA.w-8;y=rnd(ARENA.y,ARENA.y+ARENA.h);}
else if(edge===2){x=rnd(ARENA.x,ARENA.x+ARENA.w);y=ARENA.y+8;}
else {x=rnd(ARENA.x,ARENA.x+ARENA.w);y=ARENA.y+ARENA.h-8;}
return {type:'wisp',x,y,r:5,hp:3,maxhp:3,spawn:0.6,kb:{x:0,y:0},flashHit:0,bob:rnd(0,7)};
}
function spawnEcho(g){
return {type:'echo',relic:g.relic,color:g.color,x:rnd(ARENA.x+20,ARENA.x+ARENA.w-20),
y:ARENA.y+18,r:6,hp:9,maxhp:9,spawn:1.0,atkCd:rnd(.4,1),kb:{x:0,y:0},flashHit:0,orbitA:0};
}
function startRun(relicKey){
player=makePlayer(relicKey);
enemies=[]; projectiles=[]; particles=[]; dmgNums=[]; pickups=[]; slashFx=[]; echoIntros=[];
const wisps=3+Math.min(run,4);
for(let i=0;i<wisps;i++) enemies.push(spawnWisp());
// echoes from graveyard (most recent up to 4)
const recent=graveyard.slice(-4);
recent.forEach(g=>{const e=spawnEcho(g); enemies.push(e); echoIntros.push({x:e.x,y:e.y,t:0});});
if(recent.length) sfx('echo');
state='play';
updateHud();
}
// ---------- particles & fx ----------
function burst(x,y,color,n,spd){
for(let i=0;i<n;i++){const a=rnd(0,Math.PI*2),s=rnd(spd*.3,spd);
particles.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,life:rnd(.3,.7),max:.7,color,sz:rnd(1,2.4)});}
}
function dmgNum(x,y,n,color){dmgNums.push({x,y,n:Math.round(n),life:.7,color});}
function addShake(v){shake=Math.min(9,shake+v);}
// ---------- combat ----------
function playerAttack(){
const R=RELICS[player.relic];
if(player.atkCd>0) return;
player.atkCd=R.cd;
let ax=facing.x, ay=facing.y;
if(!touchMode){const dx=mouse.x-player.x,dy=mouse.y-player.y,m=Math.hypot(dx,dy)||1;ax=dx/m;ay=dy/m;}
if(R.kind==='melee'){
sfx('swing');
player.swingDir*=-1;
const ang=Math.atan2(ay,ax);
slashFx.push({x:player.x,y:player.y,ang,life:.18,max:.18,color:R.color,dir:player.swingDir,reach:26});
enemies.forEach(e=>{
if(e.spawn>0) return;
const d=dist(player,e); if(d>28) return;
const ea=Math.atan2(e.y-player.y,e.x-player.x);
let diff=Math.abs(((ea-ang+Math.PI*3)%(Math.PI*2))-Math.PI);
if(diff<1.1){ damageEnemy(e,3.2,ax,ay,3.4); }
});
addShake(2.5);
}else if(R.kind==='ranged'){
sfx('zap');
const sp=rnd(-.12,.12), ca=Math.atan2(ay,ax)+sp;
projectiles.push({x:player.x+ax*8,y:player.y+ay*8,vx:Math.cos(ca)*220,vy:Math.sin(ca)*220,
r:2,dmg:1.6,life:1.1,color:R.color,from:'player',trail:[]});
addShake(0.8);
}else if(R.kind==='orbit'){
sfx('swing');
// pulse: shove shards outward, damaging ring
player.orbitPulse=0.3;
enemies.forEach(e=>{ if(e.spawn>0)return; const d=dist(player,e);
if(d<34){const a=Math.atan2(e.y-player.y,e.x-player.x);damageEnemy(e,2.4,Math.cos(a),Math.sin(a),4.5);}});
burst(player.x,player.y,R.color,14,90);
addShake(2);
}
}
function damageEnemy(e,dmg,nx,ny,kb){
e.hp-=dmg; e.flashHit=0.12;
e.kb.x+=nx*kb*16; e.kb.y+=ny*kb*16;
dmgNum(e.x,e.y-8,dmg,'#fff');
burst(e.x,e.y,e.type==='echo'?e.color:'#ff5e7a',6,70);
sfx('hit'); hitstop=Math.max(hitstop,0.045); addShake(1.4);
if(e.hp<=0) killEnemy(e);
}
function killEnemy(e){
const i=enemies.indexOf(e); if(i<0)return; enemies.splice(i,1);
burst(e.x,e.y,e.type==='echo'?e.color:'#ff5e7a',e.type==='echo'?26:14,e.type==='echo'?150:110);
addShake(e.type==='echo'?5:2.5); sfx('die');
if(e.type==='echo'){
flash=Math.max(flash,.4);
pickups.push({x:e.x,y:e.y,r:4,color:e.color,bob:0,relic:e.relic});
}
updateHud();
}
function hurtPlayer(dmg,nx,ny){
if(player.iframe>0||player.dashing>0) return;
player.hp-=dmg; player.iframe=0.9; player.flashHit=0.3;
player.x+=nx*10; player.y+=ny*10;
addShake(6); flash=Math.max(flash,.5); sfx('hurt'); hitstop=Math.max(hitstop,.07);
burst(player.x,player.y,'#ff3b5b',12,90);
updateHud();
if(player.hp<=0) die();
}
function die(){
state='dead';
graveyard.push({relic:player.relic,color:RELICS[player.relic].color});
burst(player.x,player.y,RELICS[player.relic].color,40,180);
addShake(9); flash=.7; sfx('die');
setTimeout(showDeath,700);
}
function clearedRun(){
state='cleared';
graveyard.push({relic:player.relic,color:RELICS[player.relic].color});
sfx('pickup');
setTimeout(showCleared,500);
}
// ---------- update ----------
function update(dt){
time+=dt;
if(state!=='play') { stepParticles(dt); return; }
// input vector
let ix=0,iy=0;
if(keys['w']||keys['arrowup'])iy-=1;
if(keys['s']||keys['arrowdown'])iy+=1;
if(keys['a']||keys['arrowleft'])ix-=1;
if(keys['d']||keys['arrowright'])ix+=1;
if(touchMode){ix=moveVec.x;iy=moveVec.y;}
const m=Math.hypot(ix,iy);
if(m>0.15){ix/=m||1;iy/=m||1; facing.x=ix; facing.y=iy;}
else {ix=0;iy=0;}
// dash
if((keys['shift']||keys['_dash'])&&player.dashCd<=0&&(m>0.15)){
player.dashing=0.16; player.dashCd=0.7; player.iframe=Math.max(player.iframe,0.18);
sfx('dash'); burst(player.x,player.y,'#d8c9a8',10,60);
}
let spd=72;
if(player.dashing>0){spd=220; player.dashing-=dt;
if(Math.random()<0.6)particles.push({x:player.x,y:player.y,vx:rnd(-8,8),vy:rnd(-8,8),life:.3,max:.3,color:'#b9ac8c',sz:2});}
player.x+=ix*spd*dt; player.y+=iy*spd*dt;
player.x=clamp(player.x,ARENA.x+player.r,ARENA.x+ARENA.w-player.r);
player.y=clamp(player.y,ARENA.y+player.r,ARENA.y+ARENA.h-player.r);
player.atkCd-=dt; player.dashCd-=dt; player.iframe-=dt; player.flashHit-=dt;
player.orbitA+=dt*4;
if(player.orbitPulse>0)player.orbitPulse-=dt;
if((keys['_atk']||mouse.down||keys[' '])&&state==='play') playerAttack();
// orbit shard damage (gravewail)
if(player.relic==='grave'){
const rad=18+(player.orbitPulse>0?16:0);
for(let k=0;k<3;k++){
const a=player.orbitA+k*(Math.PI*2/3);
const sx=player.x+Math.cos(a)*rad, sy=player.y+Math.sin(a)*rad;
enemies.forEach(e=>{if(e.spawn>0)return; if(Math.hypot(e.x-sx,e.y-sy)<e.r+3){
if(!e._oc||e._oc<=0){const dx=e.x-player.x,dy=e.y-player.y,mm=Math.hypot(dx,dy)||1;
damageEnemy(e,1.1,dx/mm,dy/mm,1.5); e._oc=0.25;}}});
}
}
enemies.forEach(e=>{if(e._oc>0)e._oc-=dt;});
// enemies
enemies.forEach(e=>{
if(e.spawn>0){e.spawn-=dt; return;}
e.flashHit-=dt;
const dx=player.x-e.x, dy=player.y-e.y, d=Math.hypot(dx,dy)||1;
// knockback decay
e.x+=e.kb.x*dt; e.y+=e.kb.y*dt; e.kb.x*=Math.pow(.001,dt); e.kb.y*=Math.pow(.001,dt);
if(e.type==='wisp'){
e.bob+=dt*6;
const s=34;
e.x+=dx/d*s*dt; e.y+=dy/d*s*dt;
if(d<e.r+player.r+1) hurtPlayer(1,dx/d*-1,dy/d*-1);
}else if(e.type==='echo'){
const R=RELICS[e.relic];
let want= R.kind==='ranged'?70 : R.kind==='melee'?18 : 14;
const s=46;
if(Math.abs(d-want)>6){const dir=d>want?1:-1; e.x+=dx/d*s*dt*dir; e.y+=dy/d*s*dt*dir;}
else {e.x+=(-dy/d)*s*.5*dt; e.y+=(dx/d)*s*.5*dt;} // strafe
e.x=clamp(e.x,ARENA.x+e.r,ARENA.x+ARENA.w-e.r);
e.y=clamp(e.y,ARENA.y+e.r,ARENA.y+ARENA.h-e.r);
e.atkCd-=dt; e.orbitA+=dt*4;
if(e.atkCd<=0){
e.atkCd=R.cd*1.6;
if(R.kind==='ranged'){
sfx('zap');
projectiles.push({x:e.x,y:e.y,vx:dx/d*150,vy:dy/d*150,r:2,dmg:1,life:1.4,color:R.color,from:'echo',trail:[]});
}else if(R.kind==='melee'){
if(d<26){const ang=Math.atan2(dy,dx);
slashFx.push({x:e.x,y:e.y,ang,life:.18,max:.18,color:R.color,dir:1,reach:22});
hurtPlayer(1,-dx/d,-dy/d);}
}
}
// echo orbit / melee contact
if(R.kind==='orbit'){const rad=16;for(let k=0;k<3;k++){const a=e.orbitA+k*2.09;
const sx=e.x+Math.cos(a)*rad,sy=e.y+Math.sin(a)*rad;
if(Math.hypot(player.x-sx,player.y-sy)<player.r+3)hurtPlayer(1,-dx/d,-dy/d);}}
if(d<e.r+player.r){hurtPlayer(1,-dx/d,-dy/d);}
}
});
// projectiles
for(let i=projectiles.length-1;i>=0;i--){
const p=projectiles[i];
p.trail.push({x:p.x,y:p.y}); if(p.trail.length>6)p.trail.shift();
p.x+=p.vx*dt; p.y+=p.vy*dt; p.life-=dt;
let dead=p.life<=0||p.x<ARENA.x||p.x>ARENA.x+ARENA.w||p.y<ARENA.y||p.y>ARENA.y+ARENA.h;
if(p.from==='player'){
enemies.forEach(e=>{if(e.spawn>0||dead)return; if(dist(p,e)<e.r+p.r){
const m2=Math.hypot(p.vx,p.vy)||1; damageEnemy(e,p.dmg,p.vx/m2,p.vy/m2,2); dead=true;}});
}else{
if(dist(p,player)<player.r+p.r){const m2=Math.hypot(p.vx,p.vy)||1;hurtPlayer(1,p.vx/m2,p.vy/m2);dead=true;}
}
if(dead){burst(p.x,p.y,p.color,5,60); projectiles.splice(i,1);}
}
// pickups
for(let i=pickups.length-1;i>=0;i--){const pk=pickups[i]; pk.bob+=dt*5;
if(dist(pk,player)<player.r+6){
player.maxhp+=1; player.hp=Math.min(player.maxhp,player.hp+1);
burst(pk.x,pk.y,pk.color,18,120); sfx('pickup'); flash=Math.max(flash,.3);
dmgNum(player.x,player.y-12,0,'#aef'); dmgNums[dmgNums.length-1].txt='+FRAGMENT';
pickups.splice(i,1); updateHud();
}
}
stepParticles(dt);
if(enemies.length===0 && pickups.length===0 && state==='play') clearedRun();
}
function stepParticles(dt){
for(let i=particles.length-1;i>=0;i--){const p=particles[i];
p.x+=p.vx*dt;p.y+=p.vy*dt;p.vx*=Math.pow(.02,dt);p.vy*=Math.pow(.02,dt);p.vy+=20*dt;p.life-=dt;
if(p.life<=0)particles.splice(i,1);}
for(let i=dmgNums.length-1;i>=0;i--){const d=dmgNums[i];d.y-=18*dt;d.life-=dt;if(d.life<=0)dmgNums.splice(i,1);}
for(let i=slashFx.length-1;i>=0;i--){slashFx[i].life-=dt;if(slashFx[i].life<=0)slashFx.splice(i,1);}
for(let i=echoIntros.length-1;i>=0;i--){echoIntros[i].t+=dt;if(echoIntros[i].t>1)echoIntros.splice(i,1);}
}
// ---------- render ----------
const dust=[];for(let i=0;i<28;i++)dust.push({x:rnd(0,VW),y:rnd(0,VH),s:rnd(2,7),ph:rnd(0,7)});
function render(){
// floor
b.fillStyle='#0c0912'; b.fillRect(0,0,VW,VH);
// tiles
for(let ty=ARENA.y;ty<ARENA.y+ARENA.h;ty+=16){
for(let tx=ARENA.x;tx<ARENA.x+ARENA.w;tx+=16){
const ck=((tx/16|0)+(ty/16|0))%2;
b.fillStyle=ck?'#1c1726':'#191320';
b.fillRect(tx,ty,16,16);
b.fillStyle='rgba(0,0,0,.25)'; b.fillRect(tx,ty,16,1); b.fillRect(tx,ty,1,16);
}
}
// walls (top thick, sides)
b.fillStyle='#322a40'; b.fillRect(ARENA.x-6,ARENA.y-8,ARENA.w+12,8);
b.fillStyle='#241d30';
b.fillRect(ARENA.x-6,ARENA.y,4,ARENA.h); b.fillRect(ARENA.x+ARENA.w+2,ARENA.y,4,ARENA.h);
b.fillRect(ARENA.x-6,ARENA.y+ARENA.h,ARENA.w+12,4);
// torches
const tor=[[ARENA.x+10,ARENA.y-4],[ARENA.x+ARENA.w-10,ARENA.y-4]];
tor.forEach(([tx,ty])=>{const fl=Math.sin(time*9+tx)*1.5;
b.fillStyle='#3a2f1a'; b.fillRect(tx-1,ty,3,6);
b.fillStyle='#ffb347'; b.fillRect(tx-1,ty-3,3,3);
b.fillStyle='#fff0b0'; b.fillRect(tx,ty-2-(fl>0?1:0),1,2);});
// dust motes
dust.forEach(d=>{const yy=(d.y+Math.sin(time*0.6+d.ph)*4);
b.fillStyle='rgba(220,210,170,'+(0.05+0.04*Math.sin(time+d.ph))+')'; b.fillRect(d.x|0,yy|0,1,1);});
if(state==='play'||state==='dead'||state==='cleared'){
// echo intro telegraphs
echoIntros.forEach(ei=>{const a=1-ei.t;const rr=4+ei.t*22;
b.strokeStyle='rgba(192,139,255,'+a*0.8+')';b.lineWidth=1;
b.beginPath();b.arc(ei.x,ei.y,rr,0,Math.PI*2);b.stroke();});
// pickups
pickups.forEach(pk=>{const yo=Math.sin(pk.bob)*2;
b.save();b.translate(pk.x,pk.y+yo);
b.fillStyle=pk.color;b.globalAlpha=.9;
b.fillRect(-2,-2,4,4);b.globalAlpha=.4;b.fillRect(-3,-3,6,6);b.restore();
b.fillStyle=pk.color;for(let k=0;k<4;k++){const a=time*3+k*1.57;
b.fillRect(pk.x+Math.cos(a)*6,pk.y+yo+Math.sin(a)*6,1,1);}});
// particles (behind actors)
particles.forEach(p=>{b.globalAlpha=clamp(p.life/p.max,0,1);b.fillStyle=p.color;
b.fillRect(p.x-p.sz/2,p.y-p.sz/2,p.sz,p.sz);});b.globalAlpha=1;
// enemies
enemies.forEach(e=>{
if(e.spawn>0){const a=1-e.spawn;b.globalAlpha=a*0.6;
b.fillStyle=e.type==='echo'?e.color:'#6f6a8a';
b.fillRect(e.x-2,e.y-2,4,4);b.globalAlpha=1;return;}
if(e.type==='wisp')drawWisp(e); else drawEcho(e);
});
// slashes
slashFx.forEach(s=>{const a=s.life/s.max;b.save();b.translate(s.x,s.y);
b.rotate(s.ang);b.strokeStyle=s.color;b.globalAlpha=a;b.lineWidth=2;
b.beginPath();b.arc(0,0,s.reach,-0.9*s.dir,0.9*s.dir);b.stroke();
b.globalAlpha=a*.3;b.lineWidth=5;b.stroke();b.restore();b.globalAlpha=1;});
// player
if(state==='play'||state==='cleared') drawPlayer();
// projectiles
projectiles.forEach(p=>{p.trail.forEach((t,i)=>{b.globalAlpha=i/p.trail.length*.6;
b.fillStyle=p.color;b.fillRect(t.x-1,t.y-1,2,2);});b.globalAlpha=1;
b.fillStyle='#fff';b.fillRect(p.x-1,p.y-1,2,2);
b.fillStyle=p.color;b.fillRect(p.x-2,p.y-2,4,4);b.globalAlpha=.3;b.fillRect(p.x-3,p.y-3,6,6);b.globalAlpha=1;});
// damage numbers
dmgNums.forEach(d=>{b.globalAlpha=clamp(d.life/.7,0,1);
b.font='6px "Press Start 2P", monospace';b.textAlign='center';
b.fillStyle='#000';b.fillText(d.txt||d.n,d.x+1,d.y+1);
b.fillStyle=d.txt?'#aef':d.color;b.fillText(d.txt||d.n,d.x,d.y);b.globalAlpha=1;});
b.textAlign='left';
}
// lighting pass: darkness with light holes
b.globalCompositeOperation='multiply';
const lg=b.createRadialGradient(VW/2,VH/2,10,VW/2,VH/2,VH*0.9);
lg.addColorStop(0,'#ffffff');lg.addColorStop(1,'#3a3550');
b.fillStyle=lg;b.fillRect(0,0,VW,VH);
if(player&&(state==='play'||state==='cleared')){
const pl=b.createRadialGradient(player.x,player.y,4,player.x,player.y,46);
const pc=RELICS[player.relic].color;
pl.addColorStop(0,'#fff');pl.addColorStop(1,'#000');
b.globalCompositeOperation='screen';b.globalAlpha=.18;b.fillStyle=pc;
b.beginPath();b.arc(player.x,player.y,40,0,Math.PI*2);b.fill();b.globalAlpha=1;
}
b.globalCompositeOperation='source-over';
// hit flash
if(flash>0){b.fillStyle='rgba(255,90,110,'+flash*0.5+')';b.fillRect(0,0,VW,VH);}
// ---- blit to screen with shake ----
const sx=(Math.random()*2-1)*shake, sy=(Math.random()*2-1)*shake;
ctx.clearRect(0,0,cv.width,cv.height);
ctx.drawImage(buf, sx/VW*cv.width|0, sy/VH*cv.height|0, cv.width, cv.height);
}
function drawPlayer(){
const c=RELICS[player.relic].color;
const blink=player.iframe>0&&Math.floor(time*20)%2===0;
if(blink)return;
b.save();b.translate(player.x|0,player.y|0);
// shadow
b.fillStyle='rgba(0,0,0,.4)';b.fillRect(-4,4,8,3);
// body (cloak)
const body=player.flashHit>0?'#ffffff':'#cdbf9c';
b.fillStyle='#2a2436';b.fillRect(-4,-2,8,9); // cloak
b.fillStyle=body;b.fillRect(-3,-7,6,6); // head/hood
b.fillStyle=c;b.fillRect(-3,-1,6,2); // belt glow
b.fillStyle='#000';b.fillRect(-2,-5,1,1);b.fillRect(1,-5,1,1); // eyes
// facing marker / weapon nub
b.fillStyle=c;b.fillRect((facing.x*5)-1,(facing.y*5)-1,2,2);
b.restore();
// gravewail shards
if(player.relic==='grave'){const rad=18+(player.orbitPulse>0?16:0);
for(let k=0;k<3;k++){const a=player.orbitA+k*2.09;
b.fillStyle=c;b.fillRect((player.x+Math.cos(a)*rad)|0,(player.y+Math.sin(a)*rad)|0,2,2);
b.globalAlpha=.4;b.fillRect((player.x+Math.cos(a)*rad)-1|0,(player.y+Math.sin(a)*rad)-1|0,4,4);b.globalAlpha=1;}}
}
function drawWisp(e){
b.save();b.translate(e.x|0,(e.y+Math.sin(e.bob)*1.5)|0);
b.fillStyle='rgba(0,0,0,.35)';b.fillRect(-3,4,6,2);
const col=e.flashHit>0?'#fff':'#7d77a0';
b.globalAlpha=.85;b.fillStyle=col;b.fillRect(-3,-3,6,6);
b.fillStyle=e.flashHit>0?'#fff':'#a89fd0';b.fillRect(-2,-4,4,3);
b.globalAlpha=1;b.fillStyle='#ff5e7a';b.fillRect(-2,-2,1,1);b.fillRect(1,-2,1,1);
b.restore();
}
function drawEcho(e){
const c=e.flashHit>0?'#fff':e.color;
b.save();b.translate(e.x|0,e.y|0);
// ghostly aura
b.globalAlpha=.18+0.06*Math.sin(time*4);b.fillStyle=e.color;
b.beginPath();b.arc(0,0,10,0,Math.PI*2);b.fill();b.globalAlpha=1;
b.fillStyle='rgba(0,0,0,.3)';b.fillRect(-4,4,8,2);
b.fillStyle='#1b1626';b.fillRect(-4,-2,8,8);
b.globalAlpha=.92;b.fillStyle=c;b.fillRect(-3,-7,6,6);
b.globalAlpha=1;b.fillStyle='#000';b.fillRect(-2,-5,1,1);b.fillRect(1,-5,1,1);
b.fillStyle=c;b.fillRect(-3,-1,6,1);
b.restore();
if(e.relic==='grave'){const rad=16;for(let k=0;k<3;k++){const a=e.orbitA+k*2.09;
b.fillStyle=e.color;b.fillRect((e.x+Math.cos(a)*rad)|0,(e.y+Math.sin(a)*rad)|0,2,2);}}
}
// ---------- HUD / overlays ----------
const heartsEl=document.getElementById('hearts');
function updateHud(){
if(!player)return;
let h='';for(let i=0;i<player.maxhp;i++)h+='<div class="heart'+(i<player.hp?'':' empty')+'"></div>';
heartsEl.innerHTML=h;
document.getElementById('relicTag').textContent=RELICS[player.relic].name;
document.getElementById('relicTag').style.color=RELICS[player.relic].color;
document.getElementById('runNum').textContent='RUN '+run;
document.getElementById('foeNum').textContent='FOES '+(enemies?enemies.length:0);
}
const overlay=document.getElementById('overlay');
function showOverlay(html){overlay.innerHTML=html;overlay.classList.remove('hidden');bindOverlay();}
function hideOverlay(){overlay.classList.add('hidden');}
function relicCards(){
return RELIC_KEYS.map(k=>{const r=RELICS[k];
return `<div class="relicCard" data-relic="${k}" style="--ac:${r.color}">
<div class="ic">${r.ic}</div><div class="nm" style="color:${r.color}">${r.name}</div>
<div class="ds">${r.desc}</div></div>`;}).join('');
}
function showRelicSelect(){
const ghosts=graveyard.slice(-4);
const ghostTxt=ghosts.length? `<div class="graveline">${ghosts.length} Echo${ghosts.length>1?'es':''} stir in the crypt -- your past builds, hunting you.</div>`
: `<div class="graveline">The crypt is quiet... for now.</div>`;
showOverlay(`<div class="subtitle" style="margin-bottom:6px;color:var(--gold)">RUN ${run}</div>
<div class="title" style="font-size:20px;margin-bottom:14px">CHOOSE YOUR RELIC</div>
${ghostTxt}
<div class="relicRow">${relicCards()}</div>
<div class="hint">Whatever you pick becomes an Echo when this run ends.</div>`);
}
function showDeath(){
const r=RELICS[player.relic];
showOverlay(`<div class="title" style="color:#ff5e7a;font-size:26px">YOU FELL</div>
<div class="deathline">Your <b style="color:${r.color}">${r.name}</b> build is entombed.</div>
<div class="graveline">It will rise as an Echo next descent.</div>
<button class="btn" id="againBtn">DESCEND AGAIN</button>
<div class="hint">Graveyard: ${graveyard.length} build${graveyard.length>1?'s':''} laid to rest</div>`);
}
function showCleared(){
const r=RELICS[player.relic];
showOverlay(`<div class="title" style="color:#8fe28f;font-size:24px">CRYPT CLEARED</div>
<div class="deathline">You retire the <b style="color:${r.color}">${r.name}</b> and move on.</div>
<div class="graveline">Even a victorious self joins the crypt. Go deeper -- it'll be waiting.</div>
<button class="btn" id="againBtn">DESCEND DEEPER</button>
<div class="hint">Graveyard: ${graveyard.length} build${graveyard.length>1?'s':''}</div>`);
}
function bindOverlay(){
const sb=document.getElementById('startBtn');
if(sb)sb.onclick=()=>{initAudio();run=1;graveyard=[];state='relicSelect';showRelicSelect();};
const ag=document.getElementById('againBtn');
if(ag)ag.onclick=()=>{run++;state='relicSelect';showRelicSelect();};
document.querySelectorAll('.relicCard').forEach(c=>c.onclick=()=>{
hideOverlay();startRun(c.dataset.relic);});
}
bindOverlay();
// ---------- input ----------
addEventListener('keydown',e=>{const k=e.key.toLowerCase();keys[k]=true;
if([' ','arrowup','arrowdown','arrowleft','arrowright'].includes(k))e.preventDefault();});
addEventListener('keyup',e=>{keys[e.key.toLowerCase()]=false;});
cv.addEventListener('mousemove',e=>{const r=cv.getBoundingClientRect();
mouse.x=(e.clientX-r.left)/r.width*VW; mouse.y=(e.clientY-r.top)/r.height*VH;});
cv.addEventListener('mousedown',e=>{initAudio();mouse.down=true;});
addEventListener('mouseup',()=>mouse.down=false);
// touch controls
if(touchMode){
document.getElementById('touch').style.display='block';
const stick=document.getElementById('stick'),knob=document.getElementById('knob');
let sid=null,scx=0,scy=0;
stick.addEventListener('touchstart',e=>{e.preventDefault();initAudio();const t=e.changedTouches[0];
sid=t.identifier;const r=stick.getBoundingClientRect();scx=r.left+r.width/2;scy=r.top+r.height/2;},{passive:false});
function moveStick(cx,cy){let dx=cx-scx,dy=cy-scy;const m=Math.hypot(dx,dy),max=40;
if(m>max){dx=dx/m*max;dy=dy/m*max;}knob.style.left=(34+dx)+'px';knob.style.top=(34+dy)+'px';
moveVec.x=dx/max;moveVec.y=dy/max;}
addEventListener('touchmove',e=>{for(const t of e.changedTouches)if(t.identifier===sid)moveStick(t.clientX,t.clientY);},{passive:false});
addEventListener('touchend',e=>{for(const t of e.changedTouches)if(t.identifier===sid){
sid=null;moveVec.x=0;moveVec.y=0;knob.style.left='34px';knob.style.top='34px';}});
const ab=document.getElementById('atkBtn');
ab.addEventListener('touchstart',e=>{e.preventDefault();initAudio();keys['_atk']=true;},{passive:false});
ab.addEventListener('touchend',e=>{e.preventDefault();keys['_atk']=false;},{passive:false});
const db=document.getElementById('dashBtn');
db.addEventListener('touchstart',e=>{e.preventDefault();keys['_dash']=true;setTimeout(()=>keys['_dash']=false,60);},{passive:false});
}
// ---------- loop ----------
let last=performance.now();
function frame(now){
let dt=(now-last)/1000; last=now; if(dt>0.05)dt=0.05;
shake*=Math.pow(0.0001,dt); if(shake<0.05)shake=0;
flash*=Math.pow(0.002,dt); if(flash<0.01)flash=0;
if(hitstop>0){hitstop-=dt;} else { update(dt); }
render();
requestAnimationFrame(frame);
}
resize();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
```
[REQUESTED CHANGES]
(no specific request — apply your best judgment)
--- HOW TO RESPOND (READ FIRST) ---
Before writing any code, follow this exact process:
1. **ANALYZE** the widget source code provided above and identify:
a. Which Vibes SDK features it already uses (vibes.save, vibes.load, vibes.shared.join, etc.)
b. Which SDK features would genuinely benefit THIS specific widget — tailored to what it does, not a dump of everything available.
2. **PRESENT A NUMBERED LIST** covering:
- SDK features currently active in this widget
- New SDK features that would concretely improve this widget (be specific: why this widget, what it enables)
3. **WAIT** — do not write any code yet. Reply with your analysis and numbered list, then stop and ask the user which numbered items they want.
4. **IMPLEMENT ONLY** the items the user confirms, plus any explicit change they requested. Do not add unrequested features.
**IMPORTANT — Shared state room names:**
If you add vibes.shared.join(), do NOT use a hardcoded string literal as the room name (e.g. vibes.shared.join("lobby")) unless the user explicitly wants ALL viewers to share one single global state. A hardcoded room name means every person who visits this widget reads and writes the same shared state — it is a global room. For per-user or per-session isolation, derive the room name from a variable (e.g. a user ID, session token, or random value). When in doubt, use vibes.save/vibes.load for per-user persistence instead.
--- VIBES SDK CONTEXT ---
## Vibes SDK Reference
You are building an HTML widget for It Just Vibes (itjustvibes.com). The Vibes SDK is auto-injected — do NOT add a script tag. Just use `window.vibes` (or just `vibes`).
### Setup
Wrap your startup code in `vibes.onReady`:
```js
vibes.onReady(async () => {
const saved = await vibes.load("myKey");
// your widget logic here
});
```
### State (Per-User Persistence)
Every user gets their own isolated state per widget. All methods return Promises.
| Method | Description |
|--------|-------------|
| `await vibes.save(key, value)` | Save JSON-serializable data |
| `await vibes.load(key)` | Load saved data (returns `null` if not found) |
| `await vibes.delete(key)` | Delete a saved key |
| `await vibes.listKeys()` | Get array of all saved key names |
**Key rules:**
- Keys are strings, max 64 characters, alphanumeric + dashes/underscores
- Values must be JSON-serializable (objects, arrays, strings, numbers, booleans)
- Max 100KB per value, 500KB per widget per user, 5MB per user total
- Max 100 keys per widget per user
### Fetch Proxy
Use direct `fetch()` for APIs with permissive CORS headers (`Access-Control-Allow-Origin: *`). Use `vibes.fetch` for APIs without CORS headers (the proxy handles cross-origin requests):
```js
const resp = await vibes.fetch("https://api.example.com/data", {
method: "GET", // GET, POST, PUT, DELETE
headers: {}, // optional headers
body: null, // optional body (string)
timeout: null // optional timeout in ms
});
const data = await resp.json(); // or resp.text()
console.log(resp.status, resp.ok);
```
### Multiplayer (Shared State)
Real-time shared state across all users viewing the same widget.
```js
// Join a room (call once at startup)
await vibes.shared.join("lobby", { persistent: true });
// Set shared state (broadcasts to all users)
await vibes.shared.set("score", { player1: 10, player2: 7 });
// Read shared state (synchronous, returns last known value)
const score = vibes.shared.get("score");
// Listen for changes to a specific key
vibes.shared.onChange("score", (newValue) => {
console.log("Score updated:", newValue);
});
// Listen for any shared state change
vibes.shared.onAny((key, value) => {
console.log(key, "changed to", value);
});
// Get number of connected users
const count = await vibes.shared.getUserCount();
// Leave room
vibes.shared.leave();
// Clear all shared state for this room
await vibes.shared.clear();
```
**Shared state options:**
- `{ persistent: true }` — state survives page reloads (stored server-side)
- Default room name is "__default__" if omitted
### Rules
1. **Do NOT use localStorage, sessionStorage, or window.storage** — they are blocked or undefined in the sandbox. Use `vibes.save`/`vibes.load` instead.
2. **Do NOT add a script tag to import the SDK** — it is auto-injected.
3. **Wrap startup code in `vibes.onReady()`** — the SDK may not be ready immediately.
4. **Await all SDK calls** — every method (except `vibes.shared.get`) returns a Promise.
5. **Do NOT import external JS libraries via script tags** — they will be blocked. Include library code inline or use a CDN link in a `<link>` tag for CSS only.
6. **Keep total code under 80KB** — that is the default widget size limit (your account limit may be higher).
### Rate Limits
| Operation | Limit |
|-----------|-------|
| Writes (save + delete combined) | 30/min |
| Reads (load) | 60/min |
| List keys | 30/min |
| Fetch proxy | Rate limited per widget |
### Error Handling
All SDK methods can reject. Wrap in try/catch:
```js
try {
await vibes.save("key", value);
} catch (err) {
console.error("Save failed:", err.message);
}
```
Fetch proxy errors include `err.code`: `"RATE_LIMITED"`, `"BLOCKED"`, `"FETCH_ERROR"`.
### Data Export
Export rows of data as CSV or Excel directly from your widget.
```js
// Register dataset for the platform's "Export data" button
// AND optionally trigger an immediate download
vibes.exportData(rows, { filename: 'results.csv' })
// rows: Array of arrays (each inner array is one row; first row = headers)
// options.filename: sets the download filename and format (csv or xlsx)
// options.directDownload: false to only register without downloading (default: true)
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `filename` | string | `'data.csv'` | Download filename; extension determines format (`.csv` or `.xlsx`) |
| `directDownload` | boolean | `true` | Trigger immediate browser download in addition to registering the dataset |
**Notes:**
- First call registers the dataset so the widget chrome shows an "Export data" button
- Use `.csv` extension for CSV, `.xlsx` for Excel
- CSV injection safety is automatic (formula-starting cells prefixed with `'`)
- Data stays in the browser — no server round-trip
--- FORK & RESUBMIT INSTRUCTION ---
Return the complete, self-contained HTML file with all changes applied.
It should be ready to paste into itjustvibes.com/submit to create a new fork.
Include this comment at the top of the output:
/* Forked from: https://itjustvibes.com/Owner/echograve */