Post

Rebuilding Snake: From a 4-Hour Haxe Jam Game to 10 Levels in Godot 4

Rebuilding Snake: From a 4-Hour Haxe Jam Game to 10 Levels in Godot 4

I built a Snake game in 4 hours for a game jam back in 2014. Haxe, OpenFL, Flash target, 30 FPS. Three hand-crafted levels, frame-counter movement, bitmap tile rendering. It worked. Then it sat untouched for over a decade.

This week I brought it back to life in Godot 4.4 — and then went way beyond the original. The final version has 10 levels (7 procedurally generated), pixel-styled snake graphics with directional eyes, pulsing gem-like food, an event-driven input system that fixes the original’s control bugs, procedural chiptune music, and an HTML5 export you can play right in the browser. All of it lives in a single 1,155-line GDScript file.

The whole thing was built across three Claude Code sessions with Opus 4.6. Here’s the story.

Snake conversion diagram

The Original: 4-Hour Jam Game

The original Snake was built with:

  • Haxe targeting Flash via OpenFL
  • Bitmap tile rendering — a flat array of tile types drawn as colored rectangles
  • Frame-counter movement — the snake moves every speed frames at a fixed 30 FPS
  • Three hand-designed levels stored as integer arrays (20x20, 20x20, 40x40)
  • Quirky conventions: xPos meant row (vertical), yPos meant column (horizontal)

The game was a single Game.hx class — about 500 lines. Simple, functional, janky in all the right game-jam ways.

Session 1: The Faithful Port

The first Claude Code session was a straight conversion. I pointed Claude at the original Haxe source and said “convert this to Godot 4.” The goals were:

  1. Identical gameplay — same levels, same movement timing, same food mechanics
  2. Time-based movement instead of frame counting — interval = (speed + 1) / 30.0 to match the original’s feel at any framerate
  3. HTML5 export via Godot’s GL Compatibility renderer

The tricky parts were the original’s quirks. The food counter logic was food_counter > num_food, which means you need numFood + 2 food items to advance — not numFood. The HUD shows num_food - food_counter + 2 as a countdown. Food is rendered as blue (the water tile color), not a distinct food color. These are all bugs-as-features from a 4-hour jam, and they’re all faithfully preserved.

1
2
3
4
5
6
# Time-based movement matching original's frame counter at 30 FPS
var interval := (snake_spd + 1) / 30.0
if move_tmr >= interval:
    prev_snake = snake.duplicate()
    _do_move()
    move_tmr = 0.0

Level 3’s data — 1,600 integer values for a 40x40 grid — was the most error-prone part. Transcribing it by hand is a fool’s errand. Claude extracted it programmatically from the Haxe source and verified the array sizes matched. Lesson learned: never manually transcribe large data arrays.

The game ran on the first export. Movement interpolation (lerp between previous and current tile positions) made it look smooth instead of tile-snapping. A countdown timer (3-2-1-GO!) was added because the original dropped you in with no warning.

Session 2: Polish Pass 1

With the core working, I came back for quality-of-life improvements:

  • Procedural chiptune music — a 16-beat loop generated entirely in code using triangle and sine waves in C major pentatonic. No audio files needed. The music generates a PackedByteArray of 16-bit PCM samples, wraps it in an AudioStreamWAV, and loops forever.
  • Food pickup effects — expanding rings and particle squares that burst outward when you eat food
  • Snake interpolation — smooth movement between tiles instead of snapping
  • UI panels — menu, countdown, game over with level select, win screen
1
2
3
4
5
# Procedural music: triangle wave melody + sine wave bass
var mel_phase := fmod(t_sec * mel_freq, 1.0)
var mel_wave: float = 4.0 * abs(mel_phase - 0.5) - 1.0
var mel_env: float = max(0.0, 1.0 - beat_frac * 2.0)
mel_env *= mel_env  # quadratic decay

Session 3: The Big One — Controls, Graphics, and 10 Levels

The third session was the most ambitious. Four major systems in one pass:

Input System Overhaul

