GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

CSS Snake Game: 9 Boards That Find Their Own Path

Open a css snake game and the snake already turns toward the food before you touch a key — scene 01's shortest path and scene 02's Hamiltonian cycle

Run All Nine Snake Games Right on This Page

Auto-plays · click a tile to jump to its section · all nine in one zip

CSS Snake Game: 9 Boards Where Pathfinding, Cycles and Interpolation Move the Snake

The board is drawn on a canvas and the frame around it is CSS. JavaScript in each scene's index.html paints the cells, the snake and the food and then picks the next move, while CSS owns the stage size, the status pill, caption and button laid on top, and the keyboard focus outline. The stage is min(92vw, calc(160vh - 12.8vw)) wide with a 480:300 ratio and 16px corners.

Inside, the canvas is 480 by 300 multiplied by devicePixelRatio (capped at 2), so resizing the window never changes how many cells the board has. makeBoard(GRID_N) sets the cells: 16 rows give 18.75px cells, 25 across and 16 down for 400 cells; 12 rows give 25px cells and 228 of them; 8 rows give 37.5px cells and 96.

Time moves in fixed 16 ms steps. A scene piles those steps up and runs one logic tick each time TICK_MS fills, then draws once per frame. The only source of randomness is a seeded lcg, so the food lands on the same cells every run, and scenes 01, 02, 03, 05, 06, 07 and 08 spend hidden warm-up ticks so they open mid-game. Scene 01 opens at length 54, 02 at 91 of 96 cells, 08 at score 0070 with length 10 and 07 at length 11, while 04 and 09 are visible from their first frame.

The nine scenes are sorted by what the snake computes. Scenes 01 and 02 choose the next cell, and 03 and 04 are two ways to draw that same movement as a body. Scenes 05, 07 and 09 paint values onto cells or skin, 06 changes the shape of the board and 08 adds a reaction at the moment of eating.

Choosing by use is the quick route. An auto-play demo or an AI opponent preview starts at 01, an endless idle screen at 02, a mobile screen where smooth motion matters at 03, and a free-moving worm character at 04. Play-history visualization is 05, a map without walls is 06, a skin shop preview is 07, a score-reward HUD is 08 and a pathfinding explainer screen is 09.

If you need a snake that a person steers with arrow keys first, HTML CSS JS mini games: 9 copy-paste mini apps or 9 CSS Game Demos — Mini Games in HTML & CSS fits better. This page is for the other case, where nobody touches anything and the snake finds its own way.

Code for Each Technique Behind a Snake Game in HTML, CSS and JavaScript

Every snippet is a run of consecutive lines cut from the <script> of that scene's index.html, and none passes 25 lines. If you want to create a snake game using html css and javascript, the usual start is key input and collision checks; these nine scenes show the judging and drawing math instead.

01A Snake That Takes the Shortest Breadth-First Path and Chases Its Tail When Boxed In

Each tick a breadth-first search fills distances outward from the food and picks the shortest road for the head, then simulate walks that road to build a virtual body. tailReachable asks whether the virtual head can still reach the virtual tail, and if not the road is dropped (the pink line) and the snake follows its tail. The capture of 24 frames measured 49.8% cumulative area, mean intensity 114.0 and 23/23 frames moving.

A Snake That Takes the Shortest Breadth-First Path and Chases Its Tail When Boxed In
Open in a new tab

01 · A Snake That Takes the Shortest Breadth-First Path and Chases Its Tail When Boxed In — Measured area 49.751% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

TICK_MSGRID_NSAFE_CHECKREJECT_LIFE
        if (g.food >= 0) {
            const dist = distField(b, g.food, blocked, g.wrap);
            const path = pathFrom(b, dist, head, g.wrap);
            plan.path = path;
            if (path.length > 0) {
                if (!safeOn) {
                    plan.next = path[0];
                    return plan;
                }
                const v = simulate(g.body, path);
                if (tailReachable(b, v, g.wrap, plan.trap)) {
                    plan.next = path[0];
                    return plan;
                }
                plan.rejected = path;
            }
        }

02A Snake Filling the Whole Board on a Hamiltonian Cycle

