-- SNAKE.LUA — a snake game written in Lua, running in the browser via Fengari. -- The `js` module bridges Lua and the browser: `window`, `document`, canvas, etc. local js = require "js" local window = js.global local document = window.document local canvas = document:getElementById("game") local ctx = canvas:getContext("2d") local scoreEl = document:getElementById("score") local highEl = document:getElementById("high") local GRID = 22 -- board is GRID x GRID cells local CELL = canvas.width / GRID -- pixel size of one cell -- Game state local snake, dir, nextDir, food, score, high, pendingGrow local alive, started, stepMs, acc, lastTime, flashT local particles = {} math.randomseed(os.time()) local function rnd(a, b) return a + math.random() * (b - a) end local function pick(t) return t[math.random(#t)] end local PALETTES = { normal = { "#ff5d73", "#ffd166", "#ff9e64", "#ffffff" }, twin = { "#ffd700", "#fff3b0", "#ffaa00", "#ffffff" }, pong = { "#5de4ff", "#a0f0ff", "#58a6ff", "#ffffff" }, death = { "#ff5d73", "#ffd166", "#7ee787", "#5de4ff", "#ffffff" }, } -- ---------------------------------------------------------------- storage local function loadHigh() local ok, v = pcall(function() return window.localStorage:getItem("lua-snake-high") end) if ok and v ~= nil and v ~= js.null then return tonumber(v) or 0 end return 0 end local function saveHigh(h) pcall(function() window.localStorage:setItem("lua-snake-high", tostring(h)) end) end -- ---------------------------------------------------------------- particles local function spawn(p) p.age = p.age or 0 particles[#particles + 1] = p end -- Each explosion style is a function(x, y, colors). One is picked at random -- every time a dot is eaten. local function fxSparkleBurst(x, y, colors) for _ = 1, 16 do local a, sp = rnd(0, math.pi * 2), rnd(40, 160) spawn { kind = "star", x = x, y = y, vx = math.cos(a) * sp, vy = math.sin(a) * sp, life = rnd(0.45, 0.9), size = rnd(2.5, 5.5), color = pick(colors), rot = rnd(0, math.pi), spin = rnd(-6, 6), twinkle = true, seed = rnd(0, 99) } end end local function fxRingPulse(x, y, colors) for i = 1, 3 do spawn { kind = "ring", x = x, y = y, vx = 0, vy = 0, life = 0.55, delay = (i - 1) * 0.12, r0 = 3, r1 = CELL * rnd(1.8, 2.6), color = pick(colors) } end for _ = 1, 6 do local a, sp = rnd(0, math.pi * 2), rnd(20, 70) spawn { kind = "dot", x = x, y = y, vx = math.cos(a) * sp, vy = math.sin(a) * sp, life = rnd(0.4, 0.7), size = rnd(1.5, 3), color = pick(colors) } end end local function fxConfettiPop(x, y, colors) for _ = 1, 18 do local a, sp = rnd(-math.pi, 0), rnd(60, 190) -- pop upward spawn { kind = "rect", x = x, y = y, vx = math.cos(a) * sp, vy = math.sin(a) * sp, life = rnd(0.6, 1.1), size = rnd(3, 6.5), color = pick(colors), rot = rnd(0, math.pi), spin = rnd(-12, 12), gravity = 320 } end end local function fxPixieSpiral(x, y, colors) for i = 1, 14 do spawn { kind = "orbit", cx = x, cy = y, x = x, y = y, angle = (i / 14) * math.pi * 2, angVel = rnd(4, 7) * (i % 2 == 0 and 1 or -1), radius = 2, radVel = rnd(28, 60), life = rnd(0.5, 0.9), size = rnd(1.8, 3.5), color = pick(colors), twinkle = true, seed = rnd(0, 99) } end end local function fxFireworkTrail(x, y, colors) for _ = 1, 20 do local a, sp = rnd(0, math.pi * 2), rnd(70, 200) spawn { kind = "spark", x = x, y = y, vx = math.cos(a) * sp, vy = math.sin(a) * sp, life = rnd(0.5, 0.95), color = pick(colors), gravity = 140, twinkle = true, seed = rnd(0, 99) } end end local function fxStarburst(x, y, colors) for i = 1, 6 do local a = (i / 6) * math.pi * 2 + rnd(-0.2, 0.2) spawn { kind = "star", x = x, y = y, vx = math.cos(a) * rnd(25, 55), vy = math.sin(a) * rnd(25, 55), life = rnd(0.6, 0.9), size = rnd(6, 9), color = pick(colors), rot = a, spin = rnd(-3, 3) } end spawn { kind = "ring", x = x, y = y, vx = 0, vy = 0, life = 0.4, r0 = 2, r1 = CELL * 1.6, color = colors[1] } end local EFFECTS = { fxSparkleBurst, fxRingPulse, fxConfettiPop, fxPixieSpiral, fxFireworkTrail, fxStarburst } local function explode(x, y, colors) pick(EFFECTS)(x, y, colors) end local function floatText(txt, x, y, color) spawn { kind = "text", text = txt, x = x, y = y - 4, vx = 0, vy = -36, life = 0.9, size = 15, color = color or "#ffffff" } end local function updateParticles(dt) local i = 1 while i <= #particles do local p = particles[i] if p.delay and p.delay > 0 then p.delay = p.delay - dt i = i + 1 else p.age = p.age + dt if p.age >= p.life then table.remove(particles, i) else if p.kind == "orbit" then p.angle = p.angle + p.angVel * dt p.radius = p.radius + p.radVel * dt p.x = p.cx + math.cos(p.angle) * p.radius p.y = p.cy + math.sin(p.angle) * p.radius else p.vy = p.vy + (p.gravity or 0) * dt p.x = p.x + p.vx * dt p.y = p.y + p.vy * dt end if p.spin then p.rot = (p.rot or 0) + p.spin * dt end i = i + 1 end end end end local function drawStar(x, y, r, points, rot) ctx:beginPath() for i = 0, points * 2 - 1 do local ang = rot + i * math.pi / points local rad = (i % 2 == 0) and r or r * 0.45 local sx, sy = x + math.cos(ang) * rad, y + math.sin(ang) * rad if i == 0 then ctx:moveTo(sx, sy) else ctx:lineTo(sx, sy) end end ctx:closePath() end local function drawParticles(now) for _, p in ipairs(particles) do if not (p.delay and p.delay > 0) then local t = 1 - p.age / p.life local alpha = t if p.twinkle then alpha = t * (0.55 + 0.45 * math.sin(now / 40 + p.seed)) end ctx.globalAlpha = math.max(alpha, 0) if p.kind == "ring" then ctx.strokeStyle = p.color ctx.lineWidth = 2.5 * t + 0.5 ctx:beginPath() ctx:arc(p.x, p.y, p.r0 + (p.r1 - p.r0) * (p.age / p.life), 0, math.pi * 2) ctx:stroke() elseif p.kind == "rect" then ctx:save() ctx:translate(p.x, p.y) ctx:rotate(p.rot or 0) ctx.fillStyle = p.color ctx:fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6) ctx:restore() elseif p.kind == "star" then ctx.fillStyle = p.color drawStar(p.x, p.y, p.size * (0.4 + 0.6 * t), 4, p.rot or 0) ctx:fill() elseif p.kind == "spark" then ctx.strokeStyle = p.color ctx.lineWidth = math.max(2 * t, 0.4) ctx:beginPath() ctx:moveTo(p.x, p.y) ctx:lineTo(p.x - p.vx * 0.05, p.y - p.vy * 0.05) ctx:stroke() elseif p.kind == "text" then ctx.fillStyle = p.color ctx.font = "bold " .. math.floor(p.size) .. "px 'Courier New', monospace" ctx.textAlign = "center" ctx:fillText(p.text, p.x, p.y) else -- "dot" ctx.fillStyle = p.color ctx:beginPath() ctx:arc(p.x, p.y, math.max(p.size * t, 0.3), 0, math.pi * 2) ctx:fill() end end end ctx.globalAlpha = 1 end -- ---------------------------------------------------------------- game logic -- Dot kinds: "normal" (+1), "twin" (+2, golden pair), "pong" (+3, bounces -- around the arena in pixel space until the snake's head intercepts it). local function placeFood() while true do local fx, fy = math.random(0, GRID - 1), math.random(0, GRID - 1) local clash = false for _, s in ipairs(snake) do if s.x == fx and s.y == fy then clash = true; break end end if not clash then local roll = math.random() local kind = "normal" if roll < 0.15 then kind = "twin" elseif roll < 0.30 then kind = "pong" end food = { x = fx, y = fy, kind = kind } if kind == "pong" then food.px, food.py = (fx + 0.5) * CELL, (fy + 0.5) * CELL -- launch on a pong-style diagonal, with a little jitter local ang = (math.random(4) - 1) * (math.pi / 2) + math.pi / 4 + rnd(-0.25, 0.25) local speed = math.min(140 + score * 3, 250) food.vx, food.vy = math.cos(ang) * speed, math.sin(ang) * speed food.trail = {} end return end end end local function foodCenter() if food.kind == "pong" then return food.px, food.py end return (food.x + 0.5) * CELL, (food.y + 0.5) * CELL end local function reset() local mid = math.floor(GRID / 2) snake = { {x = mid, y = mid}, {x = mid - 1, y = mid}, {x = mid - 2, y = mid} } dir = { x = 1, y = 0 } nextDir = dir score = 0 stepMs = 130 -- ms per move; shrinks as you eat acc = 0 alive = true flashT = 0 pendingGrow = 0 scoreEl.textContent = "0" placeFood() end local function die() alive = false flashT = 1 local h = snake[1] local cx, cy = (h.x + 0.5) * CELL, (h.y + 0.5) * CELL for _ = 1, 3 do explode(cx + rnd(-12, 12), cy + rnd(-12, 12), PALETTES.death) end if score > high then high = score highEl.textContent = tostring(high) saveHigh(high) end end local function eat() local cx, cy = foodCenter() local kind = food.kind local points = (kind == "twin" and 2) or (kind == "pong" and 3) or 1 local grow = (kind == "twin" and 2) or 1 score = score + points pendingGrow = pendingGrow + grow scoreEl.textContent = tostring(score) stepMs = math.max(60, stepMs - 2.5 * points) explode(cx, cy, PALETTES[kind]) floatText("+" .. points, cx, cy, PALETTES[kind][1]) placeFood() end local function step() dir = nextDir local head = snake[1] local nx, ny = head.x + dir.x, head.y + dir.y -- walls kill if nx < 0 or ny < 0 or nx >= GRID or ny >= GRID then return die() end -- self-collision kills (skip segments about to vacate) local occupied = #snake - (pendingGrow > 0 and 0 or 1) for i = 1, occupied do if snake[i].x == nx and snake[i].y == ny then return die() end end table.insert(snake, 1, { x = nx, y = ny }) if food.kind ~= "pong" and nx == food.x and ny == food.y then eat() end if pendingGrow > 0 then pendingGrow = pendingGrow - 1 else table.remove(snake) end end -- Bouncing dots move every frame, not every snake-step local function updatePong(dt) if food.kind ~= "pong" or not started or not alive then return end local r = CELL * 0.38 food.px = food.px + food.vx * dt food.py = food.py + food.vy * dt if food.px < r then food.px = r; food.vx = -food.vx elseif food.px > canvas.width - r then food.px = canvas.width - r; food.vx = -food.vx end if food.py < r then food.py = r; food.vy = -food.vy elseif food.py > canvas.height - r then food.py = canvas.height - r; food.vy = -food.vy end table.insert(food.trail, 1, { x = food.px, y = food.py }) if #food.trail > 8 then table.remove(food.trail) end local h = snake[1] local hx, hy = (h.x + 0.5) * CELL, (h.y + 0.5) * CELL local dx, dy = food.px - hx, food.py - hy if dx * dx + dy * dy < (CELL * 0.75) ^ 2 then eat() end end -- ---------------------------------------------------------------- drawing local function roundRect(x, y, w, h, r) ctx:beginPath() ctx:moveTo(x + r, y) ctx:arcTo(x + w, y, x + w, y + h, r) ctx:arcTo(x + w, y + h, x, y + h, r) ctx:arcTo(x, y + h, x, y, r) ctx:arcTo(x, y, x + w, y, r) ctx:closePath() end local function drawFood(now) local kind = food.kind if kind == "pong" then for i, tp in ipairs(food.trail) do local t = 1 - (i - 1) / 8 ctx.globalAlpha = 0.18 * t ctx.fillStyle = "#5de4ff" ctx:beginPath() ctx:arc(tp.x, tp.y, CELL * 0.3 * t, 0, math.pi * 2) ctx:fill() end ctx.globalAlpha = 1 ctx.fillStyle = "rgba(93,228,255,0.25)" ctx:beginPath(); ctx:arc(food.px, food.py, CELL * 0.5, 0, math.pi * 2); ctx:fill() ctx.fillStyle = "#5de4ff" ctx:beginPath(); ctx:arc(food.px, food.py, CELL * 0.32, 0, math.pi * 2); ctx:fill() ctx.fillStyle = "#e6ffff" ctx:beginPath(); ctx:arc(food.px, food.py, CELL * 0.13, 0, math.pi * 2); ctx:fill() elseif kind == "twin" then local fx, fy = (food.x + 0.5) * CELL, (food.y + 0.5) * CELL local a = now / 260 local off = CELL * 0.2 ctx.fillStyle = "rgba(255,215,0,0.18)" ctx:beginPath(); ctx:arc(fx, fy, CELL * 0.62, 0, math.pi * 2); ctx:fill() for s = -1, 1, 2 do local ox, oy = math.cos(a) * off * s, math.sin(a) * off * s ctx.fillStyle = "#ffd700" ctx:beginPath(); ctx:arc(fx + ox, fy + oy, CELL * 0.24, 0, math.pi * 2); ctx:fill() ctx.fillStyle = "#fff3b0" ctx:beginPath(); ctx:arc(fx + ox - 1.5, fy + oy - 1.5, CELL * 0.09, 0, math.pi * 2); ctx:fill() end -- ambient pixie dust if math.random() < 0.12 then spawn { kind = "star", x = fx + rnd(-CELL, CELL) * 0.7, y = fy + rnd(-CELL, CELL) * 0.7, vx = 0, vy = -14, life = 0.5, size = rnd(1.5, 3), color = "#fff3b0", rot = rnd(0, math.pi), spin = 2, twinkle = true, seed = rnd(0, 99) } end else local pulse = 0.75 + 0.25 * math.sin(now / 180) local fx, fy = (food.x + 0.5) * CELL, (food.y + 0.5) * CELL ctx.fillStyle = "#ff5d73" ctx:beginPath(); ctx:arc(fx, fy, CELL * 0.36 * pulse, 0, math.pi * 2); ctx:fill() ctx.fillStyle = "rgba(255,93,115,0.25)" ctx:beginPath(); ctx:arc(fx, fy, CELL * 0.55 * pulse, 0, math.pi * 2); ctx:fill() end end local function draw(now) -- background ctx.fillStyle = "#0d1117" ctx:fillRect(0, 0, canvas.width, canvas.height) -- faint grid ctx.strokeStyle = "rgba(255,255,255,0.035)" ctx.lineWidth = 1 for i = 1, GRID - 1 do ctx:beginPath(); ctx:moveTo(i * CELL, 0); ctx:lineTo(i * CELL, canvas.height); ctx:stroke() ctx:beginPath(); ctx:moveTo(0, i * CELL); ctx:lineTo(canvas.width, i * CELL); ctx:stroke() end drawFood(now) -- snake: head bright, body fading toward tail for i = #snake, 1, -1 do local s = snake[i] local t = (i - 1) / math.max(#snake - 1, 1) local g = math.floor(210 - 90 * t) ctx.fillStyle = (i == 1) and "#7ee787" or ("rgb(46," .. g .. ",120)") local pad = (i == 1) and 1 or 2 roundRect(s.x * CELL + pad, s.y * CELL + pad, CELL - pad * 2, CELL - pad * 2, 5) ctx:fill() end -- eyes on the head local h = snake[1] ctx.fillStyle = "#0d1117" local ex, ey = h.x * CELL + CELL / 2, h.y * CELL + CELL / 2 local ox, oy = dir.y * CELL * 0.18, dir.x * CELL * 0.18 local fxo, fyo = dir.x * CELL * 0.12, dir.y * CELL * 0.12 ctx:beginPath(); ctx:arc(ex + ox + fxo, ey + oy + fyo, 2.2, 0, math.pi * 2); ctx:fill() ctx:beginPath(); ctx:arc(ex - ox + fxo, ey - oy + fyo, 2.2, 0, math.pi * 2); ctx:fill() -- overlays if not started then ctx.fillStyle = "rgba(13,17,23,0.82)" ctx:fillRect(0, 0, canvas.width, canvas.height) ctx.fillStyle = "#7ee787" ctx.font = "bold 30px 'Courier New', monospace" ctx.textAlign = "center" ctx:fillText("SNAKE.LUA", canvas.width / 2, canvas.height / 2 - 38) ctx.fillStyle = "#c9d1d9" ctx.font = "15px 'Courier New', monospace" ctx:fillText("arrows / WASD to move", canvas.width / 2, canvas.height / 2 - 2) ctx.fillStyle = "#ffd700" ctx:fillText("gold twin +2", canvas.width / 2, canvas.height / 2 + 24) ctx.fillStyle = "#5de4ff" ctx:fillText("cyan bouncer +3", canvas.width / 2, canvas.height / 2 + 46) ctx.fillStyle = "#c9d1d9" ctx:fillText("press SPACE or tap to start", canvas.width / 2, canvas.height / 2 + 78) elseif not alive then if flashT > 0 then flashT = flashT - 0.05 end ctx.fillStyle = "rgba(13,17,23," .. (0.82 - 0.3 * math.max(flashT, 0)) .. ")" ctx:fillRect(0, 0, canvas.width, canvas.height) ctx.fillStyle = "#ff5d73" ctx.font = "bold 30px 'Courier New', monospace" ctx.textAlign = "center" ctx:fillText("GAME OVER", canvas.width / 2, canvas.height / 2 - 26) ctx.fillStyle = "#c9d1d9" ctx.font = "15px 'Courier New', monospace" ctx:fillText("score: " .. score .. " best: " .. high, canvas.width / 2, canvas.height / 2 + 10) ctx:fillText("SPACE or tap to play again", canvas.width / 2, canvas.height / 2 + 34) end -- particles render above overlays so explosions always pop drawParticles(now) end -- ---------------------------------------------------------------- main loop local function frame(_, now) if lastTime == nil then lastTime = now end local dt = math.min(now - lastTime, 50) -- clamp huge tab-switch gaps lastTime = now local dtSec = dt / 1000 if started and alive then acc = acc + dt while acc >= stepMs do acc = acc - stepMs step() if not alive then break end end updatePong(dtSec) end updateParticles(dtSec) draw(now) window:requestAnimationFrame(frame) end -- ---------------------------------------------------------------- input local function begin() if not started then started = true elseif not alive then reset() end end local function turn(x, y) -- ignore reversals into your own neck if x == -dir.x and y == -dir.y then return end nextDir = { x = x, y = y } end document:addEventListener("keydown", function(_, ev) local k = ev.key if k == " " or k == "Enter" then ev:preventDefault(); begin() elseif k == "ArrowUp" or k == "w" or k == "W" then ev:preventDefault(); turn(0, -1) elseif k == "ArrowDown" or k == "s" or k == "S" then ev:preventDefault(); turn(0, 1) elseif k == "ArrowLeft" or k == "a" or k == "A" then ev:preventDefault(); turn(-1, 0) elseif k == "ArrowRight" or k == "d" or k == "D" then ev:preventDefault(); turn(1, 0) end end) -- touch: tap to start, swipe to steer local touchX, touchY canvas:addEventListener("touchstart", function(_, ev) ev:preventDefault() local t = ev.touches[0] touchX, touchY = t.clientX, t.clientY begin() end) canvas:addEventListener("touchmove", function(_, ev) ev:preventDefault() if touchX == nil then return end local t = ev.touches[0] local dx, dy = t.clientX - touchX, t.clientY - touchY if math.abs(dx) < 24 and math.abs(dy) < 24 then return end if math.abs(dx) > math.abs(dy) then turn(dx > 0 and 1 or -1, 0) else turn(0, dy > 0 and 1 or -1) end touchX, touchY = t.clientX, t.clientY end) canvas:addEventListener("click", function(_, _) begin() end) -- ---------------------------------------------------------------- go high = loadHigh() highEl.textContent = tostring(high) started = false reset() window:requestAnimationFrame(frame)