From 3cde4845572d2aca52afad2802fe235c43a323c1 Mon Sep 17 00:00:00 2001 From: Demetri Pirpiris Date: Sat, 15 Mar 2025 00:49:30 -0600 Subject: [PATCH] Gummy Arena v1 --- index.html | 330 ++++++++++++++++ script.js | 1114 ++++++++++++++++++++++++++++++++++++++++++++++++++++ styles.css | 304 ++++++++++++++ 3 files changed, 1748 insertions(+) create mode 100644 index.html create mode 100644 script.js create mode 100644 styles.css diff --git a/index.html b/index.html new file mode 100644 index 0000000..ff5d839 --- /dev/null +++ b/index.html @@ -0,0 +1,330 @@ + + + + + Neon Synthwave Snake Evolution Simulator + + + + + + +
+
Fast Forwarding...
+
+ + + + + +
+
+ +

How This Simulation Works

+

+ Welcome to the Neon Synthwave Snake Evolution Simulator! Your main goal is to help create + (and sometimes directly control) the environment so the snakes can grow as long as possible. +

+

+ Each snake tries to eat food (and occasionally each other) to gain points, grow in length, and + eventually become the biggest snake. Behind the scenes, genetic algorithms evolve the snakes’ + “brains” to adapt their behavior over time. +

+

+ There’s also a hidden twist: the size of your browser window subtly influences the snakes’ + learning process. Try making the canvas larger or smaller to see how it affects the outcome! +

+

+ Objective: Achieve the highest possible snake length! +

+
+
+ + +
+
+ Simulation Controls + +
+
+ + +
+ + + + + +
+ + +
+
+ + +
+ + +
+
+ Adjust food points, dropped food, and kill bonus rewards here. +
+
+ + + 3 +
+ Higher values give snakes more points for eating standard food. +
+
+
+ + + 10 +
+ When a snake dies, it leaves behind 'dropped food' worth these extra points. +
+
+
+ + + 0 +
+ If a snake collides with your body, you gain these extra points. +
+
+
+ + +
+
+ Adjust survival bonus per frame and extra points for bigger snakes. +
+
+ + + 2 +
+ Each frame, snakes get a tiny fraction of this as extra points (×0.001). +
+
+
+ + + 1 +
+ Extra points for each unit of snake length. Encourages bigger snakes! +
+
+
+
+ + +
+
+ Control how food is generated and how big your snakes can get from it. +
+
+ + + 120 +
+ The map won't exceed this many food items at once. +
+
+
+ + + 3 +
+ Each frame, if below the max, there's a spawn chance out of 100. +
+
+
+ + + 10 +
+ Each time a snake eats, it grows by this many segments. +
+
+
+ + + 0.05 +
+ Controls how 'fat' the snake gets with each meal. +
+
+
+ + +
+
+ Control the number of snakes and their intelligence or speed. +
+
+ + + 30 +
+ Increase or decrease the total population of snakes. +
+
+
+ + + 3.5 +
+ Snakes turn at a rate up to 0.15 / (lengthFactor) * speed; higher is more agile. +
+
+ + +
+ + + 0.15 +
+ Controls how sharply snakes can turn (before factoring in size). +
+
+ + +
+ + + 1 +
+ How many body length units are spent per second while boosting. +
+
+ + +
+ + + 2 +
+ How much faster the snake goes when boosting. +
+
+ +
+ + + 15 +
+ The bigger the brain, the more complex the snake's decision-making. +
+
+
+ + +
+
+ Adjust evolutionary parameters to influence genetic changes. +
+
+ + + 0.1 +
+ Probability that a brain's bit will flip during mutation. +
+
+
+ + + 0.5 +
+ Likelihood of choosing a gene from the first parent during crossover. +
+
+
+ + +
+
+ Adjust environmental parameters that affect food and snake respawning. +
+
+ + + 2000 +
+ Time (ms) before dropped food disappears. +
+
+
+ + + 0 +
+ Delay (sec) before a new snake spawns after one dies. +
+
+
+ +
+ + +
+
+
+ + +
+
+ Time & Leaderboards + +
+
+
+ + +
+ +
+
+
+

Current Leaderboard

+
+
+
+

All-Time Leaderboard

