web
2025-12-20
concepts
Description
test
Code
<!DOCTYPE html><html lang="en"><head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width,initial-scale=1" /> <title>Deterministic Roguelike (Demo)</title> <style> :root{ /* Force dark mode only as requested */ color-scheme: dark; --bg: light-dark(#0b1220, #0b1220); --surface: light-dark(#0f1724, #111216); --muted: light-dark(#94a3b8, #94a3b8); --primary: #3B82F6; --secondary: #8B5CF6; --warning: #F59E0B; --error: #EF4444; --text: #e6eef8; --accent: var(--secondary); --radius: 6px; font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial; --ui-z: 1000; } html,body{height:100%;margin:0;background:var(--bg);color:var(--text);} *{box-sizing:border-box} /* Layout */ .app{ display:flex;flex-direction:column;height:100vh;gap:12px;padding:16px; -webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale; } /* Game area */ .viewport{ position:relative;flex:1;display:flex;align-items:center;justify-content:center; background:linear-gradient(180deg, rgba(255,255,255,0.01), rgba(255,255,255,0.01)); border-radius:8px;overflow:hidden;border:1px solid rgba(255,255,255,0.03); } canvas#gameCanvas{display:block;width:100%;height:100%;background:transparent} /* HUD overlay */ .hud{position:absolute;inset:0;pointer-events:none;z-index:var(--ui-z);} .hud .layer{position:absolute;pointer-events:auto} /* Top-left health */ .health{left:16px;top:16px;width:220px} .health .bar{width:200px;height:20px;background:rgba(255,255,255,0.04);border-radius:6px;overflow:hidden;border:1px solid rgba(255,255,255,0.03);display:flex;align-items:center;padding:4px} .health .fill{height:100%;background:linear-gradient(90deg,var(--primary),var(--accent));width:50%;transition:width 220ms ease} .health .text{margin-left:8px;color:var(--text);font-weight:600;font-size:14px} /* Top-center timer */ .timer{top:16px;left:50%;transform:translateX(-50%);text-align:center} .timer .big{font-size:18px;font-weight:700;letter-spacing:-0.02em} .timer .lvl{font-size:12px;color:var(--muted)} /* Top-right gold & weapons */ .top-right{right:16px;top:16px;text-align:right} .gold{display:flex;align-items:center;gap:8px;padding:6px 8px;background:rgba(255,255,255,0.02);border-radius:6px;border:1px solid rgba(255,255,255,0.03)} .gold .icon{width:18px;height:18px;border-radius:4px;background:var(--warning)} .weapons{display:flex;flex-direction:column;gap:6px;margin-top:10px} .weapon{width:28px;height:28px;border-radius:6px;background:rgba(255,255,255,0.03);display:flex;align-items:center;justify-content:center;font-size:12px} /* Bottom full-width exp bar */ .exp{left:0;right:0;bottom:12px;padding:0 16px} .exp .barwrap{height:10px;background:rgba(255,255,255,0.03);border-radius:6px;overflow:hidden;border:1px solid rgba(255,255,255,0.03)} .exp .fill{height:100%;background:linear-gradient(90deg,var(--secondary),var(--primary));width:30%;transition:width 220ms ease} /* Passives bottom-right */ .passives{right:16px;bottom:16px;display:flex;gap:8px} .passive{width:28px;height:28px;border-radius:6px;background:rgba(255,255,255,0.02);display:flex;align-items:center;justify-content:center} /* Level-up modal */ .modal-backdrop{position:absolute;inset:0;background:rgba(2,6,23,0.6);display:flex;align-items:center;justify-content:center;z-index:2000} .modal{background:var(--surface);padding:18px;border-radius:10px;min-width:320px;border:1px solid rgba(255,255,255,0.04)} .modal h3{margin:0 0 8px 0;font-size:18px} .options{display:flex;gap:10px} .option{flex:1;padding:10px;border-radius:8px;background:linear-gradient(180deg, rgba(255,255,255,0.01), rgba(255,255,255,0.01));border:1px solid rgba(255,255,255,0.03);cursor:pointer} .option:hover{outline:2px solid rgba(59,130,246,0.12)} .option .title{font-weight:600} .option .desc{font-size:13px;color:var(--muted)} /* System messages */ .sys{position:fixed;left:16px;bottom:120px;background:rgba(0,0,0,0.4);padding:8px 12px;border-radius:6px;font-size:13px} /* Minimal buttons */ button.btn{appearance:none;border:0;background:var(--primary);color:white;padding:8px 12px;border-radius:6px;cursor:pointer} button.btn.secondary{background:var(--secondary)} /* Accessibility */ .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} </style></head><body> <div class="app"> <div class="viewport" id="viewport"> <canvas id="gameCanvas" width="1280" height="720" aria-hidden="true"></canvas> <div class="hud" id="hud"> <div class="layer health" id="hud-health"> <div class="bar"> <div class="fill" id="hp-fill" style="width:100%"></div> <div class="text" id="hp-text">100 / 100</div> </div> </div> <div class="layer timer" id="hud-timer"> <div class="big" id="timer-text">00:00</div> <div class="lvl" id="level-text">Lv.1</div> </div> <div class="layer top-right" id="hud-top-right"> <div class="gold" id="gold-box"><div class="icon" aria-hidden></div><div id="gold-text">0</div></div> <div class="weapons" id="weapons-list" aria-hidden></div> </div> <div class="layer exp" id="hud-exp"> <div class="barwrap"><div class="fill" id="exp-fill" style="width:10%"></div></div> </div> <div class="layer passives" id="hud-passives"></div> </div> <!-- Level-up modal template --> <template id="tmpl-levelup"> <div class="modal-backdrop"> <div class="modal" role="dialog" aria-modal="true"> <h3>Level Up! Choose an upgrade</h3> <div class="options"></div> <div style="margin-top:12px;text-align:right"><button class="btn" data-action="close">Close</button></div> </div> </div> </template> <!-- System messages --> <div id="sys-msg" class="sys" style="display:none"></div> </div> <div style="display:flex;gap:8px;align-items:center;justify-content:flex-end"> <button id="btn-save" class="btn secondary">Save Now</button> <button id="btn-load" class="btn">Load</button> <div style="flex:1"></div> <div style="font-size:13px;color:var(--muted)">Session demo — determinism & systems implemented</div> </div> </div> <script type="module"> // Wait for DOM document.addEventListener('DOMContentLoaded', () => { // Application code in module scope class Utils { static clamp(v,min,max){return Math.max(min,Math.min(max,v))} static length(x,y){return Math.hypot(x,y)} static normalize(x,y){const l=Math.hypot(x,y);return l>0?{x:x/l,y:y/l}:{x:0,y:0}} static lerp(a,b,t){return a + (b-a)*t} static now(){return performance.now()} static floor(v){return Math.floor(v)} } // Seeded RNG using SplitMix64 -> xorshift64* outputs mapped to [0,1) class SeededRNG { constructor(seed64){this._state = BigInt(seed64) & ((1n<<64n)-1n)} // splitmix64 next nextU64(){ this._state = (this._state + 0x9E3779B97F4A7C15n) & ((1n<<64n)-1n); let z = this._state; z = (z ^ (z >> 30n)) * 0xBF58476D1CE4E5B9n & ((1n<<64n)-1n); z = (z ^ (z >> 27n)) * 0x94D049BB133111EBn & ((1n<<64n)-1n); z = z ^ (z >> 31n); return z & ((1n<<64n)-1n); } next(){ // float in [0,1) const u = this.nextU64(); // take top 53 bits const mant = Number(u >> 11n) & ((1<<53)-1); return mant / Math.pow(2,53); } nextBetween(a,b){return a + (b-a)*this.next()} // deterministic shuffle using Fisher-Yates shuffle(array){ for(let i=array.length-1;i>0;i--){const j = Math.floor(this.next()*(i+1));[array[i],array[j]]=[array[j],array[i]]} } weightedChoice(weights){ // weights array of numbers const total = weights.reduce((s,w)=>s+w,0); if(total <= 0) return 0; let r = this.next()*total; for(let i=0;i<weights.length;i++){r -= weights[i]; if(r<=0) return i} return weights.length-1 } } // Spatial Hash with cell_size 8 units class SpatialHash { constructor(cellSize=8){ this.cellSize = cellSize; this._cells = new Map(); } _key(ix,iy){return ix+','+iy} _cellCoords(pos){return [Math.floor(pos.x/this.cellSize), Math.floor(pos.y/this.cellSize)];} clear(){this._cells.clear()} insert(entity){const [ix,iy]=this._cellCoords(entity.pos);const k=this._key(ix,iy);if(!this._cells.has(k))this._cells.set(k,[]);this._cells.get(k).push(entity)} query(pos,radius){ const minx = Math.floor((pos.x - radius)/this.cellSize); const maxx = Math.floor((pos.x + radius)/this.cellSize); const miny = Math.floor((pos.y - radius)/this.cellSize); const maxy = Math.floor((pos.y + radius)/this.cellSize); const out = []; for(let x=minx;x<=maxx;x++)for(let y=miny;y<=maxy;y++){const k=this._key(x,y);const list=this._cells.get(k);if(list)out.push(...list)} return out; } } // Entity factories and IDs let globalIdCounter = 1n; function nextId(){return Number(globalIdCounter++)} // Game constants const FIXED_DELTA = 1/60; // required const FIXED_DELTA_MS = FIXED_DELTA*1000; const GAME_DURATION = 1200; // seconds (20 minutes) const SPATIAL_CELL = 8; // required // Default world const DEFAULT_WORLD = { width:1920, height:1080 }; const CAMERA_RADIUS = 300; // arbitrary camera radius for spawn calculations const SPAWN_MARGIN = 50; // Application main class GameState { constructor(seed){ this.schema_version = 2; this.rng_seed = BigInt(seed ?? Date.now()); this.game_frame = 0n; // u64 this.game_time = 0.0; this.player = createDefaultPlayer(); this.enemies = []; // stable array iteration this.projectiles = []; this.gems = []; this.spatial_hash = new SpatialHash(SPATIAL_CELL); this.pending_spawns = []; this.is_simulation_paused = false; this.is_level_up_pending = false; this.accumulator = 0; this.gold = 0; this.logs = []; } toSerializable(){ return { schema_version: this.schema_version, rng_seed: String(this.rng_seed), game_frame: String(this.game_frame), player: this.player, enemies: this.enemies.map(e=>({id:e.id,pos:e.pos,hp:e.hp,base_hp:e.base_hp,enemy_type:e.enemy_type})), gold: this.gold, unlocked_items: [] } } } function createDefaultPlayer(){ return { id: nextId(), pos:{x: DEFAULT_WORLD.width/2, y: DEFAULT_WORLD.height/2}, speed: 120, // units / second health: 100, max_health: 100, weapons: [createDefaultWeapon(1)], passives: [], collision_radius: 0.5, invuln_timer: 0, experience: 0, level: 1, input_buffer: [ {x:0,y:0}, {x:0,y:0}, {x:0,y:0} ] } } function createDefaultWeapon(id){ return { id: id, weapon_id: id, last_fired_time: -9999, cooldown: 0.25, range: 400, projectile_speed: 400, base_damage: 10, base_pierces: 1, type: 'projectile' } } function createEnemy(type, pos, baseHp=10, baseDamage=5, baseSpeed=40, rng){ const id = nextId(); // scale stats per game minutes deterministic: simple formula const minutes = (Number(app.state.game_time) / 60) || 0; const hp = baseHp * Math.pow(1.0 + minutes * 0.08, 1.6); const damage = baseDamage * Math.pow(1.0 + minutes * 0.05, 1.2); const speed = baseSpeed * (1 + minutes*0.01); return { id: id, pos:{x:pos.x,y:pos.y}, hp: hp, base_hp: baseHp, damage: damage, base_damage: baseDamage, speed: speed, base_speed: baseSpeed, stuck_time: 0, last_pos: {x:pos.x,y:pos.y}, enemy_type: type } } function createProjectile(ownerId,pos,velocity,speed,range,basePierces){ const nowFrame = Number(app.state.game_frame); return { id: nextId(), pos:{x:pos.x,y:pos.y}, velocity:{x:velocity.x,y:velocity.y}, speed:speed, range:range, lifetime: range / speed, collision_radius: 0.3, base_pierces: basePierces, times_hit:0, spawn_frame: nowFrame } } function createGem(pos, base_exp=10){ const id = nextId(); const minutes = Number(app.state.game_time) / 60; const exp_value = Math.floor(base_exp * (1 + minutes * 0.02)); return { id: id, pos:{x:pos.x,y:pos.y}, exp_value: exp_value, pickup_state: 'idle', pickup_range: 4.0, spawn_frame: Number(app.state.game_frame) } } // Application class class Application { constructor(){ // DOM refs this.canvas = document.getElementById('gameCanvas'); this.ctx = this.canvas.getContext('2d'); this.hud = document.getElementById('hud'); this.hpFill = document.getElementById('hp-fill'); this.hpText = document.getElementById('hp-text'); this.timerText = document.getElementById('timer-text'); this.levelText = document.getElementById('level-text'); this.goldText = document.getElementById('gold-text'); this.expFill = document.getElementById('exp-fill'); this.weaponsList = document.getElementById('weapons-list'); this.passivesBox = document.getElementById('hud-passives'); this.sysMsg = document.getElementById('sys-msg'); // validation checks for required capabilities this._validateApiSupport(); // initial state & rng const seed = 123456789n; // fixed for demo reproducibility; could be randomized this.state = new GameState(seed); this.state.rng_seed = BigInt(seed); this.rng = new SeededRNG(this.state.rng_seed + BigInt(this.state.game_frame)); // timers & profiling this.lastRAF = performance.now(); this._running = true; this._accumulator = 0; this._lastFixedTime = performance.now(); this._frameTimes = []; // camera & world this.world = Object.assign({}, DEFAULT_WORLD); this.camera = { x: this.state.player.pos.x, y: this.state.player.pos.y, radius: CAMERA_RADIUS }; // input this.keys = new Set(); this.gamepadIndex = null; // UI templates this.tmplLevelup = document.getElementById('tmpl-levelup'); // bind events this._bindUI(); this._bindInput(); // Start initial demo content: spawn a few enemies deterministically this._seedInitialWave(); // fixed update checks this._enforceConstraints(); // start loop requestAnimationFrame(this._loop.bind(this)); // autosave timer this._setupAutosave(); } _validateApiSupport(){ // Check for required browser APIs used const missing = []; if(!window.requestAnimationFrame) missing.push('requestAnimationFrame'); if(!window.localStorage) missing.push('localStorage'); if(!window.crypto || !window.crypto.subtle) missing.push('crypto.subtle (required for checksum)'); if(!window.performance) missing.push('performance'); if(missing.length){ const msg = 'Unsupported browser APIs: ' + missing.join(', '); console.error(msg); this._showSys(msg,true); } } _enforceConstraints(){ // Fixed delta if(Math.abs(FIXED_DELTA - (1/60)) > 1e-9){ const msg = 'Fixed timestep violation: FIXED_DELTA must be 1/60s'; console.error(msg);this._showSys(msg,true); } // Spatial hash cell size if(this.state.spatial_hash.cellSize !== SPATIAL_CELL){ const msg = 'Spatial hash misconfiguration: cell_size must be 8';console.error(msg);this._showSys(msg,true); } } _bindUI(){ document.getElementById('btn-save').addEventListener('click',()=>this.saveToLocalStorage()); document.getElementById('btn-load').addEventListener('click',()=>this.loadFromLocalStorage()); } _bindInput(){ window.addEventListener('keydown', (e)=>{this.keys.add(e.key);}); window.addEventListener('keyup', (e)=>{this.keys.delete(e.key);}); window.addEventListener('gamepadconnected',(e)=>{this.gamepadIndex = e.gamepad.index;console.log('Gamepad connected',e.gamepad)}); window.addEventListener('gamepaddisconnected',(e)=>{if(this.gamepadIndex===e.gamepad.index)this.gamepadIndex=null}); } _seedInitialWave(){ // schedule an initial deterministic wave this.scheduleWave(8, [1,1,1], 2); } _setupAutosave(){ setInterval(()=>{this.saveToLocalStorage().catch(err=>console.warn('Autosave failed',err))}, 60*1000); } _loop(now){ if(!this._running) return; const deltaMs = Math.min(100, now - this.lastRAF); this.lastRAF = now; this._accumulator += deltaMs; // Fixed update loop using accumulator in ms while(this._accumulator >= FIXED_DELTA_MS){ if(!this.state.is_simulation_paused){ this._updateFixed(FIXED_DELTA); } this._accumulator -= FIXED_DELTA_MS; } const interp = this._accumulator / FIXED_DELTA_MS; this._render(interp); // performance tracking const frameTime = deltaMs; this._frameTimes.push(frameTime); if(this._frameTimes.length>120) this._frameTimes.shift(); requestAnimationFrame(this._loop.bind(this)); } _updateFixed(dt){ // UpdateStep (strict order enforced) // 1. collect_inputs -> input_buffer[game_frame % 3] const idx = Number(this.state.game_frame % 3n); const input = this._collectInputs(); this.state.player.input_buffer[idx] = input; // 2. player.update this._updatePlayer(input, dt); // 3. spatial_hash.clear(); insert player this.state.spatial_hash.clear(); this.state.spatial_hash.insert({pos:this.state.player, id:this.state.player.id}); // 4. Enemies update for(let i=0;i<this.state.enemies.length;i++){ const e = this.state.enemies[i]; this._enemyAIUpdate(e, dt); this.state.spatial_hash.insert(e); } // 5. Projectiles update for(let i=this.state.projectiles.length-1;i>=0;i--){ const p = this.state.projectiles[i]; p.pos.x += p.velocity.x * dt; p.pos.y += p.velocity.y * dt; this.state.spatial_hash.insert(p); // lifetime check const age = (Number(this.state.game_frame) - p.spawn_frame) * FIXED_DELTA; if(age >= p.lifetime || (p.base_pierces - p.times_hit) <= 0){ this.state.projectiles.splice(i,1); } } // 6. weapon_system.update_all this._weaponUpdateAll(); // 7. collision_system.resolve_all this._resolveCollisions(); // 8. gem_system.update_collection this._updateGems(); // 9. spawn_system.execute_pending this._executePendingSpawns(); // 10. level up pending if(this._checkLevelUp()){ this._triggerLevelUp(); } // 11. increment frame/time this.state.game_frame += 1n; this.state.game_time = Number(this.state.game_frame) * FIXED_DELTA; // enforce game_duration if(this.state.game_time >= GAME_DURATION){ this._endRun(); } } _collectInputs(){ // merge keyboard and gamepad into a direction vector, apply gamepad deadzone let x = 0, y = 0; if(this.keys.has('ArrowUp')||this.keys.has('w')||this.keys.has('W')) y -= 1; if(this.keys.has('ArrowDown')||this.keys.has('s')||this.keys.has('S')) y += 1; if(this.keys.has('ArrowLeft')||this.keys.has('a')||this.keys.has('A')) x -= 1; if(this.keys.has('ArrowRight')||this.keys.has('d')||this.keys.has('D')) x += 1; // gamepad axes const gp = navigator.getGamepads ? navigator.getGamepads()[this.gamepadIndex] : null; if(gp && gp.axes && gp.axes.length>=2){ const gx = gp.axes[0] || 0; const gy = gp.axes[1] || 0; const dead = 0.15; const mag = Math.hypot(gx,gy); if(mag > dead){ x += gx; y += gy; } } // normalize const len = Math.hypot(x,y); if(len > 0){ x /= len; y /= len; } return {x: x, y: y}; } _updatePlayer(input, dt){ // movement using fixed timestep physics if(input.x !== 0 || input.y !== 0){ const dir = Utils.normalize(input.x, input.y); this.state.player.pos.x += dir.x * this.state.player.speed * dt; this.state.player.pos.y += dir.y * this.state.player.speed * dt; // keep in world bounds this.state.player.pos.x = Utils.clamp(this.state.player.pos.x, 0, this.world.width); this.state.player.pos.y = Utils.clamp(this.state.player.pos.y, 0, this.world.height); } // invuln timer if(this.state.player.invuln_timer > 0){ this.state.player.invuln_timer = Math.max(0, this.state.player.invuln_timer - dt); } } _enemyAIUpdate(enemy, dt){ // Always seek player const pv = this.state.player.pos; let dx = pv.x - enemy.pos.x; let dy = pv.y - enemy.pos.y; const desired = Utils.normalize(dx,dy); desired.x *= enemy.speed; desired.y *= enemy.speed; // separation: query neighbors within 24 units const neighbors = this.state.spatial_hash.query(enemy.pos, 24).filter(n=>n !== enemy && n.enemy_type !== undefined); let sepX = 0, sepY = 0; for(const n of neighbors){ const ox = enemy.pos.x - n.pos.x; const oy = enemy.pos.y - n.pos.y; const d = Math.hypot(ox,oy) || 0.001; if(d < 24){ sepX += ox/d * (24 - d); sepY += oy/d * (24 - d); } } sepX *= 0.5; sepY *= 0.5; // wall avoidance simple force away from borders let wallX = 0, wallY = 0; const margin = 30; if(enemy.pos.x < margin) wallX += (margin - enemy.pos.x)/margin * 30; if(enemy.pos.x > this.world.width - margin) wallX -= (enemy.pos.x - (this.world.width - margin))/margin * 30; if(enemy.pos.y < margin) wallY += (margin - enemy.pos.y)/margin * 30; if(enemy.pos.y > this.world.height - margin) wallY -= (enemy.pos.y - (this.world.height - margin))/margin * 30; let fx = desired.x + sepX + wallX; let fy = desired.y + sepY + wallY; // clamp magnitude to speed const mag = Math.hypot(fx,fy); if(mag > enemy.speed && mag>0){ fx = fx / mag * enemy.speed; fy = fy / mag * enemy.speed; } enemy.pos.x += fx * dt; enemy.pos.y += fy * dt; // stuck detection const moved = Math.hypot(enemy.pos.x - enemy.last_pos.x, enemy.pos.y - enemy.last_pos.y); if(moved < 0.1){ enemy.stuck_time += dt; if(enemy.stuck_time > 2.0){ const dir = Utils.normalize(this.state.player.pos.x - enemy.pos.x, this.state.player.pos.y - enemy.pos.y); enemy.pos.x += dir.x * 1.5; enemy.pos.y += dir.y * 1.5; enemy.stuck_time = 0; }} else { enemy.stuck_time = 0; } enemy.last_pos.x = enemy.pos.x; enemy.last_pos.y = enemy.pos.y; } _weaponUpdateAll(){ const game_time = this.state.game_time; const player = this.state.player; for(const w of player.weapons){ if(game_time - w.last_fired_time >= w.cooldown){ // find nearest enemy in range (iterate all enemies every frame) let nearest = null; let nd = Infinity; for(const e of this.state.enemies){ const d = Math.hypot(e.pos.x - player.pos.x, e.pos.y - player.pos.y); if(d <= w.range && d < nd){ nearest = e; nd = d; } } if(nearest){ // fire projectile const rngSeed = Number(this.state.rng_seed + BigInt(this.state.game_frame) + BigInt(w.id)); const rr = new SeededRNG(BigInt(rngSeed)); const dmgMult = 1.0; // no passives in demo const randFactor = rr.nextBetween(0.9,1.1); const finalDamage = Math.floor(w.base_damage * (1 + dmgMult) * randFactor); // projectile velocity toward nearest const dir = Utils.normalize(nearest.pos.x - player.pos.x, nearest.pos.y - player.pos.y); const vel = {x: dir.x * w.projectile_speed, y: dir.y * w.projectile_speed}; const proj = createProjectile(w.id, {x:player.pos.x, y:player.pos.y}, vel, w.projectile_speed, w.range, w.base_pierces); proj.damage = finalDamage; this.state.projectiles.push(proj); w.last_fired_time = game_time; } } } } _resolveCollisions(){ // projectiles vs enemies for(let i=this.state.projectiles.length-1;i>=0;i--){ const p = this.state.projectiles[i]; const candidates = this.state.spatial_hash.query(p.pos, 16); for(const c of candidates){ if(c.enemy_type === undefined) continue; const e = c; const d = Math.hypot(e.pos.x - p.pos.x, e.pos.y - p.pos.y); if(d <= (p.collision_radius + 0.5)){ // hit e.hp -= p.damage || 0; p.times_hit += 1; if(e.hp <= 0){ // spawn gem this.state.gems.push(createGem({x:e.pos.x,y:e.pos.y}, 8)); const idx = this.state.enemies.indexOf(e); if(idx>=0) this.state.enemies.splice(idx,1); } if((p.base_pierces - p.times_hit) <= 0){ this.state.projectiles.splice(i,1); break; } } } } // enemies colliding with player for(const e of this.state.enemies){ const d = Math.hypot(e.pos.x - this.state.player.pos.x, e.pos.y - this.state.player.pos.y); if(d <= (this.state.player.collision_radius + 0.5)){ if(this.state.player.invuln_timer <= 0){ this.state.player.health -= e.damage; this.state.player.invuln_timer = 0.5; // invulnerability frames if(this.state.player.health <= 0){ this._gameOver(); } } } } } _updateGems(){ for(let i=this.state.gems.length-1;i>=0;i--){ const g = this.state.gems[i]; const d = Math.hypot(g.pos.x - this.state.player.pos.x, g.pos.y - this.state.player.pos.y); if(d < 0.5){ // manual pickup this.state.player.experience += g.exp_value; this.state.gems.splice(i,1); } else if(d < g.pickup_range){ // magnetic g.pos.x = Utils.lerp(g.pos.x, this.state.player.pos.x, 0.15); g.pos.y = Utils.lerp(g.pos.y, this.state.player.pos.y, 0.15); } } } _executePendingSpawns(){ if(this.state.pending_spawns.length===0) return; // check scheduled_frame const gf = Number(this.state.game_frame); for(let i=this.state.pending_spawns.length-1;i>=0;i--){ const cmd = this.state.pending_spawns[i]; if(gf >= cmd.scheduled_frame){ // spawn enemy const e = createEnemy(cmd.type, {x:cmd.pos.x,y:cmd.pos.y}); this.state.enemies.push(e); this.state.pending_spawns.splice(i,1); } } } _checkLevelUp(){ const p = this.state.player; const needed = 10 + p.level*10; // simple progression if(p.experience >= needed){ return true; } return false; } _triggerLevelUp(){ this.state.is_simulation_paused = true; this.state.is_level_up_pending = true; // deterministic options: use seeded RNG based on seed + frame const seed = Number(this.state.rng_seed + this.state.game_frame + 9999n); const rr = new SeededRNG(BigInt(seed)); const options = [ {id:'hp_up', title:'Max HP +10', desc:'Increase max health by 10'}, {id:'dmg_up', title:'Damage +2', desc:'Increase weapon damage'}, {id:'speed_up', title:'Speed +10%', desc:'Move faster'}, {id:'exp_up', title:'Exp +20%', desc:'Gain more experience'} ]; rr.shuffle(options); const opts = options.slice(0,3); // always 3 const tpl = this.tmplLevelup.content.cloneNode(true); const backdrop = tpl.querySelector('.modal-backdrop'); const optsEl = tpl.querySelector('.options'); opts.forEach(o=>{ const b = document.createElement('div'); b.className='option'; b.tabIndex=0; const t = document.createElement('div'); t.className='title'; t.textContent=o.title; b.appendChild(t); const d = document.createElement('div'); d.className='desc'; d.textContent=o.desc; b.appendChild(d); b.addEventListener('click', ()=>{ this._applyLevelUpChoice(o); backdrop.remove(); }); optsEl.appendChild(b); }); // fallback auto-choose after 10s const autoChoose = setTimeout(()=>{ if(this.state.is_level_up_pending){ this._applyLevelUpChoice(opts[0]); backdrop.remove(); } }, 10000); backdrop.querySelector('[data-action="close"]').addEventListener('click', ()=>{ clearTimeout(autoChoose); backdrop.remove(); this.state.is_simulation_paused=false; this.state.is_level_up_pending=false; }); document.body.appendChild(backdrop); } _applyLevelUpChoice(option){ // apply effect deterministically const p = this.state.player; if(option.id === 'hp_up'){ p.max_health += 10; p.health = Math.min(p.health + 10, p.max_health); } if(option.id === 'dmg_up'){ for(const w of p.weapons) w.base_damage += 2; } if(option.id === 'speed_up'){ p.speed *= 1.10; } if(option.id === 'exp_up'){ p.experience = Math.floor(p.experience * 1.2); } // log level-up choice this.state.logs.push({frame: String(this.state.game_frame), level: p.level, chosen_upgrade: option.id}); p.level += 1; p.experience = 0; // reset exp this.state.is_simulation_paused = false; this.state.is_level_up_pending = false; } scheduleWave(spawn_count, enemy_weights, spawns_per_second){ // schedule deterministic spawn commands per plan const wave_size = spawn_count; for(let i=0;i<wave_size;i++){ const angleSeed = Number(this.state.rng_seed + this.state.game_frame + BigInt(i)); const rr1 = new SeededRNG(BigInt(angleSeed)); const angle = rr1.next() * Math.PI * 2; const distSeed = Number(this.state.rng_seed + this.state.game_frame + BigInt(i) + 1000n); const rr2 = new SeededRNG(BigInt(distSeed)); let distance = this.camera.radius + SPAWN_MARGIN + rr2.next() * 5; // ensure outside camera if(distance <= this.camera.radius + SPAWN_MARGIN) distance = this.camera.radius + SPAWN_MARGIN + 1; const spawn_pos = {x: this.state.player.pos.x + Math.cos(angle)*distance, y: this.state.player.pos.y + Math.sin(angle)*distance}; const typeSeed = Number(this.state.rng_seed + this.state.game_frame + BigInt(i) + 2000n); const rr3 = new SeededRNG(BigInt(typeSeed)); const idx = rr3.weightedChoice(enemy_weights); const enemy_type = idx+1; const delay = i * (1.0 / spawns_per_second); const scheduled_frame = Number(this.state.game_frame) + Math.round(delay / FIXED_DELTA); this.state.pending_spawns.push({pos:spawn_pos,type:enemy_type,delay:delay,scheduled_frame:scheduled_frame}); } } _endRun(){ this._showSys('Run complete — saving...', false); this.saveToLocalStorage(); this._running = false; } _gameOver(){ this._showSys('Game Over — saving...', false); this.saveToLocalStorage(); this._running = false; } async saveToLocalStorage(){ try{ const payload = this.state.toSerializable(); const str = JSON.stringify(payload); let checksum = ''; if(window.crypto && window.crypto.subtle){ const data = new TextEncoder().encode(str); const hash = await crypto.subtle.digest('SHA-256', data); checksum = Array.from(new Uint8Array(hash)).map(b=>b.toString(16).padStart(2,'0')).join(''); } else { // fallback checksum let s=0; for(let i=0;i<str.length;i++) s=(s+str.charCodeAt(i))&0xFFFFFFFF; checksum = s.toString(16); this._showSys('Warning: crypto.subtle unavailable - using weak checksum', true); } const saveObj = {schema_version: payload.schema_version, payload: payload, checksum: checksum}; localStorage.setItem('vampire_survivors_save_v2', JSON.stringify(saveObj)); this._showSys('Saved', false); return true; }catch(err){console.error('Save failed',err); this._showSys('Save failed: '+err.message,true); return false} } async loadFromLocalStorage(){ try{ const raw = localStorage.getItem('vampire_survivors_save_v2'); if(!raw) { this._showSys('No save found, creating new.', true); return this._createDefaultSave(); } const obj = JSON.parse(raw); if(obj.schema_version !== 2){ throw new Error('Unsupported schema_version'); } const payload = obj.payload; const checksum = obj.checksum; // validate checksum const str = JSON.stringify(payload); if(window.crypto && window.crypto.subtle){ const data = new TextEncoder().encode(str); const hash = await crypto.subtle.digest('SHA-256', data); const ch = Array.from(new Uint8Array(hash)).map(b=>b.toString(16).padStart(2,'0')).join(''); if(ch !== checksum) throw new Error('Checksum mismatch'); } // basic validation if(typeof payload.gold !== 'number' || payload.gold < 0) throw new Error('Invalid gold'); if(!Array.isArray(payload.unlocked_items)) throw new Error('Invalid unlocked_items'); // apply load stubs (demo only applies some fields) this.state.gold = payload.gold; this._showSys('Save loaded', false); return true; }catch(err){ console.error('Load failed',err); this._showSys('Save corrupted - new save created', true); this._createDefaultSave(); return false } } _createDefaultSave(){ this.state.gold = 0; // persisted minimal const payload = {schema_version:2, rng_seed:String(this.state.rng_seed), game_frame:String(this.state.game_frame), player:this.state.player, enemies:[], gold:0, unlocked_items:[]}; localStorage.setItem('vampire_survivors_save_v2', JSON.stringify({schema_version:2, payload:payload, checksum:'0'})); this._showSys('Default save created', true); } _showSys(msg,error=false){ this.sysMsg.style.display='block'; this.sysMsg.textContent = msg; this.sysMsg.style.background = error? 'rgba(239,68,68,0.12)': 'rgba(0,0,0,0.4)'; setTimeout(()=>{ this.sysMsg.style.display='none'; }, 3000); } _render(interp){ // basic camera following player const c = this.camera; c.x = this.state.player.pos.x; c.y = this.state.player.pos.y; const ctx = this.ctx; const canvas = this.canvas; // clear ctx.clearRect(0,0,canvas.width,canvas.height); // transform world->canvas ctx.save(); const scaleX = canvas.width / Math.min(canvas.width, canvas.height) * (canvas.width/1280); // for demo, simple transform: center on player ctx.translate(canvas.width/2 - (c.x), canvas.height/2 - (c.y)); // draw background grid ctx.fillStyle = '#071023'; ctx.fillRect(c.x - canvas.width/2, c.y - canvas.height/2, canvas.width, canvas.height); ctx.strokeStyle = 'rgba(255,255,255,0.02)'; ctx.lineWidth = 1; for(let gx=0;gx<=this.world.width;gx+=64){ ctx.beginPath(); ctx.moveTo(gx,0); ctx.lineTo(gx,this.world.height); ctx.stroke(); } for(let gy=0;gy<=this.world.height;gy+=64){ ctx.beginPath(); ctx.moveTo(0,gy); ctx.lineTo(this.world.width,gy); ctx.stroke(); } // draw gems for(const g of this.state.gems){ ctx.fillStyle = '#F59E0B'; ctx.beginPath(); ctx.arc(g.pos.x, g.pos.y, 6,0,Math.PI*2); ctx.fill(); } // draw enemies for(const e of this.state.enemies){ ctx.fillStyle = '#8b5cf6'; ctx.beginPath(); ctx.arc(e.pos.x, e.pos.y, 10,0,Math.PI*2); ctx.fill(); } // draw projectiles for(const p of this.state.projectiles){ ctx.fillStyle = '#3B82F6'; ctx.beginPath(); ctx.arc(p.pos.x, p.pos.y, 4,0,Math.PI*2); ctx.fill(); } // draw player with pulsing opacity when invuln const p = this.state.player; let alpha = 1.0; if(p.invuln_timer > 0){ alpha = 0.5 + 0.5*Math.sin(Date.now()/80); } ctx.globalAlpha = alpha; ctx.fillStyle = '#3B82F6'; ctx.beginPath(); ctx.arc(p.pos.x, p.pos.y, 12,0,Math.PI*2); ctx.fill(); ctx.globalAlpha = 1; ctx.restore(); // HUD updates this.hpFill.style.width = ((this.state.player.health/this.state.player.max_health)*100)+'%'; this.hpText.textContent = Math.max(0,Math.floor(this.state.player.health)) + ' / ' + Math.floor(this.state.player.max_health); const mm = Math.floor(this.state.game_time/60); const ss = Math.floor(this.state.game_time%60).toString().padStart(2,'0'); this.timerText.textContent = `${mm}:${ss}`; this.levelText.textContent = `Lv.${this.state.player.level}`; this.goldText.textContent = String(this.state.gold); // exp fill fraction const needed = 10 + this.state.player.level*10; const frac = Math.min(1, this.state.player.experience/needed); this.expFill.style.width = (frac*100)+'%'; // weapons list render minimal this.weaponsList.innerHTML = ''; for(const w of this.state.player.weapons){ const el = document.createElement('div'); el.className='weapon'; el.textContent = 'W'+w.weapon_id; this.weaponsList.appendChild(el); } // passives this.passivesBox.innerHTML = ''; for(const p of this.state.player.passives){ const el = document.createElement('div'); el.className='passive'; el.textContent='P'; this.passivesBox.appendChild(el); } } } // Instantiate and expose window.app = new Application(); // Expose some utilities for testing/debugging window.appUtils = { FIXED_DELTA, SPATIAL_CELL }; }); </script></body></html>
# APPLICATION SPECIFICATION## SUMMARYA deterministic, top-down roguelike survival game where the player automatically attacks enemies while moving for a fixed 20-minute duration. The core loop involves collecting experience gems to level up and choose weapon upgrades, with all randomness being seedable for reproducible runs. The game must maintain 60 FPS with 500 enemies while using fixed timestep physics and spatial hashing for collision detection.## DATA MODELS### Player| Field | Type | Required | Description ||-------|------|----------|-------------|| position | vec2 | Yes | World position clamped to map boundaries || speed | float | Yes | Movement speed multiplier || health | integer | Yes | Current hit points || max_health | integer | Yes | Maximum hit points || level | integer | Yes | Current player level || weapons | array | Yes | Equipped weapons list || passives | array | Yes | Equipped passive items list |**Constraints**: Collision capsule radius of 0.5 units, position clamped to [MAP_BORDER, MAP_WIDTH - MAP_BORDER], invulnerability for 0.5 seconds after damage**Source**: "Player collision capsule: radius 0.5 units (world space), used for both enemy collision and enemy targeting" and "Player cannot move outside map boundaries: position clamped to [MAP_BORDER, MAP_WIDTH - MAP_BORDER]"### Enemy| Field | Type | Required | Description ||-------|------|----------|-------------|| position | vec2 | Yes | Current world position || velocity | vec2 | Yes | Current movement velocity || health | float | Yes | Current hit points || max_health | float | Yes | Maximum hit points || damage | float | Yes | Damage dealt on contact || speed | float | Yes | Movement speed || type_id | integer | Yes | Enemy type identifier || stuck_time | float | Yes | Time duration without significant movement |**Constraints**: Must always target player position, anti-stuck teleport if stuck for 2 seconds**Source**: "Enemies must always have a target: the player character. No other target priorities allowed" and "Anti-stuck guarantee: If enemy hasn't moved > 0.1 units in 2.0 seconds, teleport enemy 1.0 unit away from nearest enemy toward player"### Weapon| Field | Type | Required | Description ||-------|------|----------|-------------|| level | integer | Yes | Current weapon level (1-8) || base_damage | float | Yes | Base damage value || cooldown | float | Yes | Time between attacks || last_fired_time | float | Yes | Timestamp of last fire || projectile_count | integer | Yes | Number of projectiles || pierce | integer | Yes | Number of enemies pierce count || range | float | Yes | Effective attack range |**Constraints**: Max level 8, checks availability every frame, damage formula includes seeded randomness**Source**: "Weapon level cap: max_level = 8 for all weapons" and "Each weapon performs an availability check every frame: if (game_time - last_fired_time >= cooldown) then attempt_fire()"### GameState| Field | Type | Required | Description ||-------|------|----------|-------------|| rng_seed | u64 | Yes | Random number generator seed || game_frame | u64 | Yes | Current frame count || game_time | f64 | Yes | Total elapsed game time || player | Player | Yes | Player character state || enemies | HashMap | Yes | All active enemies || projectiles | HashMap | Yes | All active projectiles || gems | HashMap | Yes | All experience gems || is_simulation_paused | boolean | Yes | Pause state flag |**Constraints**: Fixed update order ensures determinism, spatial hash updated every frame**Source**: "Update order must be fixed: Player → Enemies → Projectiles → Gems → Spawns" and "Collision detection must use Spatial Hashing with cell size 8x8 units, updated every frame"## BEHAVIORS### PlayerMovement- **Trigger**: Player input every frame- **Input**: WASD/arrow keys or gamepad input direction- **Output**: Updated player position- **Steps**: 1. Read raw input values 2. Apply gamepad deadzone (0.15) 3. Normalize input direction 4. Apply fixed timestep physics 5. Clamp to map boundaries- **Constraints**: Must prevent diagonal speed advantage via normalization, fixed delta time of 1/60s- **Required**: Yes- **Source**: "Character must move with fixed timestep physics: position += input_direction * speed * fixed_delta_time where fixed_delta_time = 1/60s" and "Input direction must be normalized: input_direction = normalize(input_x, input_y) to prevent diagonal speed advantage"### WeaponUpdate- **Trigger**: Every frame update- **Input**: Current time, weapon state, enemy positions- **Output**: New projectiles if conditions met- **Steps**: 1. Check if cooldown elapsed 2. Find nearest enemy in range 3. Calculate damage with seeded RNG 4. Spawn projectile if valid target- **Constraints**: No caching of enemy positions, must iterate all enemies every frame- **Required**: Yes- **Source**: "Each weapon performs an availability check every frame: if (game_time - last_fired_time >= cooldown) then attempt_fire()" and "Weapon target acquisition: find_nearest_enemy_in_range(range) - must iterate all enemies every frame, no caching"### EnemyAI- **Trigger**: Every frame update- **Input**: Player position, other enemy positions- **Output**: New enemy velocity and position- **Steps**: 1. Calculate steering force toward player 2. Add separation force from nearby enemies 3. Add wall avoidance force 4. Update position with fixed timestep 5. Check for stuck condition- **Constraints**: Must use behavior tree or utility AI, max 0.1ms per enemy, teleport if stuck- **Required**: Yes- **Source**: "Enemy AI runs every frame: steering_force = seek(player_position) + separation(other_enemies) + wall_avoidance()" and "Enemy AI must use Behavior Tree or Utility AI with max 0.1ms per enemy per frame"### ExperienceCollection- **Trigger**: Every frame during gameplay- **Input**: Player position, gem positions- **Output**: Collected gems, added experience- **Steps**: 1. Check manual pickup range (< 0.5 units) 2. Check magnetic pickup range (< 4.0 units) 3. Apply magnetic attraction if in range 4. Add experience to player total 5. Check for level up condition- **Constraints**: Magnetic attraction begins immediately with no delay- **Required**: Yes- **Source**: "Experience gem collection uses two-phase detection: Manual pickup: distance < 0.5 units → instant collection, Magnetic pickup: distance < pickup_range (default 4.0 units) → gem moves toward player at lerp(current_pos, player_pos, 0.15) per frame" and "Magnetic attraction must have no delay: begins immediately when in range"### LevelUp- **Trigger**: When experience >= required threshold- **Input**: Player level, owned weapons/passives- **Output**: Pause simulation, show 3 upgrade options- **Steps**: 1. Set simulation paused flag 2. Generate upgrade options list 3. Shuffle and select 3 options 4. Display UI to player 5. Wait for player selection- **Constraints**: Must pause simulation but continue rendering, always 3 options- **Required**: Yes- **Source**: "Level-up screen must pause simulation (is_simulation_paused = true) but continue rendering with time_scale = 0" and "return shuffle(options)[:3] # Always 3 options"### SpawnSystem- **Trigger**: Every spawn interval during gameplay- **Input**: Game time, player position- **Output**: New enemy spawn commands- **Steps**: 1. Calculate spawn rate based on game minutes 2. Calculate wave size 3. Generate positions outside camera view 4. Select enemy type based on weighted RNG 5. Schedule spawns across frame budget- **Constraints**: Must spawn outside camera view, use exponential scaling- **Required**: Yes- **Source**: "Spawn rate formula: spawns_per_second = base_rate * (1.0 + game_minutes * 0.15) ^ 1.3 (exponential scaling)" and "Spawn position must be outside camera view: distance_to_player > camera_radius + spawn_margin"## INTERACTIONS### PlayerInput- **Actor**: User- **Action**: Press movement keys/gamepad joysticks- **Response**: Character moves in normalized direction- **Required**: Yes- **Source**: "Player must be controllable via keyboard (WASD/arrows) and gamepad simultaneously"### GemCollection- **Actor**: System (triggered by proximity)- **Action**: Player enters collection range of experience gem- **Response**: Gem either instantly collected or magnetically attracted- **Required**: Yes- **Source**: "Experience gem collection uses two-phase detection: Manual pickup: distance < 0.5 units → instant collection, Magnetic pickup: distance < pickup_range (default 4.0 units)"### LevelUpSelection- **Actor**: User- **Action**: Click/select one of 3 upgrade options- **Response**: Apply upgrade, unpause simulation- **Required**: Yes- **Source**: "Random sample(passive_items, 4 - len(options)) return shuffle(options)[:3] # Always 3 options"### MenuNavigation- **Actor**: User- **Action**: Navigate menu options with keyboard/gamepad- **Response**: Highlight/select menu items, key repeat after 500ms initial delay- **Required**: Yes- **Source**: "Key repeat for menus: 500ms initial delay, 50ms repeat rate"## CONSTRAINTS| Constraint | Type | Description | Source ||------------|------|-------------|--------|| fixed_timestep | REQUIRED | Physics must use 1/60 second timestep | "Physics must use fixed timestep: physics_delta = 1/60, accumulate remainder" || 60_fps_target | REQUIRED | Must maintain 60 FPS with 500 enemies/200 projectiles | "Must maintain 60 FPS (16.67ms/frame) with: 500 active enemies, 200 projectiles" || seeded_rng | REQUIRED | All randomness must use seeded RNG for reproducibility | "All randomness must use seeded RNG: rng = seed + game_frame + entity_id" || deterministic_float | REQUIRED | Use f64 or fixed-point for cross-platform determinism | "Floating-point operations must be deterministic across platforms: use f64 or fixed-point math" || memory_limit_web | REQUIRED | Web build max 200MB RAM usage | "Memory limit: 200MB RAM usage (web), 500MB (desktop)" || spatial_hashing | REQUIRED | Collision detection must use 8x8 unit spatial hashing | "Collision detection must use Spatial Hashing with cell size 8x8 units, updated every frame" || game_duration | REQUIRED | Each session lasts exactly 20 minutes | "Game session duration: 20 minutes (1200 seconds)" || weapon_max_level | REQUIRED | All weapons cap at level 8 | "Weapon level cap: max_level = 8 for all weapons" || enemy_stuck_handling | REQUIRED | Must teleport enemy if stuck for 2 seconds | "Anti-stuck guarantee: If enemy hasn't moved > 0.1 units in 2.0 seconds, teleport enemy 1.0 unit away" || save_version | REQUIRED | JSON save format with schema version 2 | "Save format: JSON with schema version current_version = 2" |## EXTERNAL DEPENDENCIES| Dependency | Type | Purpose | Source ||------------|------|---------|--------|| localStorage | API | Web save storage location | "Web: localStorage key "vampire_survivors_save_v2"" || filesystem | File | Desktop save storage location | "Desktop: %APPDATA%/VampireSurvivors/save_v2.json" || input_devices | Hardware | Keyboard and gamepad input | "Player must be controllable via keyboard (WASD/arrows) and gamepad simultaneously" |## PRESENTATION- **Format**: Real-time rendered game with overlay UI- **Layout**: Top-down gameplay view with fixed-position HUD elements- **Elements**: Health bar, timer, gold counter, experience bar, weapon/passive icons, damage numbers, level-up overlay- **Required**: Yes- **Source**: "HUD (Heads-Up Display): Must display with pixel-perfect positioning: Top-left: Health bar (width 200px, height 20px), HP text current/max, Top-center: Timer MM:SS, Level Lv.X, Top-right: Gold count with icon"## AMBIGUITIES### SaveLocationFormatting- **Issue**: The save path format uses Windows-style notation but must work cross-platform- **Quotes**: "Desktop: %APPDATA%/VampireSurvivors/save_v2.json" vs requirements for "Windows/Linux/Web" compatibility- **Interpretations**: 1. Use platform-specific environment variables on each OS 2. Use a cross-platform config directory standardization- **Recommendation**: Use OS-agnostic config directory (e.g., ~/.config on Linux, ~/Library/Application Support on macOS) to ensure cross-platform compatibility while maintaining the specified structure### FlexibleMenuOptions- **Issue**: Document specifies 3 options for level-up but suggests flexibility elsewhere- **Quotes**: "return shuffle(options)[:3] # Always 3 options" vs "Number of options can be 3-4, but must be consistent within a game"- **Interpretations**: 1. Exactly 3 options always 2. Configurable between 3-4 at game start- **Recommendation**: Implement exactly 3 options as this matches the core algorithm and simplifies testing### BossSpawnTiming- **Issue**: Boss spawns replace normal waves but could interfere with spawn rate formula- **Quotes**: "Boss spawns at: game_time = 300, 600, 900, 1200 seconds" vs "Boss spawn replaces normal spawn wave (not additional)" and spawn rate formula- **Interpretations**: 1. Skip the normal spawn at those exact timestamps 2. Temporarily interrupt the spawn rate schedule- **Recommendation**: Skip the normal scheduled spawn at exactly those frame times to maintain determinism
Comments
No comments yet. Be the first to comment!
Please login to leave a comment.