Every cell gets a number ord along one fixed loop through the board, and the snake skips ahead on that loop when a neighbor is closer to the food without overtaking the tail. It starts with 91 of 96 cells already filled by hidden ticks, so the board is full about 10 ticks after you see it, holds still for 14 ticks and then opens a new one. Cumulative area is 61.4% and mean intensity 90.0, and the 17/23 moving frames come from the full board sitting unchanged while it waits.

A Snake Filling the Whole Board on a Hamiltonian Cycle
Open in a new tab

02 · A Snake Filling the Whole Board on a Hamiltonian Cycle — Measured area 61.428% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

GRID_NSHORTCUT_MARGINTICK_MSWARM_FILL
            let best = cyc[(ord[head] + 1) % N];
            let bestD = 1;
            for (let d = 0; d < 4; d += 1) {
                const nb = neighborCell(b, head, d, false);
                if (nb < 0 || occupied[nb] === 1 || nb === tail) {
                    continue;
                }
                const dc = cd(head, nb);
                if (dc > bestD && dc <= foodD && dc < tailD - SHORTCUT_MARGIN) {
                    best = nb;
                    bestD = dc;
                }
            }
            if (bestD > 1) {
                cuts += 1;
                jump = 6;
            }
            moveSnake(g, best);

03A Snake Body That Glides by Pixels Between Ticks Instead of Jumping by Cells

Logic hops one cell per 100 ms tick, and the drawn position px, py moves toward each logic cell center by BODY_LERP 0.35 on every 16 ms step. A tap switches the interpolation off so the body snaps to the cell centers, which lets you compare the cream outline squares (logic cells) with the thick body. The scene measured 18.5% cumulative area, mean intensity 78.6 and 23/23 frames moving.

A Snake Body That Glides by Pixels Between Ticks Instead of Jumping by Cells
Open in a new tab

03 · A Snake Body That Glides by Pixels Between Ticks Instead of Jumping by Cells — Measured area 18.462% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

BODY_LERPTICK_MSTUBE_FRACTRAIL_LEN
                // the drawn position chases the logic cell by BODY_LERP
                for (let i = 0; i < g.body.length; i += 1) {
                    const tx = cellX(b, g.body[i]);
                    const ty = cellY(b, g.body[i]);
                    if (smoothOn) {
                        px[i] = lerp(px[i], tx, BODY_LERP);
                        py[i] = lerp(py[i], ty, BODY_LERP);
                    }
                    else {
                        px[i] = tx;
                        py[i] = ty;
                    }
                }

04A Gridless Snake Whose Chain Body Is Dragged Behind the Head by Distance Constraints

There are no cells, and only the head moves directly along its heading ang. The segments behind the head (25 at the start, with more added as it eats) coast on their previous offset times DAMP 0.97 and are then pulled back to a 9px gap from their neighbor by a constraint solved 4 times per step. Cumulative area is 7.7%, the smallest of the nine, with mean intensity 50.4 and 23/23 frames moving.

A Gridless Snake Whose Chain Body Is Dragged Behind the Head by Distance Constraints
Open in a new tab

04 · A Gridless Snake Whose Chain Body Is Dragged Behind the Head by Distance Constraints — Measured area 7.728% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SEG_GAPSTEER_RATESEG_COUNTFOLLOW_ITER
                // distance constraint: pull the gap to the neighbor back to SEG_GAP
                for (let it = 0; it < FOLLOW_ITER; it += 1) {
                    for (let i = 1; i < count; i += 1) {
                        const dx = px[i] - px[i - 1];
                        const dy = py[i] - py[i - 1];
                        const d = Math.sqrt(dx * dx + dy * dy) || 0.0001;
                        const k = ((d - SEG_GAP) / d) * FOLLOW_STIFF;
                        if (i === 1) {
                            px[i] -= dx * k;
                            py[i] -= dy * k;
                        }
                        else {
                            px[i] -= dx * k * 0.5;
                            py[i] -= dy * k * 0.5;
                            px[i - 1] += dx * k * 0.5;
                            py[i - 1] += dy * k * 0.5;
                        }
                    }
                }

05A Snake Whose Visited Cells Keep Heat That Cools Into a Trail Map

When the head enters a new cell its heat gains 1 (capped at 1.4), and every 16 ms step multiplies all cells by 0.988. That single multiplication gives a half-life near 57 steps (0.92 seconds), and heatColor cuts the value into 7 bands from purple up to cream. The render reads 22.8% cumulative area, mean intensity 62.6 and 23/23 frames moving.

