Eagle guide
The loop
Eagle.run(MyApp) opens the window, creates the GPU device, constructs your App, calls load, then every frame:
- polls events →
Inputstate,App#input,Node#input(children first) - runs zero or more fixed steps (
Config#fixed_fps, default 60): physics world step →Node#physics_process→App#fixed_update Node#process→ tweens →App#update(dt)- mixes audio
- clears, renders 3D (if a
Camera3Dis current), draws the 2D scene tree (with the currentCamera2D), thenApp#draw(g)
Clock.delta, Clock.elapsed, Clock.fps, Clock.scale (slow motion) are available anywhere.
Two styles, one engine
Immediate (LÖVE-style): draw everything yourself in draw:
class Game < App
@texture = Texture.new(Image.circle(32, Color::WHITE))
@score = 0
def draw(g : Graphics) : Nil
g.circle(400, 300, 40, color: Color::RED)
g.draw(@texture, 100, 100, rotation: Clock.elapsed, ox: 16, oy: 16)
g.print("score #{@score}", 10, 10)
end
end
Scene tree (Godot-style): build nodes and let them run:
class Player < Sprite2D
signal died
def ready : Nil
self.texture = Texture.load("res://player.png")
end
def process(dt : Float32) : Nil
self.position += Input.vector("left", "right", "up", "down") * 200 * dt
end
end
SceneTree.root.add(Player.new)
Mix freely: nodes draw first, then App#draw on top (HUD).
Assets
Texture.load("res://x.png"), Sound.load, Font.load(path, size), Mesh.load("x.obj"), Shader.load("fx.glsl"). res:// resolves to EAGLE_ASSETS, Config#assets_dir, assets/ next to the executable, or assets/ in the working directory. Loaded assets are cached.
Images: PNG (all bit depths, palettes, interlace), QOI, BMP. Decode and encode (Image#save). Audio: WAV (PCM 8/16/24/32, float). Models: OBJ.
Input
Input.map "jump", Key::Space, GamepadButton::A
Input.map "left", Key::A, Key::Left, Input.axis(GamepadAxis::LeftX, -1)
Input.pressed?("jump") # this frame
Input.down?(Key::LShift) # held
Input.axis("left", "right") # -1..1 (analog on sticks)
Input.vector("left", "right", "up", "down")
Input.mouse # also mouse_delta and wheel
Input.gamepad.try(&.rumble)
Raw events (KeyEvent, MouseButtonEvent, TextEvent, GamepadAxisEvent, …) arrive in App#input and Node#input; set event.handled = true to stop propagation.
Signals
class Enemy < Node2D
signal hit(damage : Int32)
signal died
end
enemy = Enemy.new
enemy.on_hit { |d| puts "took #{d}" } # or enemy.hit.connect { |d| ... }
enemy.emit_hit(3) # or enemy.hit.emit(3)
enemy.died.once { puts "gone" }
Physics 2D
ground = StaticBody2D.new(position: v2(400, 580)).box(800, 40)
ball = RigidBody2D.new(position: v2(400, 0)).circle(16)
ball.restitution = 0.6
ball.on_body_entered { |other| puts "bounce" }
class Player < KinematicBody2D
def physics_process(dt : Float32) : Nil
self.velocity += v2(0, 1400 * dt)
move_and_slide(dt)
puts "grounded" if on_floor?
end
end
player = Player.new(position: v2(100, 100)).box(24, 40)
SceneTree.root.add(ground, ball, player)
Physics2D.world.raycast(player.position, v2(1, 0), 100)
Units are pixels and seconds; default gravity 980 px/s². Layers/masks are bit sets on body.layer / body.mask. Area2D is a sensor. debug = true on any body draws its shapes.
UI
hud = CanvasLayer.new
panel = Panel.new(size: v2(300, 0)).tap(&.anchor = Anchor::Center).tap(&.fit_content = true)
box = VBox.new(size: v2(280, 0)).tap(&.position = v2(10, 10)).tap(&.fit_content = true)
box.add(Label.new("Settings"), Slider.new(0, 100, 50), CheckBox.new("Music", true), Button.new("OK") { panel.visible = false })
panel.add(box); hud.add(panel); SceneTree.root.add(hud)
Theme.default.font = Font.load("res://Inter.ttf", 18)
Controls receive mouse/keyboard events, manage focus (Control.focused), and inherit Theme from ancestors. Containers (VBox, HBox, GridContainer) size children by effective_min_size and size_flags.
3D
root = SceneTree.root
cam = Camera3D.new(position: v3(0, 3, 8)).tap(&.look_at(Vec3::ZERO))
cube = MeshInstance3D.new(Mesh.cube, Material.new(Color::RED), position: v3(0, 0.5, 0))
root.add(cam, DirectionalLight3D.new(v3(-0.5, -1, -0.3)), cube)
Scene3D.environment.fog(20, 80)
Scene3D.environment.shadows = true
if box = cube.global_bounds
cam.screen_to_ray(Input.mouse).intersect_aabb(box) # distance to the cube under the mouse, or nil
end
Shaders use the GLSL 330 / 300 es common subset; Material#shader accepts a custom Shader using the standard uniforms (u_model, u_view, u_projection, lights…).
Automated runs
EAGLE_FRAMES=60 EAGLE_SCREENSHOT=shot.png ./game runs 60 frames, saves the frame and exits. EAGLE_HEADLESS=1 hides the window. Eagle.init/Eagle.step/ Eagle.shutdown drive the loop manually in tests. EAGLE_GL_DEBUG=1 checks for GL errors after every 3D stage.
Backends
Platform::Base (window/input/audio/gamepads) and GPU::Device (rendering) are abstract. Platform::SDL + GPU::GL33 are the desktop implementations. Platform::Web targets wasm32-wasi and drives WebGL2, WebAudio and DOM input through web/eagle.js. Shaders and formats stay within the WebGL2 subset so the same code renders identically on both.
Exporting
eagle export exe examples/snake/main.cr # dist/snake/snake: release build; add Eagle.embed_assets("assets") for a true single file
eagle export web examples/snake/main.cr # dist/web/snake/: index.html + eagle.js + snake.wasm, serve over HTTP
eagle export app examples/snake/main.cr # dist/snake.app (macOS bundle)
Web builds need lld (brew install lld); the script downloads Crystal's wasm libraries on first use. Everything the desktop build does works in the browser except file-system access (embed assets), threads, and clipboard.
Site
script/build-site.sh compiles every example to WebAssembly, generates the API reference with crystal docs, and writes the marketing + docs site to site/. Screenshots shown on the site come from docs/screenshots/. script/publish-site.sh pushes the built site/ to the gh-pages branch, which GitHub Pages serves at https://joeyrobert.github.io/eagle.cr/.