Porting Super Hexagon to the Playdate

Super Hexagon (by Terry Cavanagh) is one of my all-time favorite games. If you haven’t played it, here’s the trailer (from 13 years ago):

It is a rhythm game with only two controls, and an official Linux port. The controls are circular (left and right rotate the player triangle around the screen) and ever since I got the Playdate, I’ve always thought that the crank on the playdate would make an amazing control for Super Hexagon.

Demo

Here’s me playing Super Hexagon on my playdate:


Super Hexagon playing in the simulator. 9MB Video, click to load. (Has Audio)

Me playing Super Hexagon on the Playdate. (Has Audio)

Notably: This is an actual port from a Linux x64 OpenGL build to the same code running on the Playdate at the maximum FPS (50) a much more contrained device: (16MB RAM, 400x240px black and white screen, ARM Cortex-M7F @ 168 MHz).

Here is the game ending playing across 3 modes (click on any to play/pause, no audio):


Original/Linux

Port/Linux

Playdate

The endings differ by a few frames, but the gameplay result is fairly accurate.

AI-usage

This was mostly driven over 2 weeks primarily using Claude Code (Opus 4.8/Fable 5). I also tested out GLM5.2 (using magnitude.dev) as well as GitHub Copilot. The primary learning here was around designing fast-feedback loops for reverse-engineering work.

Decompilation

This was a somewhat lucky pick: The Linux binaries for Super Hexagon were shipped unstripped, and several older binaries were published as well with a changelog. There’s some existing work on reverse engineering the game, which was helpful along with notes from Ethan Lee (who did the Linux port) on their approach to packaging the game.

The first approach I tried was the obvious one: generate C++ from the disassembly, recompile it, and diff the resulting disassembly byte-for-byte against the original, function by function using the same compiler toolchain as the original. That got 89 of 220 simple-functions matching exactly. These were the low-hanging fruits: - simple setters and getters and menu switches.

The remaining were broken mainly on register allocations, which are much harder to match on x64 (most decompilation matching ports are older gen consoles). The reason why we only had ~220 functions is because the game is built on open-frameworks, and I was hopeful of reusing upstream code for everything except the core game. However, at this point, I wasn’t trying a full-compilation.

I changed the approach from byte-matching to trace-matching with Frida: hook a running binary and snapshot labeled windows of game state — enemy positions, camera angle on every frame into a comparable trace.

Sample Frida trace
{"t":"call","f":79,"fn":"seekangle","args":[45]}
{"t":"call","f":79,"fn":"seekangle","args":[45]}
{
  "t": "frame",
  "f": 79,
  "rng": 0,
  "w": {
    "gA": {
      "state8": 0,
      "soundenabled": 1,
      "fullscreen": 0,
      "field_1c": 768,
      "field_20": 480,
      "assigned": [0, 0, 0, 0, 0, 0, 0, 0],
      "field_2d": 0,
      "field_30": 0,
      "field_34": 0,
      "field_38": 1,

    },
    "gB": {
      "field_e0": 1,
      "start_pulse": 0,
      "field_ec": 150,
      "start_camera": 40,
      "start_divisor": 6,
      "str_108": [-1347501544, 32669, 0, 0, 0, 0, 0, 0],
      "field_114": 60,
      "field_118": 0,
      "field_120": 180,
      "field_124": 300,
      "field_12c": 0,
      "field_130": 0,
      "field_134": -16,
      "field_138": 0,
      "field_140": 0,
      "field_14c": 0
    },
    "gW": {
      "enemy_count": 0,
      "next_level_time": 0,
      "wave_delay": 60,
      "waves_generated": 0,
      "wave_kind_flag": 0,
      "wall_speed": 25,
      "start_val_a": 30,
      "start_val_b": 30,
      "field_2890": 4,
      "field_2894": 0,
      "check_collisions_flag": -1,
      "besttimes": [0, 0, 0, 0, 0, 0],
      "current_time": 0,
      "camera_offset": 59,
      "field_28c0": 0,
      "damage_flash_timer": 0,
      "levelup_flag": 0,
      "pulse_decay_timer": 0,
      "cur_stage_num": 0,
      "field_28d8": -1,
      "field_28dc": 0,
      "wave_pattern_seed": 0
    },
    "gS": {
      "field_5380": 72.83199846221441,
      "seekangle_cur": 45,
      "visual_override_value": 3500,
      "visual_pulse_state": 0,
      "visual_pulse_value": 0,
      "visual_pulse_trigger": 0,
      "visual_sweep_state": 0,
      "visual_sweep_value": 0,
      "visual_sweep_timer": 0,
      "rotation_effect_state": 0,
      "rotation_effect_timer": 0,
      "stage_transition_state": 0,
      "pulse_cooldown": 0,
      "curstage": -2,
      "rotation": 0,
      "compl_a": [0, 0, 0],
      "compl_b": [0, 0, 0],
      "field_53f8": 0,
      "wheel_spin": 0,
      "win_state": 0,
      "first_wave_pending": 0,
      "hyperflag": 0,
      "side_change_pending": 0,
      "field_5418": 24,
      "field_541c": 6,
      "field_5420": 0,
      "field_5424": 30,
      "field_5428": 30,
      "field_542c": 24,
      "levelthresh": [600, 1200, 1800, 2700, 3600, 7200],
      "bar_start": 0,
      "bar_end": 0,
      "bar_cur": 0,
      "graphicsmode": 0,
      "camdepth": 850,
      "field_5508": 480,
      "field_550c": 0,
      "field_550d": 0,
      "field_5510": 0,
      "field_5514": 0,
      "tutorialflag": 1,
      "field_551c": 0,
      "field_5524": 0,
      "field_5530": 0.479425538604203,
      "field_5538": 384,
      "field_553c": 0,
      "field_5540": 0,
      "field_5544": 0,
      "credits": [0, 0, 0, 0, 0, 0],
      "field_5568": 22,
      "field_556c": 0,
      "field_5570": 0,
      "field_5574": -1,
      "field_5578": 0,
      "field_557c": -1,
      "field_5580": 0,
      "projsx": 665.1074861379998,
      "projsy": 707.0171784371619,
      "splash_fade_active": 0,
      "splash_fade_timer": 0
    },
    "xP": {
      "changepal_flag": 0,
      "changepal_val": 0,
      "field_29fc": 0
    },
    "xV": {
      "vcount": 30,
      "index_count": 0
    }
  },
  "en": {
    "n": 0,
    "hash": 1943471365,
    "slots": []
  }
}