A Snake Whose Visited Cells Keep Heat That Cools Into a Trail Map
Open in a new tab

05 · A Snake Whose Visited Cells Keep Heat That Cools Into a Trail Map — Measured area 22.775% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

HEAT_DECAYHEAT_GAINGRID_NPALETTE_STEPS
    function heatColor(h, steps) {
        let v = clamp(h, 0, 1);
        if (steps > 0) {
            v = Math.floor(v * steps) / steps;
        }
        const f = v * (RAMP.length - 1);
        const i = Math.min(RAMP.length - 2, Math.floor(f));
        return mix(RAMP[i], RAMP[i + 1], f - i);
    }

06A Snake That Exits the Far Side of a Wall and Is Drawn in Two Halves at the Seam

Neighbors and distances use the remainder operator so the board has no edge, and wrapDelta takes the shorter way between segments so nothing jumps across the board. Drawing repeats each segment in a 3 by 3 set of positions clipped to the board rectangle to stitch the seam, and a tap turns the copies off so only half a segment shows at the edge. The scene measured 20.9% cumulative area, mean intensity 95.3 and 23/23 frames moving.

A Snake That Exits the Far Side of a Wall and Is Drawn in Two Halves at the Seam
Open in a new tab

06 · A Snake That Exits the Far Side of a Wall and Is Drawn in Two Halves at the Seam — Measured area 20.9% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

WRAP_MODEGRID_NSEAM_GHOSTSTICK_MS
    function wrapDelta(a, c, n, wrap) {
        let d = c - a;
        if (wrap) {
            if (d > n / 2) {
                d -= n;
            }
            if (d < -n / 2) {
                d += n;
            }
        }
        return d;
    }

07A Snake Skin Whose Scale Pattern and Brightness Wave Flow Along the Body

Each scale's brightness is a sin(k·SCALE_FREQ − phase) wave over the segment index k, mixed with hash2 noise of NOISE_AMP 0.28, and phase grows by 0.07 each step so the wave runs along the body. A segment carries 4 scales, and the 360 background circles catch the same wave on a diagonal, which turns the whole board into scale wallpaper. Cumulative area is 41.1%, mean intensity 59.9 and 23/23 frames moving.

A Snake Skin Whose Scale Pattern and Brightness Wave Flow Along the Body
Open in a new tab

07 · A Snake Skin Whose Scale Pattern and Brightness Wave Flow Along the Body — Measured area 41.128% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SCALE_FREQWAVE_SPEEDNOISE_AMPSKIN_MIX
        // brightness of one scale: a sine wave over the index plus per-cell hash noise
        function scaleLight(k, seedA, seedB) {
            const wave = 0.5 + 0.5 * Math.sin(k * SCALE_FREQ - phase);
            const noise = (hash2(seedA, seedB, 455) - 0.5) * 2 * NOISE_AMP;
            return clamp(wave * (1 - NOISE_AMP * 0.5) + noise * 0.5 + 0.25, 0, 1);
        }

08A Snake Whose Food Bursts Into Gravity-Bound Particles While the Score Digits Bounce Up

At the moment of eating, 28 particles fly out radially from the food cell and fall under GRAVITY 0.075, each fading by 0.017 per step until it disappears after about 0.56 to 0.94 seconds. The score has four digits, and every digit that changes gets a kick of −4.2 upward and a spring brings it back. The measurement is 20.1% cumulative area, mean intensity 100.2 and 23/23 frames moving.

A Snake Whose Food Bursts Into Gravity-Bound Particles While the Score Digits Bounce Up
Open in a new tab

08 · A Snake Whose Food Bursts Into Gravity-Bound Particles While the Score Digits Bounce Up — Measured area 20.14% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

BURST_COUNTGRAVITYLIFE_DECAYDIGIT_STIFF
        function burst(x, y) {
            for (let i = 0; i < BURST_COUNT; i += 1) {
                const a = rnd() * PI2;
                const sp = 0.9 + rnd() * 2.7;
                sparks.push({ x: x, y: y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 1.2, life: 0.6 + rnd() * 0.4, c: BURST_COLORS[i % BURST_COLORS.length] });
            }
            rings.push({ x: x, y: y, r: 4, life: 1 });
        }

09A Snake That Rides Down the Slope of a Distance Wave Spreading From the Food

