Porting TappyPlane: From Haxe/OpenFL to Godot 4 with Claude
Back in the day I built TappyPlane for a 3-hour game jam. It was a Flappy Bird clone starring a little green plane dodging rock pillars — built with Haxe, OpenFL, and the Nape 2D physics engine. It worked, it was fun, and then it sat in a repo collecting dust.
Fast forward to now: I wanted to get it running in a browser again. The old Flash/OpenFL pipeline is dead, but Godot 4’s HTML5 export is rock solid. So I ported the whole thing to Godot 4.4 with GDScript — with Claude Code (Opus 4.6) doing the heavy lifting. Here’s how it went.
The Original Stack
The original TappyPlane was built with:
- Haxe targeting Flash/HTML5 via OpenFL
- Nape 2D physics for rigid body simulation and polygon collision
- StableXUI for XML-driven UI layouts
- Flash SharedObject for saving high scores
- Sparrow-format sprite sheets for all the art
The game was about 2,000 lines across a handful of classes: Game.hx (main controller), Airplane.hx (player), Gates.hx / Gate.hx (obstacle spawning), Ground.hx (scrolling terrain), SaveAndLoad.hx (persistence), and PhysicsData.hx (Nape polygon collision shapes exported from PhysicsEditor).
Why Godot?
A few reasons:
- HTML5 export works out of the box. Export, drop on a web server, done.
- GL Compatibility renderer targets WebGL 1, so it runs everywhere — even older browsers and mobile.
- No external dependencies. No Nape, no StableXUI, no build toolchain to wrangle. Just Godot and GDScript.
- Single-file architecture works fine for a small game. The whole game lives in one ~900-line GDScript file plus a tiny save manager autoload.
The Conversion
Physics: Nape to Manual
The original used Nape rigid bodies with gravity, impulses, and polygon collision shapes. In Godot, I went with manual physics — just velocity and gravity applied in _process():
1
2
3
4
5
6
7
const GRAVITY := 900.0
const JUMP_VEL := -310.0
func _update_player(delta):
vel_y += GRAVITY * delta
vel_y = min(vel_y, 500.0)
plane_pos.y += vel_y * delta
The original Nape gravity was 1000 with a jump impulse of -14000 on a body with mass. After some tuning, 900 gravity and -310 jump velocity felt right in Godot.
Collision: The Hard Part
This is where things got interesting. My first pass used simple axis-aligned bounding boxes (AABBs) for collision detection. It technically worked, but the rock sprites are tapered — they’re narrow at the tip and wide at the base. The AABB covers the full 108x239 sprite rectangle, so the invisible corners near the gap would catch the plane. Players would crash into what looked like empty space. Ghost collisions.
I tried shrinking the boxes and adding insets, but it never felt right. The original game used precise polygon shapes from PhysicsEditor, exported as Nape polygon data in PhysicsData.hx. Each rock had 11-12 vertices tracing its actual silhouette.
So I did what any reasonable person would do: I extracted every vertex from the original Nape data and reimplemented polygon-vs-polygon collision using Godot’s Geometry2D.intersect_polygons().
1
2
3
4
5
6
7
8
9
# Rock (bottom gate) — 12 vertices from PhysicsData.hx
ROCK_POLY = PackedVector2Array([
Vector2(9, -120), Vector2(16, -119), Vector2(17.5, -112.5),
Vector2(26.5, -25.5), Vector2(33.5, -15.5), Vector2(40.5, 50.5),
Vector2(46.5, 58.5), Vector2(53.5, 115.5), Vector2(49, 119),
Vector2(-53, 119), Vector2(-28.5, 16.5), Vector2(-17.5, -0.5)])
func _polys_hit(a: PackedVector2Array, b: PackedVector2Array) -> bool:
return Geometry2D.intersect_polygons(a, b).size() > 0
The plane polygon rotates with the sprite, the ground polygon follows the terrain surface profile, and the ceiling is a flipped copy. Every collision check is pixel-honest to the original Nape shapes. No more ghost crashes.
Gate Recycling
The original spawned 18+ gate pairs and recycled them as they scrolled off-screen. The Godot version does the same with 20 pairs — when a gate scrolls past x = -150, it gets repositioned ahead of the rightmost gate with a fresh random gap height:
1
2
3
4
5
6
7
for gate in gates:
gate.x -= dx
if gate.x < -150:
gate.x = rightmost_x + randf_range(180.0, 320.0)
gate.gap_y = randf_range(155.0, 325.0)
gate.passed = false
rightmost_x = gate.x
UI: StableXUI to Godot Controls
The original used XML layout files parsed by StableXUI at runtime. In Godot, the entire UI is built in code — VBoxContainer, HBoxContainer, CenterContainer, PanelContainer with styled StyleBoxFlat backgrounds. Title screen, HUD, game over with medal display, pause menu, tutorial with animated tap icon, high score screen — all wired up with signal callbacks.
Persistence: SharedObject to ConfigFile
Flash’s SharedObject became Godot’s ConfigFile, saving to user://tappyplane_save.cfg. The save manager is an autoload singleton that tracks high score and attempt count.
Bug Fix: The Medal Cascade
The original had a subtle bug in the medal assignment. It used cascading if statements without else:
1
2
3
if (score > 200) medal = gold;
if (score > 100) medal = silver; // overwrites gold!
if (score > 50) medal = bronze; // overwrites silver!
A score of 250 would show bronze instead of gold. The Godot version uses proper elif chains. I also halved the thresholds (25/50/100) since the original scored +2 per gate pair (counting top and bottom separately) while the Godot version scores +1 per pair.
Build Gotchas
A couple things tripped me up during the port:
Asset imports: Godot needs to run its import pipeline before assets are usable. If you clone the repo and open the project, you may need to run:
1
godot --path . --headless --import
This generates .import metadata files next to each asset. Without this step, every load() call fails silently.
Audio format: Godot 4 doesn’t support MP3 natively. The background music (Hyperton) needed to be the .ogg version instead of .mp3.
Draw order: Godot’s move_child(node, -1) doesn’t work the same as putting a child last. You need move_child(node, get_child_count() - 1) to explicitly move it to the end of the draw order.
Working with Claude: The AI Behind the Port
This entire conversion was done in a single Claude Code session running Opus 4.6. I didn’t write the GDScript by hand — I described what I wanted, pointed Claude at the original source, and iterated through playtesting feedback. Here’s what that looked like by the numbers:
By the Numbers
| Metric | Value |
|---|---|
| Human messages | 19 prompts |
| AI responses | 245 |
| Tool calls | 140 (54 shell commands, 32 file reads, 12 edits, 8 file writes) |
| Output tokens generated | 124,570 |
| Cache-served input tokens | 19.3M |
| Total tokens processed | ~20.5M |
| Active processing time | ~51 minutes |
| Wall clock time | ~7 hours (most of that was me playtesting) |
| Context compactions | 1 (the conversation outgrew the context window once) |
What Worked Well
“Read all of this, then build.” The most effective pattern was front-loading context. I pointed Claude at the entire original codebase — all seven Haxe source files, the sprite sheet XMLs, the UI layouts — and said “convert this to Godot.” Having the full picture upfront meant the first version was remarkably close to done. The game ran on the first try (after fixing the asset import pipeline).
Iterating with gameplay feedback. Claude can’t play the game, so the feedback loop was: I play, I describe the problem, Claude fixes it. “Ghost collisions where the plane crashes but I don’t see anything touching” was enough for Claude to diagnose the AABB-vs-tapered-sprite issue and propose shrinking the hitboxes. When I pushed back and said I wanted pixel-perfect polygon collision like the original, it went straight to PhysicsData.hx, extracted every vertex, and implemented Geometry2D.intersect_polygons(). Three iterations total: AABB, tuned AABB, full polygon.
Knowing the toolchain. Telling Claude exactly where Godot was installed (C:\godot\Godot_v4.4.1-stable_win64.exe) let it run headless imports and exports directly. No guessing, no back-and-forth about environment setup.
What I’d Do Differently
Be specific about quality bars upfront. My first prompt didn’t mention collision precision. If I’d said “use polygon collision matching the original Nape shapes” from the start, we’d have skipped two iterations. The AI will default to the simplest working approach (AABBs) unless you tell it you want more. That’s actually correct behavior — but it means you save time by stating your quality expectations early.
Batch your context. The session hit one context compaction — Claude’s conversation grew past the context window and had to be summarized. This is fine (it picked up seamlessly), but I could have avoided it by combining some of my shorter testing messages. Each “run the game” / “here’s what happened” round trip eats context. Batching feedback into fewer, more detailed messages keeps the window open longer.
Let it explore first. Claude used an “Explore” subagent to read the entire original codebase before writing a single line of GDScript. This was the right call. The subagent pattern — a focused research agent that reports back to the main agent — meant the conversion understood the gate recycling system, the ground scrolling, the UI state machine, and the medal logic before it started writing code. I didn’t ask for this; it did it on its own. Next time I’d explicitly request exploration on unfamiliar codebases.
Tokens and Cost Perspective
The 19.3M cache-read tokens look dramatic, but that’s how Claude Code works — each turn re-reads the conversation history from cache, which is significantly cheaper than fresh input tokens. The actual “new thinking” was 26,616 input tokens and 124,570 output tokens. The cache is what lets a multi-hour session with 140 tool calls stay coherent without re-reading every file from scratch each turn.
For context: this single session produced a complete, working game port — ~930 lines of GDScript, project configuration, export presets, save system, full UI, polygon collision — plus git commits and this blog post. The entire thing cost less than a large coffee.
The Result
The game plays identically to the original. Same sprites, same scrolling speed (150 px/s), same gap sizes, same screen shake on death, same puff particle trails behind the plane. The polygon collision is faithful to the original Nape shapes — precise and unforgiving, just like a proper Flappy Bird clone should be.
The whole Godot project exports to a handful of files that you can drop on any web server. No plugins, no special server configuration. Just HTML, JS, WASM, and a .pck file.
Give it a try right here:
Credits
All game art, UI elements, and fonts are by Kenney — planes, rocks, ground tiles, medals, buttons, and the KenVector Future font. Kenney’s assets are CC0 (public domain), which means free to use with no strings attached. If you’re building a game and need solid art fast, Kenney’s library is the best place to start. Background music is “Hyperton” by DST.
You can find the source on GitHub — the repo includes the original Haxe source alongside the Godot conversion, so you can compare the two approaches side by side.