Snake, Playable Right Here in This Post
Yesterday I wrote about wiring up Godot so Claude Code could build Pong and actually see what it made. That post ended with a working game, but you had to take my word for it. Screenshots are proof, but they are not fun.
So this time the goal was different: build a game with no engine at all, and ship it inside the post about building it. No download, no embed from some game portal, no iframe pointing at another site. The game below is a React component sitting in this article, running on the same page you are reading. Give it a try.
Snake
Arrows / WASD to steer. Swipe on mobile.
space pauses · high score stays in your browser
Snake is the right pick for this. The rules fit in your head, the whole thing is around 300 lines, and yet it still forces you to solve the three problems every real-time game has: a loop, input, and rendering. Here is what each one taught us.
A game loop that does not fight React
React wants to re-render when state changes. A game wants to update sixty times a second. If you put the snake's position in useState, you get sixty re-renders a second and a component that spends more time reconciling than playing. It works, technically, the way a car with square wheels technically rolls.
The fix is to keep two kinds of state and refuse to let them mix. Everything the game loop touches lives in refs: the snake segments, the current direction, the food position, the tick speed. Refs update instantly and trigger nothing. React state is reserved for the moments a human actually needs the UI to change: the score ticking up, the game over screen, the pause overlay. In practice React re-renders a handful of times per game instead of thousands.
The loop itself is requestAnimationFrame with a fixed-timestep accumulator. The browser calls the frame callback whenever it feels like it, often 60 times a second, sometimes 120 on a fast display, sometimes 11 when the tab is struggling. You do not want your snake moving faster on a gaming monitor. So the callback just accumulates elapsed time and steps the simulation in fixed slices. Godot gives you _physics_process for free and this is the same idea, built by hand in about eight lines.
The oldest bug in Snake
Every Snake implementation eventually meets the same bug. The snake is moving right. A player quickly presses up, then left, inside a single tick. Each input looks legal on its own: up is not the opposite of right, and left is not the opposite of up. But the snake only moved once, so the net effect is a 180 degree turn straight into its own neck. Game over, and the player is certain the game cheated them, because they never pressed left while moving right.
The fix is a tiny input queue. New directions are validated against the last queued direction rather than the direction the snake is currently facing, and the queue holds at most two moves. That is the whole patch, maybe six lines, and it is the difference between a game that feels fair and one that eats inputs. My favorite part of this project is that the most important six lines are invisible when they work.
Touch was the real work
The keyboard part of input handling is a lookup table: arrows and WASD map to directions, and the handler calls preventDefault so arrow keys stop scrolling the article while you play. Done in ten minutes.
Mobile is where the effort went, and after fixing this site's missing mobile menu yesterday I was not going to ship a desktop-only toy. Swiping is the natural way to steer Snake on a phone, so the canvas listens for touches and turns any drag past a small threshold into a direction change, then resets the anchor point so you can chain swipes without lifting your finger. The part nobody tells you: the listener has to be registered as non-passive and the canvas needs touch-action: none, or the browser will interpret every downward swipe as an attempt to scroll the page. Your snake dives and the article scrolls. Both, at once. It is as bad as it sounds.
The details that make it feel finished
None of these took more than a few minutes, and together they are most of the difference between a demo and a game:
- The canvas renders at device pixel ratio, so the grid is crisp on a phone screen instead of blurry.
- The game pauses itself when the tab loses focus. Coming back to a dead snake you did not kill is a bad feeling.
- The speed ramps up a little with every food eaten, from a comfortable start toward a cap that stays humanly playable.
- Your high score persists in
localStorage. It never leaves your browser, there is no leaderboard server, and I have no idea what you scored.
What carried over from the Godot experiment
The lesson from the Pong post was to keep the game's rules in plain code, away from the engine, so they can be tested without a display. That habit transferred directly. The tick function here takes the current state and produces the next one: move the head, check the walls, check the body, grow or shrink. It does not know the canvas exists. Rendering is a separate pass that reads the state and draws it.
That separation was the whole reason the AI feedback loop worked in Godot, and it turns out it is just as valuable when the engine is a browser. Same principle, different rendering target: the rules do not care whether the pixels come from a scene tree or a 2D context.
Two games in two days, one in a real engine and one in a blog post. If you beat my high score, the contact form is on the home page, and yes, I will want proof.