A breadth-first pass fills every cell with its distance to the food, the wave colors come from those distances, and the snake steps to the lowest-valued neighbor of its head (lowestNeighbor). Arrow keys move the food one cell, but only onto an empty neighboring cell, and a tap freezes the wave so equal-valued cells stay as a band. Painting the whole board with distance colors gives 73.6% cumulative area, the largest of the nine, with mean intensity 67.0 and 23/23 frames moving.

A Snake That Rides Down the Slope of a Distance Wave Spreading From the Food
Open in a new tab

09 · A Snake That Rides Down the Slope of a Distance Wave Spreading From the Food — Measured area 73.622% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

WAVE_SPEEDGRID_NDIST_COLOR_STEPSTICK_MS
        function refreshField() {
            const blocked = new Uint8Array(b.n);
            for (let i = 0; i < g.body.length - 1; i += 1) {
                blocked[g.body[i]] = 1;
            }
            if (g.food >= 0) {
                dist = distField(b, g.food, blocked, false);
            }
            stepTo = lowestNeighbor(b, dist, g.body[0], false);
        }

Where the Values Change the Feel — Tick Interval, Board Size, Body Interpolation

Three values change how the snake reads. The numbers in this section were counted by calling the source functions in node rather than from the browser capture, so read them apart from the measurement table at the bottom.

Cutting the Tick Interval From 90 ms to 50 ms Gives Three Times the Food in 10 Seconds

Change only TICK_MS in scene 01 from 90 to 50 and run 10 seconds from the same state: the length goes from 54 to 57 at 90 ms but from 54 to 63 at 50 ms. That is 3 food pieces against 9. Both settings drop 2 paths, so the decisions stay the same and only the speed differs.

Growing the Board From 8 Rows to 12 Makes the Fill Take 5.9 Times Longer

Raising GRID_N in scene 02 from 8 to 12 turns 96 cells into 228. Filling the board from scratch takes 10,665 ticks instead of 1,818, which at 60 ms a tick is 640 seconds instead of 109. Shortcuts rise from 171 to 811, but the longer lap of the cycle outweighs them.

All six seeds I tried filled the board from the start, in 1,818 to 2,113 ticks. Turning shortcuts off (margin 999) needs 2,311 ticks, 27% more, and SHORTCUT_MARGIN at 0, 2 and 6 takes 1,948, 1,818 and 1,885 ticks, so the default 2 was the quickest.

Lowering the Interpolation Ratio From 0.35 to 0.12 Drags the Body a Full Cell Behind

With BODY_LERP at 0.35 in scene 03, the drawn segments trail their logic cell centers by 8.5px on average, 0.34 of a 25px cell. At 0.12 the gap becomes 26.8px, or 1.07 cells, so the body arrives about one cell after the head. Setting it to 1 makes px[i] = tx, the same picture as no interpolation.

The table collects what a full render measured for each scene.

Scene Cumulative area Mean intensity Moving frames
01 Safe-check autopilot 49.8% 114.0 23/23
02 Hamiltonian cycle 61.4% 90.0 17/23
03 Interpolated body 18.5% 78.6 23/23
04 Chain rope 7.7% 50.4 23/23
05 Heat trail 22.8% 62.6 23/23
06 Torus 20.9% 95.3 23/23
07 Scale skin 41.1% 59.9 23/23
08 Eating burst 20.1% 100.2 23/23
09 Distance wave 73.6% 67.0 23/23

Where It Breaks — A Full Board, Wall Wrapping, Reduced-Motion Settings

Turning the Check Off Traps the Snake 6 Times in 600 Seconds on the Same Seed

With SAFE_CHECK on in scene 01, 600 seconds (length 209, 44 dropped paths) ended with 0 traps, and with it off the same seed trapped 6 times. The check only looks as far as the first meal and whether the tail is reachable after it, never at the food after that, so the 0 is a result for this seed and this duration and not proof that it cannot happen. Scene 09, which follows the distance field with no check, was trapped 6 times in 600 seconds too, while 03 and 08 stayed at 0.

The Cycle Closes Only When the Row Count Is Even

The loop in scene 02 crosses the top row, zigzags down through the remaining rows and climbs back up the left column. I built it for a 7-row board (11 by 7, 77 cells) and one link joined two cells that are not neighbors, so the game still ran but the head jumped across that gap. That is why the board needs an even number of rows.