However, the clean-code approach (rewriting all 220 functions from scratch) wasn’t matching closely enough so I needed something better. Moreover, I’d need to do all the cross-linking with existing libraries myself.

I found exactly what I wanted in Ghidra’s delinker extension: it lets you export parts of a program as object files. These object files have valid metadata (symbols, relocation tables…), and thus it let me replace parts of the game binary function-by-function. The first-run was 100% original-code and I slowly flipped each piece from our decompilation.

With the frida-oracle letting me trace each run, and the build focusing on small recompilations and linking it back, the feedback loops became quite fast, and I had a functional x64 port (from pure-source code) in some time. However, this approach leads to some weirdness, because in trying to rebuild the same binary, you get forced into hardcoded memory addresses, and thus a lot of code used hardcoded offsets (and goto on top of that).

But these are fixable issues once you have a functional binary that runs and plays.

Porting to the Playdate

The original game is built on openFrameworks, which supports major operating systems (Linux/MacOS/Windows/iOS/Android) only. I did not want to do a implementation rewrite, and wanted to re-use the game logic and the rendering pipeline. The first attempt involved using a WIP SDL2 driver for Playdate by Eric Lewis. With a few system calls shimmed, I got this working with openFrameworks. However, the performance wasn’t so great (15-20fps), and I decided to drop SDL2 entirely.

Since the game code draws everything via openFrameworks, and it is a simple enough game - it does this via a few openFrameworks methods. This let me switch to a tiny openFrameworks shim. This is roughly 1000 lines of code that takes a subset of the openFrameworks API and implements it using the Playdate C API. This covers ~60 functions from the 12000 functions that OpenFramework has. The majority of the work is done by just 6 functions:

  • ofxShapeBatchRenderer for rendering
  • ofTrueTypeFont which switches to Playdate text APIs.
  • ofSoundPlayer uses Playdate sound APIs.
  • ofxXmlSettings uses Playdate File APIs.
  • ofRandom uses Playdate Random APIs.
  • ofBackground/ofSetColor keeps track of colors so we can do some tone mapping.

With the above approach, the port reuses the original game logic: wave-spawning, collision math, same menu state machine, and the same ending. It even uses the same savefile format. This was able to run (with a few more optimizations) at 50fps.

There is some extra work involved in using the crank, as well as some game UI changes to account for the lack of color, the small screen, and differing inputs. But the core game loop still creates the same superhex object, and runs it every update loop.

Playghost

Playdate’s C API uses an interesting approach:

When your eventHandler function is called, the playdate argument will contain a pointer to the PlaydateAPI struct. The PlaydateAPI struct is your game’s interface back to the Playdate runtime, containing functions for accessing the sound system, display, filesystem, etc.

Testing the port is a hassle on the Simulator and the real device - the device has to reboot, and the Simulator isn’t really driveable for automated tests. As this was an educational project to help me learn fast-feedback loops, I came up with a faster approach: Shim the PlaydateAPI Struct and rebuild the game against it. I called it playghost.

It’s a small host-side C program that dlopen()s the compiled pdex.so and passes it a fake API struct: a fake clock the harness advances explicitly, scripted button presses etc. It also keeps track of a 1-bit framebuffer that it can dump into a ppm file.

It isn’t incredibly innovative, and I’m not sure if I’ll maintain it (especially given that the port used a very small part of the Playdate API and this might have been a lucky approach). But it was a simple way to let me test the game while making architectural changes.

Playghost is published at https://github.com/captn3m0/playghost.

Upgrades

With the game functional, upgrading it to a modern stack wasn’t difficult: it now runs with openFrameworks nightly (for some wayland fixes) along with whatever libraries ArchLinux is shipping today.

Code Quality and Bugs

The codebase suffers from 2 problems: it is a decompilation artifact, and fixes are mostly vibe-coded by matching against the original. Some scenarios are hard to test for (audio, ending, death, vsync) and thus there are bugs. The code quality is capped by our desire to behaviour-match. As such, it is roughly 10-15K lines of C++ code that is of dubious maintainability.

There are some small bugs as well that I haven’t gotten around to fix yet. It does build and play well on both Linux and Playdate though, and that was my main goal for this project. If you’d like to contribute, my priority would be to clean the codebase and fix bugs.

There’s some tracing and debug logic currently in the game, which helped match behaviour to the original. I’d like to get rid of it, but it is present in the current release since this isn’t at 100% yet.

Code

You can get the code at https://github.com/captn3m0/paperhexagon. You’ll need to provide your own assets for the build. I do not plan to do binary releases. There’s 2 patches made to upstream dependencies that I need to investigate and either contribute upstream or fix here as well.

Published on July 26, 2026
By