Eagle: A Crystal-Native 2D/3D Game Engine
September 29, 2026
Over the last five weeks I've been building Eagle, a 2D and 3D game engine for Crystal. Yesterday it hit v0.2.0, with installers for macOS, Linux and Windows, and every example game runs in the browser at joeyrobert.github.io/eagle.cr. This post covers why it exists, how it was built and how it got here so quickly.
Why Eagle Exists
I've had a soft spot for Crystal for years (I've written drawille.cr and TrailDB bindings in it). It reads like Ruby, compiles to fast native code and has a type system that mostly stays out of your way. What it doesn't have is a batteries-included way to make games. The options are mostly thin bindings to C libraries like SDL or raylib, which get a window on screen but leave you writing the engine yourself.
I wanted the thing I'd reach for in other languages: something with the immediacy of LÖVE (load, update, draw, and you're drawing shapes in a few lines) and the structure of Godot (a scene tree, nodes, signals and an input action map) once a game grows past a single file. A small game looks like this:
require "eagle"
include Eagle
class Game < App
@player = Sprite2D.new(Texture.new(Image.circle(32, Color::YELLOW)), Window.center)
def load
Input.map "left", Key::A, Key::Left, Input.axis(GamepadAxis::LeftX, -1)
Input.map "right", Key::D, Key::Right, Input.axis(GamepadAxis::LeftX, 1)
SceneTree.root.add(@player)
end
def update(dt : Float32)
@player.x += Input.axis("left", "right") * 250 * dt
end
def draw(g : Graphics)
g.print("Hello, Eagle!", 10, 10)
end
end
Eagle.run(Game, title: "My Game", width: 960, height: 540) Crystal All the Way Down
The rule I set at the start was that Eagle would have exactly two native dependencies: SDL2 for the window, input and audio device, and the platform's OpenGL 3.3. Everything else is Crystal: the PNG, QOI, BMP and WAV codecs, an Ogg Vorbis decoder, a TrueType parser and rasteriser, the audio mixer, 2D and 3D physics, particles, a UI toolkit and a 3D renderer with shadow maps. Games compile to a single executable with their assets embedded at compile time.
That sounds like a lot of reinventing, and it is, but it pays off. There's no dependency hell, it cross-compiles cleanly, and when something breaks the bug is in code I can read. It also made the web port possible, which I'll get to below.
Testing Without a Human
The decision that shaped the project the most was making every part of the engine verifiable without anyone looking at a screen. Any Eagle program can run for a fixed number of frames and save a screenshot (EAGLE_FRAMES=60 EAGLE_SCREENSHOT=out.png). The GPU specs render into offscreen canvases and assert on pixels: is the shadow actually darker than the lit floor, is every primitive wound the right way. Input can be injected and scripted, so integration specs can click buttons, tab through UI focus and play a few seconds of a game.
This matters because most of Eagle was written with coding agents, and an agent is only as good as its feedback loop. "The sphere looks right" isn't checkable. "The pixel under the light is brighter than the pixel in shadow" is. A lot of the bugs the specs caught were the subtle kind you'd never notice by eye until a game was built on top of them, like sphere and cylinder caps with inverted winding.
The Timeline
Late August: the foundations. I wrote the plan and shard skeleton on August 22nd. Within the first week Eagle had the SDL2 platform layer, a GL 3.3 device behind an abstraction, a batched 2D renderer, a built-in pixel font, the scene tree with a signal macro, and the GPU spec harness. By the end of August it had an audio mixer, 2D physics (SAT collisions, an impulse solver, a spatial hash, move_and_slide) and particles.
Early September: fonts, UI, 3D and games. TrueType fonts, a UI framework and the 3D renderer (meshes, Blinn-Phong materials, directional, point and spot lights, PCF shadows, a procedural sky, fog) all landed on September 2nd. Then came the first round of games: chess, checkers, breakout, asteroids, a platformer, snake and a small 3D coin collector. The chess game has a perft-verified move generator and an alpha-beta AI, which is apparently what happens when I write anything. Games are the real test of an engine API; each one shook out a handful of rough edges.
Mid September: 3D physics, Vorbis and the web. 3D physics came next, then a pure Crystal Ogg Vorbis decoder that matches ffmpeg's output on libvorbis streams. The biggest jump was WebAssembly. Crystal's wasm32-wasi target is experimental, so getting there meant stdlib shims for the event loop and threads, a pure Crystal zlib, and a JavaScript runtime (web/eagle.js) that provides WASI, WebGL2, WebAudio and DOM input. The architecture had been kept WebGL2-friendly from day one: shaders written in the subset GL 3.3 and GLSL ES 3.0 share, no geometry shaders, and the renderer never touches GL directly. Because of that, every example ran in the browser within a couple of days. A docs and marketing site with playable examples followed on September 17th.
September 18th: the big day. This was the most productive day of the project. I relicensed Eagle under the LGPL, then ran a docs pass that gave every public type an overview, usage notes and an example. All 125 doc examples are type-checked against the real API so they can't rot. After that I fanned the work out to agents running in parallel git worktrees, each on its own branch and PR: 3D spatial audio (distance models, interaural time difference, head shadow, doppler), a real checkers AI, an eagle init project wizard and installers, capsule and mesh colliders and joints for 3D physics, scroll containers and drop-downs, touch and gestures, web IME and clipboard support, and text batching. It also produced a pile of new examples: an infinite-world driving game, a first-person shooter, a voxel island maker and a library of common game algorithms (noise, A*, FOV, procedural dungeons, behavior trees).
That day also turned up the nastiest bug of the project: an intermittent segfault in the GL specs. Crystal's runtime monitor moves the main fiber to a new thread when it blocks on a syscall for more than 10ms, which happened during file reads in the audio specs. GL contexts and the macOS event loop belong to the original thread, so the next GL call crashed. The first fix overrode a private stdlib method. For the release it was replaced with building games with -Dwithout_mt, which avoids patching the stdlib at all.
Late September: release QA. Before tagging v0.2.0 I wrote a QA doc listing every doubt I had about the release (touch never tried on a real phone, Windows never run, the merged main never exercised as a whole) along with how to check each one. Then I worked through it. The result was a sweep of fixes across every subsystem and example, and CI that now smoke-runs every example headless on Linux. v0.2.0 went out on September 28th. Today I added key/value storage backed by localStorage on the web, and a first-person portal puzzle game to show it off.
Where It Stands
Eagle is about 25,000 lines of engine code, 7,000 lines of specs and 18,000 lines of example games across 25 examples. It's good enough to build real small games in, which was the goal. There are honest gaps: no skeletal animation or PBR, TrueType glyf fonts only, no scene editor or networking, and Windows builds are tested in CI but games haven't been run on a Windows machine yet.
If you write Crystal, or want an excuse to try it, give it a spin:
curl -fsSL https://raw.githubusercontent.com/joeyrobert/eagle.cr/main/install.sh | sh eagle init mygame cd mygame eagle run
The source is on GitHub and the examples are playable at joeyrobert.github.io/eagle.cr.