Too Few Constraint Passes Stretch the Chain

In scene 04 with FOLLOW_ITER at 4 the gap between neighbors averages 9.04px and peaks at 10.46px, and at 1 it averages 9.18px and peaks at 12.67px, about 1.4 times the 9px target. Nothing checks chains against each other, so the body passes through itself freely.

The Score Field Has Four Digits, So 10000 Rolls Over to 0000

digits() in scene 08 keeps only the last four characters, so at a score of 10000 the cells show 0000 while the status line prints 10000. Capping the score or adding digits is left to whoever ships it.

Under Reduced Motion Only Input Changes the Picture

When prefers-reduced-motion is on, none of the nine scenes starts its requestAnimationFrame loop; each draws one frame advanced by 1,400 ms. Scene 02 leaves the full board standing and opens a new one only on a tap.

If you want to change the values yourself, grab the archive from the card below and open it with 9tzpwtwu. It holds an HTML and SCSS pair for every scene plus a React version.

Three Variants and Accessibility

Name Value Feel
Change the tick interval 01 TICK_MS 90 → 50 Run 10 seconds from the same state and 90 ms grows the snake from 54 to 57 while 50 ms grows it from 54 to 63, three times the food. Both drop 2 paths, so the decisions stay the same and only the speed changes.
Change the board size 02 GRID_N 8 → 12 Growing the board from 96 to 228 cells means filling it from scratch takes 10,665 ticks instead of 1,818, about 5.9 times longer. At 60 ms a tick that is 640 seconds instead of 109.
Change the body interpolation strength 03 BODY_LERP 0.35 → 0.12 The average distance the drawn body trails behind its logic cell centers grows from 8.5 px (0.34 of a 25 px cell) to 26.8 px (1.07 cells), so the body is dragged about a full cell behind the head.

These three sets of numbers were also counted by calling the source functions in node.

Every scene shares one input skeleton. Each has a single <button>, and its canvas carries tabindex="0" and an aria-label. Enter, Space and a pointer press all reach the scene's tap action, and preventDefault keeps Space from scrolling the page. Only 04 and 09 use arrow keys, so only those two block the arrow-key default; in the other seven Tab simply moves from the canvas to the button. The status line is aria-live="polite" and updates only when length, dropped paths or score change.

With prefers-reduced-motion on, the button becomes the Next scene button and each press advances 900 ms, while pointer moves and key presses advance 128 ms and redraw right away. In all nine scenes, Enter and Space run that scene's tap action once, then advance 128 ms and redraw.

Is Plain JavaScript Enough for a Snake Game with HTML, CSS and JS Source Code?

For a snake game html css js source code, these nine files are unusually bare: no library, no CDN, no WebGL, and no Math.random, Date, fetch or localStorage anywhere. Pathfinding, the cycle, interpolation and particles all run on requestAnimationFrame and the 2D canvas, so plain JavaScript is enough for a snake that moves on its own.

What is missing is the part that turns a toy into a product. There is no high-score storage and no sound, and human arrow-key control exists only in a light form in 04 and 09. For reflection angles on breaking bricks the next step is Brick Breaker Game JavaScript — 9 Physics Patterns, and for stacking-block rules it is Tetris JavaScript: Build It in 9 Canvas Steps. If gravity and collision physics interest you more, see 9 Bouncing Ball Animation Patterns With Physics.

Frequently Asked Questions Before Reusing a Snake Game HTML CSS JS Source Code

Can I put this snake game html css javascript source in my own repository as it is?

Nothing is pulled in from outside, so there is no dependency list to ship with it. Each scene is one index.html with its styles and script inside, it runs when pasted, and the seeded random numbers reproduce the same picture. Any license text or repository rules are for you to set on your side.

Where do I change the food positions so every run differs?

In scene 01 the last number in newGame(b, false, 455101) is the lcg seed, and scenes 02, 03 and 05 take the same form, so changing the number gives a different board. For a fresh board every time you would have to supply your own value there, and then the same picture no longer repeats.

Does a bigger board make it slower?

In scene 02 a bigger board makes the fill 5.9 times longer in ticks, while each tick only compares cycle positions and checks four neighbors. Scene 01 reruns a breadth-first search over every cell each tick, so the work per tick should grow with the cell count. I did not measure frame time on a bigger board.

Enter the archive password

The password is inside this article. You will find it as you read.