+
+
+
+
+
+ +
+
+
+ + 0 +
+ Slide to skip simulation time quickly. Great for speeding evolution! +
+
+
+ +
+
+
+
+ + + + diff --git a/script.js b/script.js new file mode 100644 index 0000000..b802e71 --- /dev/null +++ b/script.js @@ -0,0 +1,1114 @@ +/* =========================== + GLOBAL & DEFAULT VARIABLES +=========================== */ +let snakeIdCounter = 0; +let maxLengths = {}; +let elapsedTime = 0; +let canvasWidth, canvasHeight; +let lastFrameTime = null; + +/* Updated defaults */ +const DEFAULT_BRAIN_SIZE = 15; +const DEFAULT_SNAKE_SPEED = 3.5; +const DEFAULT_MAX_FOOD = 120; +const DEFAULT_SPAWN_CHANCE = 3; +const DEFAULT_FOOD_POINTS = 3; +const DEFAULT_DROPPED_FOOD_POINTS = 10; +const DEFAULT_SURVIVAL_BONUS = 2; +const DEFAULT_BODY_LENGTH_INCREMENT = 10; +const DEFAULT_THICKNESS_INCREMENT = 0.05; +const DEFAULT_SNAKE_COUNT = 30; +const DEFAULT_LENGTH_BONUS = 1; +const DEFAULT_KILL_BONUS = 0; + +/* Evolution & Environment Defaults */ +const DEFAULT_MUTATION_RATE = 0.1; +const DEFAULT_CROSSOVER_BIAS = 0.5; +const DEFAULT_FOOD_DECAY_TIME = 2000; +const DEFAULT_RESPAWN_DELAY = 0; + +/* Newly added default constants for Turn Rate & Boost Feature */ +const DEFAULT_TURN_RATE = 0.15; +const DEFAULT_BOOST_COST = 1.0; +const DEFAULT_BOOST_MULTIPLIER = 2.0; + +/* Live controls */ +let CONTROL_BRAIN_SIZE = DEFAULT_BRAIN_SIZE; +let CONTROL_SNAKE_SPEED = DEFAULT_SNAKE_SPEED; +let CONTROL_MAX_FOOD = DEFAULT_MAX_FOOD; +let CONTROL_SPAWN_CHANCE = DEFAULT_SPAWN_CHANCE / 100; +let CONTROL_FOOD_POINTS = DEFAULT_FOOD_POINTS; +let CONTROL_DROPPED_FOOD_POINTS = DEFAULT_DROPPED_FOOD_POINTS; +let CONTROL_SURVIVAL_BONUS = DEFAULT_SURVIVAL_BONUS / 1000; +let CONTROL_BODY_LENGTH_INCREMENT = DEFAULT_BODY_LENGTH_INCREMENT; +let CONTROL_THICKNESS_INCREMENT = DEFAULT_THICKNESS_INCREMENT; +let CONTROL_SNAKE_COUNT = DEFAULT_SNAKE_COUNT; +let CONTROL_LENGTH_BONUS = DEFAULT_LENGTH_BONUS; +let CONTROL_KILL_BONUS = DEFAULT_KILL_BONUS; +let CONTROL_MUTATION_RATE = DEFAULT_MUTATION_RATE; +let CONTROL_CROSSOVER_BIAS = DEFAULT_CROSSOVER_BIAS; +let CONTROL_FOOD_DECAY_TIME = DEFAULT_FOOD_DECAY_TIME; +let CONTROL_RESPAWN_DELAY = DEFAULT_RESPAWN_DELAY; + +/* Newly added live control variables for Turn Rate & Boost */ +let CONTROL_TURN_RATE = DEFAULT_TURN_RATE; +let CONTROL_BOOST_COST = DEFAULT_BOOST_COST; +let CONTROL_BOOST_MULTIPLIER = DEFAULT_BOOST_MULTIPLIER; + +/* Random name generator arrays */ +const CREATURE_WORDS = [ + "Shadow", "Arcane", "Giant", "Flying", "Poison", "Cosmic", "Metal", + "Lurking", "Frost", "Ghoulish", "Infernal", "Serpent", "Draconic", "Wraith", + "Neon", "Psychedelic", "Electro", "Turbo", "Nova", "Cyborg", "Spooky", "Radiant", + "Celestial", "Demonic", "Slimy", "Furry", "Scaled", "Venomous", "Quantum", "Crystal", + "Armored", "Vampire", "Bio", "Cyber", "Warped", "Chaotic", "Magnetic", "Sonic", + "Pulsing", "Dark", "Psycho", "Laser", "Molten", "Mystic", "Ghostly", "Titanic", + "Ancient", "Cursed", + // Playful/fun names + "Bouncy", "Zany", "Wacky", "Funky", "Quirky", "Silly", "Peppy", "Jolly" +]; +const OBJECT_WORDS = [ + "Hydra", "Sphinx", "Slime", "Golem", "Dragon", "Beast", "Worm", + "Wraith", "Zombie", "Ogre", "Witch", "Basilisk", "Wyvern", "Spider", + "Phantom", "Fiend", "Demon", "Alien", "Parasite", "Robot", "Ghoul", "Tentacle", + "Construct", "Crawler", "Serpent", "Locust", "Lizard", "Poltergeist", "Chimera", "Bug", + "Scorpion", "Leech", "Wisp", "Mothman", "Revenant", "Medusa", "Dragonfly", "Amoeba", + "Pixie", "Doppelganger", "Harpy", "Gargoyle", "Troglodyte", "Valkyrie", "Kelpie", + "Manticore", "Cockatrice", "Homunculus", + // Playful/fun names + "Cupcake", "Noodle", "Banana", "Pickle", "Gizmo", "Biscuit", "Muffin", "Sparky" +]; + +function getRandomSnakeName(id) { + const cWord = CREATURE_WORDS[Math.floor(Math.random() * CREATURE_WORDS.length)]; + const oWord = OBJECT_WORDS[Math.floor(Math.random() * OBJECT_WORDS.length)]; + return `${cWord}${oWord}#${id}`; +} + +/* =========================== + Canvas Setup +=========================== */ +const canvas = document.getElementById('simCanvas'); +const ctx = canvas.getContext('2d'); + +function resizeCanvas() { + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + canvasWidth = canvas.width; + canvasHeight = canvas.height; +} +resizeCanvas(); +window.addEventListener('resize', () => { + resizeCanvas(); + updateLayout(); +}); + +/* Utility */ +function distance(x1, y1, x2, y2) { + return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); +} +function angleDifference(a, b) { + let diff = a - b; + while (diff < -Math.PI) diff += 2 * Math.PI; + while (diff > Math.PI) diff -= 2 * Math.PI; + return diff; +} +function getRandomNeonColor() { + const hue = Math.floor(Math.random() * 360); + return `hsl(${hue}, 100%, 50%)`; +} + +/* =========================== + RESPONSIVE LAYOUT LOGIC +=========================== */ +let isMobileLayout = null; +function updateLayout() { + const width = window.innerWidth; + const newIsMobile = (width < 768); + + if (newIsMobile === isMobileLayout) return; + isMobileLayout = newIsMobile; + + const tlPanel = document.getElementById('timeAndLeaderboardPanel'); + const ctrlPanel = document.getElementById('controlPanel'); + + if (isMobileLayout) { + // fix top & bottom for mobile + tlPanel.style.position = 'fixed'; + tlPanel.style.top = '20px'; + tlPanel.style.left = '50%'; + tlPanel.style.transform = 'translateX(-50%)'; + + ctrlPanel.style.position = 'fixed'; + ctrlPanel.style.bottom = '20px'; + ctrlPanel.style.left = '50%'; + ctrlPanel.style.transform = 'translateX(-50%)'; + + removeDraggable(tlPanel); + removeDraggable(ctrlPanel); + + // auto-minimize control panel, expand top panel + const tlContent = tlPanel.querySelector('.content'); + const tlMinimizeBtn = tlPanel.querySelector('.minimize'); + if (tlContent && tlMinimizeBtn) { + tlContent.style.display = 'block'; + tlMinimizeBtn.textContent = '–'; + } + const ctrlContent = ctrlPanel.querySelector('.content'); + const ctrlMinimizeBtn = ctrlPanel.querySelector('.minimize'); + if (ctrlContent && ctrlMinimizeBtn) { + ctrlContent.style.display = 'none'; + ctrlMinimizeBtn.textContent = '+'; + } + + } else { + // desktop layout + tlPanel.style.position = 'absolute'; + tlPanel.style.top = '20px'; + tlPanel.style.left = '50%'; + tlPanel.style.transform = 'translateX(-50%)'; + + ctrlPanel.style.position = 'absolute'; + ctrlPanel.style.bottom = '20px'; + ctrlPanel.style.left = '50%'; + ctrlPanel.style.transform = 'translateX(-50%)'; + + removeDraggable(tlPanel); + removeDraggable(ctrlPanel); + } +} + +function removeDraggable(panel) { + if (!panel) return; + const header = panel.querySelector('.header'); + if (!header) return; + const clone = panel.cloneNode(true); + panel.parentNode.replaceChild(clone, panel); + + attachMinimizeLogic(clone); + attachTopLevelTabLogic(clone); + attachRewardSubTabLogic(clone); + wireUIEvents(); +} + +function attachMinimizeLogic(panel) { + const miniBtn = panel.querySelector('.minimize'); + if (!miniBtn) return; + miniBtn.addEventListener('click', () => { + const content = panel.querySelector('.content'); + if (!content) return; + const isClosed = (content.style.display === 'none'); + if (isClosed) { + content.style.display = 'block'; + miniBtn.textContent = '–'; + if (isMobileLayout) { + // auto-minimize the other panel if mobile + if (panel.id === 'controlPanel') { + const otherPanel = document.getElementById('timeAndLeaderboardPanel'); + const otherContent = otherPanel.querySelector('.content'); + if (otherContent && otherContent.style.display !== 'none') { + otherContent.style.display = 'none'; + otherPanel.querySelector('.minimize').textContent = '+'; + } + } else { + const otherPanel = document.getElementById('controlPanel'); + const otherContent = otherPanel.querySelector('.content'); + if (otherContent && otherContent.style.display !== 'none') { + otherContent.style.display = 'none'; + otherPanel.querySelector('.minimize').textContent = '+'; + } + } + } + } else { + content.style.display = 'none'; + miniBtn.textContent = '+'; + } + }); +} + +/** + * Top-level tab logic (Reward, Food, etc.) + */ +function attachTopLevelTabLogic(panel) { + const topLevelBtns = panel.querySelectorAll('.top-level-tabs .tabBtn'); + if (!topLevelBtns.length) return; + + topLevelBtns.forEach(btn => { + btn.addEventListener('click', () => { + const parent = btn.closest('.top-level-tabs'); + const panelContent = parent.parentNode; + const allTabBtns = parent.querySelectorAll('.tabBtn'); + const allTabs = panelContent.querySelectorAll('.tab-content'); + + allTabBtns.forEach(b => b.classList.remove('active')); + allTabs.forEach(t => t.classList.remove('active')); + + btn.classList.add('active'); + const tabId = btn.getAttribute('data-tab'); + const target = panelContent.querySelector('#' + tabId); + if (target) target.classList.add('active'); + + // if "rewardTab," default sub-tab = "Points & Kills" + if (tabId === 'rewardTab') { + const rewardSubTabs = target.querySelectorAll('.reward-sub-content'); + const subBtns = target.querySelectorAll('.reward-subtabs .tabBtn'); + rewardSubTabs.forEach(s => s.classList.remove('active')); + subBtns.forEach(s => s.classList.remove('active')); + const firstSubBtn = target.querySelector('.reward-subtabs .tabBtn[data-subtab="rewardPointsSubTab"]'); + const firstSubContent = target.querySelector('#rewardPointsSubTab'); + if (firstSubBtn) firstSubBtn.classList.add('active'); + if (firstSubContent) firstSubContent.classList.add('active'); + } + }); + }); +} + +/** + * Sub-tab logic for the "Reward" panel + */ +function attachRewardSubTabLogic(panel) { + const rewardTab = panel.querySelector('#rewardTab'); + if (!rewardTab) return; + + const subTabButtons = rewardTab.querySelectorAll('.reward-subtabs .tabBtn'); + subTabButtons.forEach(subBtn => { + subBtn.addEventListener('click', () => { + subTabButtons.forEach(b => b.classList.remove('active')); + const allSubContents = rewardTab.querySelectorAll('.reward-sub-content'); + allSubContents.forEach(s => s.classList.remove('active')); + + subBtn.classList.add('active'); + const subId = subBtn.getAttribute('data-subtab'); + const targetSub = rewardTab.querySelector('#' + subId); + if (targetSub) targetSub.classList.add('active'); + }); + }); +} + +/* Wire up UI events (sliders, etc.) */ +function wireUIEvents() { + // EXACTLY as before, hooking each slider to the right variable and label + const killBonusSlider = document.getElementById('killBonusSlider'); + if (killBonusSlider) { + killBonusSlider.oninput = e => { + CONTROL_KILL_BONUS = parseFloat(e.target.value); + updateControlValue('killBonusVal', e.target.value); + }; + } + + const snakeSpeedSlider = document.getElementById('snakeSpeedSlider'); + if (snakeSpeedSlider) { + snakeSpeedSlider.oninput = e => { + CONTROL_SNAKE_SPEED = parseFloat(e.target.value); + updateControlValue('snakeSpeedVal', e.target.value); + }; + } + + const brainSizeSlider = document.getElementById('brainSizeSlider'); + if (brainSizeSlider) { + brainSizeSlider.oninput = e => { + const newVal = parseFloat(e.target.value); + if (newVal > CONTROL_BRAIN_SIZE) CONTROL_BRAIN_SIZE = newVal; + else e.target.value = CONTROL_BRAIN_SIZE; + updateControlValue('brainSizeVal', e.target.value); + }; + } + + const survivalBonusSlider = document.getElementById('survivalBonusSlider'); + if (survivalBonusSlider) { + survivalBonusSlider.oninput = e => { + CONTROL_SURVIVAL_BONUS = parseFloat(e.target.value) * 0.001; + updateControlValue('survivalBonusVal', e.target.value); + }; + } + + const bodyLengthIncSlider = document.getElementById('bodyLengthIncSlider'); + if (bodyLengthIncSlider) { + bodyLengthIncSlider.oninput = e => { + CONTROL_BODY_LENGTH_INCREMENT = parseFloat(e.target.value); + updateControlValue('bodyLengthIncVal', e.target.value); + }; + } + + const thicknessIncSlider = document.getElementById('thicknessIncSlider'); + if (thicknessIncSlider) { + thicknessIncSlider.oninput = e => { + CONTROL_THICKNESS_INCREMENT = parseFloat(e.target.value); + updateControlValue('thicknessIncVal', e.target.value); + }; + } + + const maxFoodSlider = document.getElementById('maxFoodSlider'); + if (maxFoodSlider) { + maxFoodSlider.oninput = e => { + CONTROL_MAX_FOOD = parseInt(e.target.value); + updateControlValue('maxFoodVal', e.target.value); + }; + } + + const spawnChanceSlider = document.getElementById('spawnChanceSlider'); + if (spawnChanceSlider) { + spawnChanceSlider.oninput = e => { + CONTROL_SPAWN_CHANCE = parseFloat(e.target.value) / 100; + updateControlValue('spawnChanceVal', e.target.value); + }; + } + + const foodPointsSlider = document.getElementById('foodPointsSlider'); + if (foodPointsSlider) { + foodPointsSlider.oninput = e => { + CONTROL_FOOD_POINTS = parseFloat(e.target.value); + updateControlValue('foodPointsVal', e.target.value); + }; + } + + const droppedFoodPointsSlider = document.getElementById('droppedFoodPointsSlider'); + if (droppedFoodPointsSlider) { + droppedFoodPointsSlider.oninput = e => { + CONTROL_DROPPED_FOOD_POINTS = parseFloat(e.target.value); + updateControlValue('droppedFoodPointsVal', e.target.value); + }; + } + + const snakeCountSlider = document.getElementById('snakeCountSlider'); + if (snakeCountSlider) { + snakeCountSlider.oninput = e => { + const val = parseInt(e.target.value); + updateControlValue('snakeCountVal', val); + adjustSnakeCount(val); + }; + } + + const lengthBonusSlider = document.getElementById('lengthBonusSlider'); + if (lengthBonusSlider) { + lengthBonusSlider.oninput = e => { + CONTROL_LENGTH_BONUS = parseFloat(e.target.value); + updateControlValue('lengthBonusVal', e.target.value); + }; + } + + const timeAccelSlider = document.getElementById('timeAccelSlider'); + if (timeAccelSlider) { + timeAccelSlider.oninput = e => { + updateControlValue('timeAccelVal', e.target.value); + }; + } + + const mutationRateSlider = document.getElementById('mutationRateSlider'); + if (mutationRateSlider) { + mutationRateSlider.oninput = e => { + CONTROL_MUTATION_RATE = parseFloat(e.target.value); + updateControlValue('mutationRateVal', e.target.value); + }; + } + + const crossoverBiasSlider = document.getElementById('crossoverBiasSlider'); + if (crossoverBiasSlider) { + crossoverBiasSlider.oninput = e => { + CONTROL_CROSSOVER_BIAS = parseFloat(e.target.value); + updateControlValue('crossoverBiasVal', e.target.value); + }; + } + + const foodDecayTimeSlider = document.getElementById('foodDecayTimeSlider'); + if (foodDecayTimeSlider) { + foodDecayTimeSlider.oninput = e => { + CONTROL_FOOD_DECAY_TIME = parseInt(e.target.value); + updateControlValue('foodDecayTimeVal', e.target.value); + }; + } + + const respawnDelaySlider = document.getElementById('respawnDelaySlider'); + if (respawnDelaySlider) { + respawnDelaySlider.oninput = e => { + CONTROL_RESPAWN_DELAY = parseFloat(e.target.value); + updateControlValue('respawnDelayVal', e.target.value); + }; + } + + // Turn Rate + const turnRateSlider = document.getElementById('turnRateSlider'); + if (turnRateSlider) { + turnRateSlider.oninput = e => { + CONTROL_TURN_RATE = parseFloat(e.target.value); + updateControlValue('turnRateVal', e.target.value); + }; + } + + // Boost Cost + const boostCostSlider = document.getElementById('boostCostSlider'); + if (boostCostSlider) { + boostCostSlider.oninput = e => { + CONTROL_BOOST_COST = parseFloat(e.target.value); + updateControlValue('boostCostVal', e.target.value); + }; + } + + // Boost Multiplier + const boostMultiplierSlider = document.getElementById('boostMultiplierSlider'); + if (boostMultiplierSlider) { + boostMultiplierSlider.oninput = e => { + CONTROL_BOOST_MULTIPLIER = parseFloat(e.target.value); + updateControlValue('boostMultiplierVal', e.target.value); + }; + } + + // Buttons + const restoreBtn = document.getElementById('restoreDefaultsButton'); + if (restoreBtn) restoreBtn.onclick = restoreDefaults; + + const resetBtn = document.getElementById('resetSimButton'); + if (resetBtn) resetBtn.onclick = resetSimulation; + + const fastForwardBtn = document.getElementById('fastForwardButton'); + if (fastForwardBtn) fastForwardBtn.onclick = fastForwardSimulation; +} + +/* Adjust snake population */ +function adjustSnakeCount(desiredCount) { + if (!sim) return; + const currentCount = sim.snakes.length; + if (desiredCount < currentCount) { + const diff = currentCount - desiredCount; + // sort by fitness, remove the lowest + sim.snakes.sort((a, b) => a.fitness - b.fitness); + for (let i = 0; i < diff; i++) { + sim.removeSnakeNoRespawn(0, true); + } + } else if (desiredCount > currentCount) { + const diff = desiredCount - currentCount; + for (let i = 0; i < diff; i++) { + sim.spawnNewSnake(); + } + } +} + +/* CONTROL FUNCTIONS */ +function updateControlValue(labelId, val) { + const el = document.getElementById(labelId); + if (el) el.textContent = val; +} + +function restoreDefaults() { + CONTROL_BRAIN_SIZE = 15; + CONTROL_SNAKE_SPEED = 3.5; + CONTROL_MAX_FOOD = 120; + CONTROL_SPAWN_CHANCE = 0.03; + CONTROL_FOOD_POINTS = 3; + CONTROL_DROPPED_FOOD_POINTS = 10; + CONTROL_SURVIVAL_BONUS = 0.002; + CONTROL_BODY_LENGTH_INCREMENT = 10; + CONTROL_THICKNESS_INCREMENT = 0.05; + CONTROL_SNAKE_COUNT = 30; + CONTROL_LENGTH_BONUS = 1; + CONTROL_KILL_BONUS = 0; + CONTROL_MUTATION_RATE = 0.1; + CONTROL_CROSSOVER_BIAS = 0.5; + CONTROL_FOOD_DECAY_TIME = 2000; + CONTROL_RESPAWN_DELAY = 0; + + CONTROL_TURN_RATE = DEFAULT_TURN_RATE; + CONTROL_BOOST_COST = DEFAULT_BOOST_COST; + CONTROL_BOOST_MULTIPLIER = DEFAULT_BOOST_MULTIPLIER; + + // Update all slider positions: + document.getElementById('brainSizeSlider').value = 15; + document.getElementById('snakeSpeedSlider').value = 3.5; + document.getElementById('maxFoodSlider').value = 120; + document.getElementById('spawnChanceSlider').value = 3; + document.getElementById('foodPointsSlider').value = 3; + document.getElementById('droppedFoodPointsSlider').value = 10; + document.getElementById('survivalBonusSlider').value = 2; + document.getElementById('bodyLengthIncSlider').value = 10; + document.getElementById('thicknessIncSlider').value = 0.05; + document.getElementById('snakeCountSlider').value = 30; + document.getElementById('lengthBonusSlider').value = 1; + document.getElementById('killBonusSlider').value = 0; + document.getElementById('mutationRateSlider').value = 0.1; + document.getElementById('crossoverBiasSlider').value = 0.5; + document.getElementById('foodDecayTimeSlider').value = 2000; + document.getElementById('respawnDelaySlider').value = 0; + document.getElementById('timeAccelSlider').value = 0; + document.getElementById('turnRateSlider').value = DEFAULT_TURN_RATE; + document.getElementById('boostCostSlider').value = DEFAULT_BOOST_COST; + document.getElementById('boostMultiplierSlider').value = DEFAULT_BOOST_MULTIPLIER; + + // Update all displayed labels: + updateControlValue('brainSizeVal', 15); + updateControlValue('snakeSpeedVal', 3.5); + updateControlValue('maxFoodVal', 120); + updateControlValue('spawnChanceVal', 3); + updateControlValue('foodPointsVal', 3); + updateControlValue('droppedFoodPointsVal', 10); + updateControlValue('survivalBonusVal', 2); + updateControlValue('bodyLengthIncVal', 10); + updateControlValue('thicknessIncVal', 0.05); + updateControlValue('snakeCountVal', 30); + updateControlValue('lengthBonusVal', 1); + updateControlValue('killBonusVal', 0); + updateControlValue('mutationRateVal', 0.1); + updateControlValue('crossoverBiasVal', 0.5); + updateControlValue('foodDecayTimeVal', 2000); + updateControlValue('respawnDelayVal', 0); + updateControlValue('timeAccelVal', 0); + updateControlValue('turnRateVal', DEFAULT_TURN_RATE); + updateControlValue('boostCostVal', DEFAULT_BOOST_COST); + updateControlValue('boostMultiplierVal', DEFAULT_BOOST_MULTIPLIER); +} + +function resetSimulation() { + snakeIdCounter = 0; + maxLengths = {}; + elapsedTime = 0; + sim = new Simulation(); +} + +function fastForwardSimulation() { + if (!sim) return; + const minutesToSkip = parseInt(document.getElementById('timeAccelSlider').value); + updateControlValue('timeAccelVal', minutesToSkip); + if (minutesToSkip > 0) { + showLoadingOverlay(); + setTimeout(() => { + const framesToSim = minutesToSkip * 60; // e.g. 1 minute => 60 frames + for (let i = 0; i < framesToSim; i++) { + sim.update(1.0 / 60.0); + } + elapsedTime += minutesToSkip; + hideLoadingOverlay(); + }, 50); + } +} + +function showLoadingOverlay() { + document.getElementById('loadingOverlay').style.display = 'flex'; +} +function hideLoadingOverlay() { + document.getElementById('loadingOverlay').style.display = 'none'; +} + +/* =========================== + CLASS DEFINITIONS +=========================== */ + +/** + * Brain with two outputs (turn, boost). + */ +class Brain { + constructor(size, existingConfig = null) { + this.size = size; + if (existingConfig) { + this.config = existingConfig.slice(); + } else { + this.config = Array.from({ length: size }, () => Math.random() < 0.5); + } + this.fitness = 0; + } + + decide(sensors) { + let sumForTurning = 0; + let sumForBoost = 0; + const half = Math.floor(this.config.length / 2); + + // turning + for (let i = 0; i < sensors.length; i++) { + if (sensors[i]) sumForTurning++; + } + for (let i = 0; i < half; i++) { + if (this.config[i]) sumForTurning++; + } + sumForTurning += Math.random(); + const turnThreshold = (sensors.length + half) / 2; + const turn = (sumForTurning > turnThreshold) ? 1 : -1; + + // boost + for (let i = half; i < this.config.length; i++) { + if (this.config[i]) sumForBoost++; + } + sumForBoost += Math.random(); + const boostThreshold = (this.config.length - half) / 2; + const boost = (sumForBoost > boostThreshold); + + return { turn, boost }; + } + + mutate() { + if (Math.random() < CONTROL_MUTATION_RATE) { + const index = Math.floor(Math.random() * this.config.length); + this.config[index] = !this.config[index]; + } + } + + static crossover(b1, b2) { + const size = Math.max(b1.size, b2.size); + const newBrain = new Brain(size); + for (let i = 0; i < size; i++) { + const bit1 = b1.config[i % b1.config.length]; + const bit2 = b2.config[i % b2.config.length]; + newBrain.config[i] = (Math.random() < CONTROL_CROSSOVER_BIAS) ? bit1 : bit2; + } + return newBrain; + } +} + +class Food { + constructor(x, y, isDropped = false, worth = CONTROL_FOOD_POINTS) { + this.x = x; + this.y = y; + this.radius = 3; + this.color = '#FFF'; + this.isDropped = isDropped; + this.worth = worth; + } + update() { + return true; + } + render(ctx) { + ctx.fillStyle = this.color; + ctx.beginPath(); + ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2); + ctx.fill(); + } +} + +class DroppedFood extends Food { + constructor(x, y) { + super(x, y, true, CONTROL_DROPPED_FOOD_POINTS); + this.creationTime = Date.now(); + this.lifespan = CONTROL_FOOD_DECAY_TIME; + } + update() { + const age = Date.now() - this.creationTime; + return age < this.lifespan; + } + render(ctx) { + const age = Date.now() - this.creationTime; + let alpha = 1.0; + if (age < 1000) { + alpha = 0.5 + 0.5 * Math.sin(performance.now() / 80); + } else { + const fadeFrac = 1 - (age - 1000) / 1000; + alpha = fadeFrac; + } + ctx.save(); + ctx.fillStyle = "#ff66ff"; + ctx.shadowColor = "rgba(255,102,255,0.8)"; + ctx.shadowBlur = 10; + ctx.globalAlpha = alpha; + ctx.beginPath(); + ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } +} + +class Snake { + constructor(x, y, brain = null) { + this.id = snakeIdCounter++; + this.name = getRandomSnakeName(this.id); + this.x = x; + this.y = y; + this.thickness = 4; + this.radius = this.thickness * 0.6; + this.speed = CONTROL_SNAKE_SPEED; + this.direction = Math.random() * Math.PI * 2; + this.frontColor = getRandomNeonColor(); + this.backColor = getRandomNeonColor(); + this.brain = brain || new Brain(CONTROL_BRAIN_SIZE); + this.fitness = 0; + // We'll store objects: { x, y, edgeJump?: boolean } + this.body = [{ x: this.x, y: this.y }]; + this.bodyLength = 50; + this.isBoosting = false; + } + + gatherSensors(allFood, allSnakes) { + // Omitted for brevity + return []; + } + + update(dt, allFood, allSnakes) { + // 1) Decide + const decision = this.brain.decide(this.gatherSensors(allFood, allSnakes)); + const turn = decision.turn; + const wantBoost = decision.boost; + + // 2) Turning + const sizeFactor = Math.max(1, this.bodyLength / 50); + const turnAngle = CONTROL_TURN_RATE / sizeFactor; + this.direction += turn * turnAngle; + + // 3) Boost + if (wantBoost && this.bodyLength > 1) { + this.isBoosting = true; + } else { + this.isBoosting = false; + } + + if (this.isBoosting) { + this.speed = CONTROL_SNAKE_SPEED * CONTROL_BOOST_MULTIPLIER; + const lengthLost = CONTROL_BOOST_COST * dt; + this.bodyLength -= lengthLost; + + const thicknessLost = lengthLost * CONTROL_THICKNESS_INCREMENT; + this.thickness = Math.max(1, this.thickness - thicknessLost); + + if (this.bodyLength < 1) { + this.bodyLength = 1; + this.isBoosting = false; + } + this.radius = this.thickness * 0.6; + } else { + this.speed = CONTROL_SNAKE_SPEED; + } + + // 4) Move + const oldX = this.x; + const oldY = this.y; + + this.x += this.speed * Math.cos(this.direction); + this.y += this.speed * Math.sin(this.direction); + + // Check for wrapping + let wrapped = false; + if (this.x < 0) { + this.x += canvasWidth; + wrapped = true; + } else if (this.x > canvasWidth) { + this.x -= canvasWidth; + wrapped = true; + } + if (this.y < 0) { + this.y += canvasHeight; + wrapped = true; + } else if (this.y > canvasHeight) { + this.y -= canvasHeight; + wrapped = true; + } + + // If we did wrap, push an edgeJump marker + if (wrapped) { + this.body.push({ x: NaN, y: NaN, edgeJump: true }); + } + + this.body.push({ x: this.x, y: this.y }); + + while (this.body.length > Math.floor(this.bodyLength)) { + this.body.shift(); + } + + // 5) Mutate + this.brain.mutate(); + + // 6) Survival bonus + this.fitness += CONTROL_SURVIVAL_BONUS; + + // 7) Length bonus + if (CONTROL_LENGTH_BONUS > 0) { + this.fitness += (this.bodyLength * CONTROL_LENGTH_BONUS * 0.001); + } + + // record best length + if (!maxLengths[this.id]) { + maxLengths[this.id] = { name: this.name, length: this.bodyLength, color: this.frontColor }; + } else if (this.bodyLength > maxLengths[this.id].length) { + maxLengths[this.id].length = this.bodyLength; + } + } + + render(ctx) { + ctx.save(); + + if (this.isBoosting) { + ctx.shadowBlur = 15; + ctx.shadowColor = this.frontColor; + } + + const halfIndex = Math.floor(this.body.length / 2); + + // front half + ctx.strokeStyle = this.frontColor; + ctx.lineWidth = this.thickness; + ctx.beginPath(); + ctx.moveTo(this.body[0].x, this.body[0].y); + for (let i = 1; i < halfIndex; i++) { + const seg = this.body[i]; + if (seg.edgeJump) { + // break the path + ctx.stroke(); + ctx.beginPath(); + if (i + 1 < halfIndex) { + ctx.moveTo(this.body[i + 1].x, this.body[i + 1].y); + i++; + } + } else { + ctx.lineTo(seg.x, seg.y); + } + } + ctx.stroke(); + + // back half + ctx.strokeStyle = this.backColor; + ctx.beginPath(); + ctx.moveTo(this.body[halfIndex].x, this.body[halfIndex].y); + for (let i = halfIndex + 1; i < this.body.length; i++) { + const seg = this.body[i]; + if (seg.edgeJump) { + ctx.stroke(); + ctx.beginPath(); + if (i + 1 < this.body.length) { + ctx.moveTo(this.body[i + 1].x, this.body[i + 1].y); + i++; + } + } else { + ctx.lineTo(seg.x, seg.y); + } + } + ctx.stroke(); + + // head + ctx.fillStyle = this.frontColor; + ctx.beginPath(); + ctx.arc(this.x, this.y, this.radius * 1.3, 0, Math.PI * 2); + ctx.fill(); + + ctx.restore(); + } +} + +class Simulation { + constructor() { + this.snakes = []; + this.food = []; + this.deadSnakesBrains = []; + this.maxFoodAllowed = CONTROL_MAX_FOOD; + this.spawnFoodChance = CONTROL_SPAWN_CHANCE; + + // initial snakes + for (let i = 0; i < CONTROL_SNAKE_COUNT; i++) { + this.spawnNewSnake(); + } + // initial food + for (let i = 0; i < 50; i++) { + const x = Math.random() * canvasWidth; + const y = Math.random() * canvasHeight; + this.food.push(new Food(x, y, false, CONTROL_FOOD_POINTS)); + } + } + + spawnNewSnake() { + let newBrain; + if (this.deadSnakesBrains.length > 0) { + const b1 = this.deadSnakesBrains[Math.floor(Math.random() * this.deadSnakesBrains.length)]; + const b2 = b1; + newBrain = Brain.crossover(b1, b2); + } else { + newBrain = new Brain(CONTROL_BRAIN_SIZE); + } + const x = Math.random() * canvasWidth; + const y = Math.random() * canvasHeight; + const newSnake = new Snake(x, y, newBrain); + this.snakes.push(newSnake); + maxLengths[newSnake.id] = { name: newSnake.name, length: newSnake.bodyLength, color: newSnake.frontColor }; + } + + removeSnakeNoRespawn(index, dropBody = false) { + const snake = this.snakes[index]; + if (dropBody) { + snake.body.forEach(seg => { + if (!seg.edgeJump) { + // only add actual segments as dropped food + this.food.push(new DroppedFood(seg.x, seg.y)); + } + }); + } + this.snakes.splice(index, 1); + } + + spawnFood() { + const x = Math.random() * canvasWidth; + const y = Math.random() * canvasHeight; + this.food.push(new Food(x, y, false, CONTROL_FOOD_POINTS)); + } + + killSnake(index) { + const snake = this.snakes[index]; + this.deadSnakesBrains.push(snake.brain); + + // drop the snake's body + snake.body.forEach(seg => { + if (!seg.edgeJump) { + this.food.push(new DroppedFood(seg.x, seg.y)); + } + }); + this.snakes.splice(index, 1); + + // respawn + setTimeout(() => { + let newBrain; + if (this.deadSnakesBrains.length > 0) { + const b1 = this.deadSnakesBrains[Math.floor(Math.random() * this.deadSnakesBrains.length)]; + const b2 = b1; + newBrain = Brain.crossover(b1, b2); + } else { + newBrain = new Brain(CONTROL_BRAIN_SIZE); + } + const x = Math.random() * canvasWidth; + const y = Math.random() * canvasHeight; + const newbie = new Snake(x, y, newBrain); + this.snakes.push(newbie); + maxLengths[newbie.id] = { + name: newbie.name, + length: newbie.bodyLength, + color: newbie.frontColor + }; + }, CONTROL_RESPAWN_DELAY * 1000); + } + + update(dt) { + const clampedDt = Math.min(dt, 0.05); + + // spawn extra food if needed + const normalFoodCount = this.food.filter(f => !f.isDropped).length; + if (normalFoodCount < this.maxFoodAllowed / 2) { + if (Math.random() < this.spawnFoodChance) { + this.spawnFood(); + } + } + + // update snakes + for (let i = this.snakes.length - 1; i >= 0; i--) { + const snake = this.snakes[i]; + snake.update(clampedDt, this.food, this.snakes); + + // check if eats food + for (let j = this.food.length - 1; j >= 0; j--) { + const f = this.food[j]; + if (distance(snake.x, snake.y, f.x, f.y) < snake.radius + f.radius) { + snake.bodyLength += CONTROL_BODY_LENGTH_INCREMENT; + snake.thickness += CONTROL_THICKNESS_INCREMENT; + snake.radius = snake.thickness * 0.6; + snake.fitness += f.worth; + + this.food.splice(j, 1); + + if (snake.bodyLength > maxLengths[snake.id].length) { + maxLengths[snake.id].length = snake.bodyLength; + } + } + } + + // collisions with other snakes + for (let j = 0; j < this.snakes.length; j++) { + if (i === j) continue; + const other = this.snakes[j]; + for (let k = 1; k < other.body.length; k++) { + const point = other.body[k]; + if (point.edgeJump) continue; // skip jump markers + if (distance(snake.x, snake.y, point.x, point.y) < snake.radius + 0.5) { + if (CONTROL_KILL_BONUS > 0) { + other.fitness += CONTROL_KILL_BONUS; + } + this.killSnake(i); + break; + } + } + } + } + + // update dropped food + for (let i = this.food.length - 1; i >= 0; i--) { + if (typeof this.food[i].update === 'function') { + const keep = this.food[i].update(); + if (!keep) { + this.food.splice(i, 1); + } + } + } + } + + render(ctx) { + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + for (const f of this.food) { + f.render(ctx); + } + for (const s of this.snakes) { + s.render(ctx); + } + } +} + +/* Leaderboards & Main Loop */ +let sim = null; + +function updateLeaderboards() { + if (!sim) return; + + // Current top 5 + const currentLeaders = sim.snakes + .slice() + .sort((a, b) => b.bodyLength - a.bodyLength) + .slice(0, 5); + let currentList = "
    "; + currentLeaders.forEach(s => { + currentList += ` +
  1. + ${s.name}: ${s.bodyLength.toFixed(0)} +
  2. + `; + }); + currentList += "
"; + + // All-time top 5 + const allTimeArray = Object.values(maxLengths); + allTimeArray.sort((a, b) => b.length - a.length); + const allTimeLeaders = allTimeArray.slice(0, 5); + let allTimeList = "
    "; + allTimeLeaders.forEach(r => { + allTimeList += ` +
  1. + ${r.name}: ${r.length.toFixed(0)} +
  2. + `; + }); + allTimeList += "
"; + + const singleTime = `Elapsed Time: ${elapsedTime.toFixed(2)} min`; + + const currCol = document.getElementById("currentLeaderboardColumn"); + if (currCol) currCol.innerHTML = currentList; + const allCol = document.getElementById("allTimeLeaderboardColumn"); + if (allCol) allCol.innerHTML = allTimeList; + const timeEl = document.getElementById("leaderboardTime"); + if (timeEl) timeEl.innerHTML = singleTime; +} +setInterval(updateLeaderboards, 1000); + +function mainLoop(timestamp) { + if (!lastFrameTime) lastFrameTime = timestamp; + const rawDt = (timestamp - lastFrameTime) / 1000; + // clamp dt to avoid giant leaps + const dt = Math.min(rawDt, 0.05); + + lastFrameTime = timestamp; + elapsedTime += dt / 60; + + if (sim) { + sim.update(dt); + sim.render(ctx); + } + requestAnimationFrame(mainLoop); +} +requestAnimationFrame(mainLoop); + +function init() { + resetSimulation(); + updateLayout(); + + attachTopLevelTabLogic(document.getElementById("controlPanel")); + attachRewardSubTabLogic(document.getElementById("controlPanel")); + attachTopLevelTabLogic(document.getElementById("timeAndLeaderboardPanel")); + + wireUIEvents(); +} +init(); diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..110bce6 --- /dev/null +++ b/styles.css @@ -0,0 +1,304 @@ +/* Full-screen layout */ +html, body { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + background: #000; + overflow: hidden; + font-family: 'Roboto', sans-serif; + color: #FFF; +} + +/* Canvas covers the entire window */ +canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: block; + background: #000; +} + +/* Panels for controls + time & leaderboards */ +.control-panel, +.time-panel { + position: absolute; + padding: 8px; + user-select: none; + z-index: 2; + border: 1px solid #555; + border-radius: 8px; + box-shadow: 0 0 10px rgba(255, 0, 255, 0.3); + background: linear-gradient(135deg, rgba(20,0,40,0.8), rgba(0,0,0,0.6)); +} + +/* widths: make both 420px to match each other in desktop */ +.control-panel { + width: 420px; + left: 50%; + bottom: 20px; + transform: translateX(-50%); +} +.time-panel { + width: 420px; + left: 50%; + top: 20px; + transform: translateX(-50%); + text-align: center; +} + +/* Panel headers */ +.control-panel .header, +.time-panel .header { + font-weight: bold; + padding: 6px; + background: rgba(0,0,0,0.8); + display: flex; + justify-content: space-between; + align-items: center; + border-radius: 5px; + box-shadow: inset 0 0 5px rgba(255,0,255,0.2); +} + +/* Panel content */ +.control-panel .content, +.time-panel .content { + margin-top: 8px; + font-size: 14px; +} + +/* Buttons in panels */ +.control-panel button, +.time-panel button { + background: #444; + border: none; + color: #FFF; + cursor: pointer; + padding: 4px 8px; + font-weight: bold; + border-radius: 4px; +} +.control-panel button:hover, +.time-panel button:hover { + background: #666; +} + +/* Control panel styling */ +.control-group { + margin-bottom: 10px; + text-align: left; +} +.control-group label { + display: inline-block; + width: 150px; + font-weight: bold; +} +.control-group input[type=range] { + width: 200px; +} +.control-buttons { + text-align: center; + margin-top: 10px; +} +.control-buttons button { + margin: 0 5px; + padding: 6px 12px; + background: linear-gradient(90deg, #8000ff 0%, #ff00ff 100%); + border: none; + color: #FFF; + cursor: pointer; + font-weight: bold; + border-radius: 4px; + box-shadow: 0 0 5px rgba(255,0,255,0.4); +} +.control-buttons button:hover { + opacity: 0.8; +} + +/* Main tab styling */ +.top-level-tabs { + display: flex; + margin-bottom: 10px; +} +.top-level-tabs .tabBtn { + flex: 1; + margin: 0 2px; + background: #444; + border: none; + color: #FFF; + cursor: pointer; + padding: 4px 8px; + font-weight: bold; + border-radius: 4px; +} +.top-level-tabs .tabBtn.active { + background: linear-gradient(90deg, #8000ff 0%, #ff00ff 100%); + box-shadow: 0 0 5px rgba(255,0,255,0.4); +} + +/* Sub-tab styling for Reward sub-tabs */ +.reward-subtabs { + display: flex; + margin-bottom: 10px; +} +.reward-subtabs .tabBtn { + flex: 1; + margin: 0 2px; + background: #444; + border: none; + color: #FFF; + cursor: pointer; + padding: 4px 8px; + font-weight: bold; + border-radius: 4px; +} +.reward-subtabs .tabBtn.active { + background: linear-gradient(90deg, #8000ff 0%, #ff00ff 100%); + box-shadow: 0 0 5px rgba(255,0,255,0.4); +} +.reward-sub-content { + display: none; /* hidden by default */ +} +.reward-sub-content.active { + display: block; +} + +/* .tab-content is used for top-level content areas */ +.tab-content { + display: none; /* hidden by default */ +} +.tab-content.active { + display: block; +} + +/* Basic text styling for descriptions */ +.tab-description { + font-size: 13px; + font-style: italic; + margin-bottom: 10px; + opacity: 0.9; +} +.setting-description { + font-size: 12px; + font-style: italic; + margin-top: 4px; + opacity: 0.8; +} + +/* Leaderboard styling */ +.leaderboard-text { + font-size: 12px; + line-height: 1.2; + word-break: break-word; /* allow word wrapping */ +} + +/* Loading overlay for fast-forward */ +#loadingOverlay { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + background: rgba(0,0,0,0.8); + color: #FFF; + display: none; + align-items: center; + justify-content: center; + z-index: 10; + text-align: center; +} +@keyframes sinewave { + 0% { transform: translateX(-50%) translateY(0); } + 50% { transform: translateX(-50%) translateY(-20px); } + 100% { transform: translateX(-50%) translateY(0); } +} +#loadingOverlay .sinewave { + font-size: 24px; + animation: sinewave 1s infinite; +} + +/* Center the "?" help button on the right edge */ +.help-button { + position: absolute; + top: 50%; + right: 10px; + transform: translateY(-50%); + z-index: 3; + background: #444; + border: 1px solid #555; + color: #FFF; + font-size: 16px; + padding: 4px 8px; + cursor: pointer; + border-radius: 50%; + box-shadow: 0 0 8px rgba(255, 0, 255, 0.5); +} +.help-button:hover { + background: #666; +} + +/* Help Modal Overlay */ +.help-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0,0,0,0.8); + display: none; + z-index: 9999; + align-items: center; + justify-content: center; /* center horizontally */ +} + +/* Help Modal Content */ +.help-content { + position: relative; + width: 400px; + max-width: 90%; + padding: 16px; + border: 1px solid #555; + border-radius: 8px; + background: linear-gradient(135deg, rgba(20,0,40,0.9), rgba(0,0,0,0.7)); + box-shadow: 0 0 10px rgba(255, 0, 255, 0.4); + text-align: left; +} + +/* Close button inside modal */ +.help-close { + position: absolute; + top: 6px; + right: 8px; + background: transparent; + border: none; + color: #FFF; + font-size: 18px; + cursor: pointer; +} +.help-close:hover { + color: #ff00ff; +} + +/* Mobile-friendly adjustments */ +@media (max-width: 768px) { + .control-panel { + width: 90%; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + } + .time-panel { + width: 90%; + top: 20px; + left: 50%; + transform: translateX(-50%); + } + /* Stack the leaderboards vertically in mobile to avoid overflow */ + .leaderboardColumn { + width: 100% !important; + margin-bottom: 10px; + padding-left: 0 !important; + } +}