Every classic Snake game everyone remembers from a Nokia phone ends your run the instant you touch a wall. RedoHub's version doesn't -- the snake wraps around to the opposite edge instead. That one rule change was the first real design decision I made, and it shaped a few other decisions after it.

Wrapping Edges Was a Deliberate Difficulty Choice, Not an Oversight

The collision check in gameTick() only ever looks at the snake's own body, never the boundary:

const next = { x: (head.x + state.dir.x + COLS) % COLS, y: (head.y + state.dir.y + ROWS) % ROWS, }; if (state.snake.some(s => s.x === next.x && s.y === next.y)) { die(); return; }

The modulo arithmetic on next.x and next.y is what makes the wrap possible -- adding COLS or ROWS before taking the modulo handles the negative-direction case correctly (JavaScript's % can return negative numbers for negative inputs, which would break a naive wrap). I chose this over wall-death for a very concrete reason: the board is only 20×20 cells, and on a phone screen that's a genuinely small play area. A wall-death rule punishes a player harshly for a single miscalculated turn near an edge, which happens constantly on a small board. Wrapping means the only way to actually lose is to run into your own body, which feels like a fairer test of the skill the game is actually about -- planning your snake's path -- rather than a test of precisely judging distance to an edge.

The Direction You Pressed Isn't the Direction That Moves -- Yet

Keyboard, D-pad, and swipe input all funnel into the same function, and that function doesn't touch the snake's actual movement direction at all:

function setDir(d) { const nd = DMAP[d]; if (nd.x !== -state.dir.x || nd.y !== -state.dir.y) { state.nextDir = nd; } }

It writes to state.nextDir, a separate field from state.dir. The actual direction only updates once, at the very start of the next gameTick(). This is the fix for a bug every Snake clone runs into eventually: if a player presses Down then Right in rapid succession while moving Up, and both keypresses are allowed to update the live direction immediately, the snake can end up moving Down for a single frame -- directly into the segment behind its own head -- before the second input takes effect. Buffering into nextDir and only committing once per tick guarantees the snake changes direction exactly once per tick, no matter how many keys get pressed between ticks.

The other half of that same bug is the explicit 180°-reversal check: nd.x !== -state.dir.x || nd.y !== -state.dir.y. Without it, pressing the opposite arrow key while moving would let the snake instantly reverse into the segment right behind its head, which is an instant, cheap death that has nothing to do with player skill. Rejecting the reversal outright, rather than letting it happen and immediately killing the player, is a small mercy that keeps a mistimed keypress from feeling unfair.

Rendering Every Frame, But Only Moving on a Timer

The game loop calls itself via requestAnimationFrame on every single frame, but it only advances the snake's position when enough time has passed:

function loop(ts) { if (state.phase !== 'playing') return; rafId = requestAnimationFrame(loop); if (ts - lastTick >= speed()) { lastTick = ts; gameTick(); } drawFrame(); }

Splitting "how often we redraw" from "how often the snake actually moves" is what lets the speed ramp exist at all. If I'd used setInterval(gameTick, speed()) instead, changing the speed mid-game would mean tearing down and recreating the interval every time the score crosses a threshold, which is fiddly and easy to get slightly wrong. Here, speed() is just a number the loop checks against a timestamp on every frame, so the tick rate can change on the very next check with no bookkeeping at all.

The Speed Ramp Has a Floor for a Reason

function speed() { return Math.max(MIN_SPEED, BASE_SPEED - state.score * SPEED_STEP); }

Every point makes the tick interval 10ms shorter, starting from a 150ms base. Without the Math.max(MIN_SPEED, ...) floor, a sufficiently high score would drive the tick interval toward zero or negative, and the game would either freeze trying to process an absurd number of ticks per frame or behave unpredictably. Capping it at 70ms means the game gets meaningfully harder for the first several dozen points and then plateaus at "about as fast as a human can react," rather than becoming mathematically, rather than skillfully, impossible.

Bonus Food Is a Timed Risk, Not a Guaranteed Bonus

Every fifth food eaten spawns a second, higher-value item that only exists for 80 ticks before it despawns:

if (state.eaten % 5 === 0 && !state.bonus) placeBonus(); // ...later, each tick the bonus isn't eaten: state.bonusTimer--; if (state.bonusTimer <= 0) state.bonus = null;

Making the bonus worth 3 points instead of 1 but time-limited turns it into an actual decision point instead of free value: chase it and risk a worse path toward your next few moves, or ignore it and stay safe. A bonus with no expiry would just be "extra points if convenient," which doesn't create any tension. The ticking countdown is what makes it a choice.

High Scores Live in localStorage, and That's Enough

The high score is read once on load with parseInt(localStorage.getItem('snack_hi') || '0') and written back the moment a run beats it. There's no account system and no server involved, which means the high score is really "your best score on this browser, on this device" rather than a global leaderboard. For a casual game meant to be picked up for a couple of minutes between other things, I think that's the right amount of investment -- a real leaderboard needs a backend, moderation for cheating, and ongoing maintenance, none of which a five-minute distraction game needs to justify.