The original polled keys every frame with Input.is_key_pressed() and checked 180-degree reversals against the pending direction. This had two bugs:

  1. Sluggish controls — quick taps between frames got missed entirely
  2. Self-death from rapid input — if you pressed DOWN then LEFT between ticks while moving RIGHT, the LEFT press would pass the reversal guard (checked against DOWN, not the actual last move), and the snake would turn into itself

The fix was an event-driven input queue:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func _unhandled_input(event: InputEvent) -> void:
    if state != ST_PLAYING:
        return
    if not event is InputEventKey or not event.pressed or event.echo:
        return
    # Queue up to 2 direction changes
    if input_queue.size() < 2:
        input_queue.append(new_dir)

func _dequeue_dir() -> void:
    while input_queue.size() > 0:
        var candidate: int = input_queue[0]
        input_queue.remove_at(0)
        # Check against last ACTUAL move, not pending direction
        if snake.size() > 1 and _is_opposite(candidate, last_move_dir):
            continue
        dir = candidate
        return

The key insight: last_move_dir tracks the direction of the most recent actual move, not the pending direction. The reversal guard checks against this, so rapid inputs can never cause self-collision. The queue holds up to 2 entries, so quick direction changes (like rounding a corner) are captured even between movement ticks.

Refined Snake Graphics

The original drew flat colored rectangles. The new version uses bordered segments with alternating shade pairs:

1
2
3
4
5
6
7
8
9
func _draw_snake_segment(pos, is_head, seg_index):
    var border := maxf(1.0, tile_draw_size * 0.1)
    if is_head:
        draw_rect(Rect2(pos, size), Color8(100, 0, 0))        # dark red border
        draw_rect(Rect2(pos + border_vec, inner), Color8(200, 20, 20))  # bright red fill
        _draw_snake_eyes(pos, size)  # white squares + black pupils
    else:
        # Even segments: dark orange / bright orange
        # Odd segments: darker orange / slightly different bright

The head has two white square eyes with black pupils that shift in the movement direction — a small touch that gives the snake personality.

Pulsing Food Diamonds

Food is no longer a flat blue rectangle. It’s drawn as a diamond (rotated square) on top of the terrain tile, with a pulsing scale animation and a lighter highlight diamond for a gem-like shine:

1
2
3
4
5
6
7
8
9
var pulse: float = 0.35 + 0.08 * sin(game_time * 5.0)
var half := tile_draw_size * pulse
var diamond := PackedVector2Array([
    center + Vector2(0, -half),
    center + Vector2(half, 0),
    center + Vector2(0, half),
    center + Vector2(-half, 0),
])
draw_colored_polygon(diamond, Color(0.2, 0.4, 1.0))

Seven Procedurally Generated Levels

The original had 3 hand-crafted levels. The new version has 10 — the original 3 plus 7 generated at runtime:

Level Size Shape Terrain Food Grow Speed
1 20x20 Square Grass 4+2 2 -1
2 20x20 Cross with center hole Grass 6+2 3 -1
3 40x40 Maze with text art Dirt 8+2 4 -2
4 22x22 Plus/Cross Grass 5+2 2 -1
5 24x24 Arena with 4 pillars Dirt 6+2 3 -1
6 26x26 Donut (hollow center) Grass 7+2 3 -1
7 24x24 Divided (cross walls, doorways) Dirt 7+2 3 -2
8 28x28 Scattered wall islands Grass 8+2 3 -1
9 30x30 Diamond (rotated square) Dirt 9+2 4 -2
10 34x34 Fortress (inner ring, 4 openings) Dirt 10+2 4 -2

Three helper functions drive the generation:

  • _gen_grid(s) — creates an s*s array filled with T_BLANK
  • _fill_rect(grid, s, r1, c1, r2, c2, val) — stamps a rectangular region
  • _wall_border(grid, s) — scans every non-blank tile and checks its 8 neighbors; if any neighbor is blank or out-of-bounds, it becomes a wall

The wall border function is the clever part. You design levels by painting floor tiles onto a blank canvas, and the walls generate themselves automatically. The diamond level (9) just fills tiles where abs(r - mid) + abs(c - mid) <= mid - 1, and the wall border traces a perfect diamond perimeter.

Working with Claude: Three Sessions, One Game

By the Numbers

Metric Session 1 Session 2 Session 3 Total
Human prompts ~8 ~6 ~6 ~20
Lines of GDScript ~600 ~770 ~1,155 (cumulative)
Key features Core port, 3 levels, movement Music, effects, UI Input, graphics, 10 levels Full game
Wall clock time ~2 hours ~1.5 hours ~1.5 hours ~5 hours
Context compactions 0 0 0 0

Estimated total token usage across all three sessions: ~30-40M tokens processed (dominated by cache-read input). Actual new output tokens: ~200-300K. Cost: roughly the price of two coffees.

What Worked Well

Incremental sessions with clear goals. Each session had a focused scope: port, polish, expand. This meant each conversation started clean with a clear objective instead of one sprawling session that loses context. The MEMORY.md file preserved key decisions and quirks across sessions — Claude read it at the start of each conversation and knew immediately about the food counter bug, the coordinate naming convention, and the level data format.

Stating quality bars explicitly. Session 3’s plan specified “bordered segments with alternating colors” and “directional eyes” for the snake, not just “make it look better.” The more specific the request, the fewer iterations needed. When I said “pulsing diamond with a highlight for gem-like shine,” that’s exactly what was built in one pass.

The plan-then-implement pattern. Session 3 used Claude’s plan mode to design all four systems before writing any code. The plan identified the input queue approach, the exact level shapes and parameters, and the drawing functions needed. When implementation started, it was a single file write — all 1,155 lines at once — followed by one bug fix (draw_colored_polygon takes a Color, not a PackedColorArray in Godot 4). Two iterations total.

What I’d Do Differently

Combine sessions 1 and 2. The procedural music and food effects could have been part of the initial port. I split them because I wasn’t sure how ambitious to be, but Claude handles large feature sets in a single pass better than I expected. Fewer sessions means less overhead re-establishing context.

Specify the input system fix earlier. The polling-based input bug (self-death from rapid direction changes) existed from session 1 and wasn’t fixed until session 3. I should have described the desired input behavior in the first session. The event-queue approach is strictly better than polling — there’s no reason to start with the worse system and upgrade later.

Test procedural levels programmatically. The 7 generated levels were designed on paper (a markdown table in the plan) and implemented in code. I verified them by playing. A better approach: write a quick validation function that checks every generated grid for connectivity (flood-fill from start position, verify all floor tiles are reachable). This would catch islands or blocked doorways without manual playtesting.

Lessons for Future AI-Assisted Game Ports

  1. Front-load your source. Point the AI at the entire original codebase before it writes anything. Claude’s explore agent read every Haxe source file and built a mental model of the game before generating a single line of GDScript.

  2. Use persistent memory. The MEMORY.md file was essential across sessions. Recording “food count to advance: food_counter > num_food (needs numFood+2 food items total)” meant session 2 and 3 didn’t re-discover this quirk.

  3. Plan before you code for large changes. Session 3’s plan mode designed four systems, identified dependencies, and caught the draw_colored_polygon API difference before it became a runtime error (well, almost — it caught it on the first --check-only run).

  4. Be specific about feel. “Faster controls” is vague. “Event-based input with a 2-entry queue, reversal checks against last actual move direction” is implementable in one pass. The AI will build exactly what you describe — so describe what you actually want.

  5. Verify large data programmatically. Level 3’s 1,600-value array and the procedural level generation both benefited from programmatic verification instead of eyeballing. Use Python scripts or Godot’s --check-only flag to catch errors before you play.

The Result

From a 4-hour jam game with 3 levels and frame-counter movement to a polished 10-level snake game with procedural music, refined graphics, and responsive controls — all running in the browser.

The snake has personality now (directional eyes), the food sparkles (pulsing diamonds), the controls are tight (event queue), and there are enough levels to keep you busy. The whole thing is a single GDScript file — no external assets, no scene tree complexity, no dependencies.

Give it a try:

Open Fullscreen ↗

Source code is on GitHub — the repo includes both the original Haxe source and the Godot conversion.

This post is licensed under CC BY 4.0 by the author.