Eaglev0.1.0

Eagle guide

The loop

Eagle.run(MyApp) opens the window, creates the GPU device, constructs your App, calls load, then every frame:

  1. polls events → Input state, App#input, Node#input (children first)
  2. runs zero or more fixed steps (Config#fixed_fps, default 60): physics world step → Node#physics_processApp#fixed_update
  3. Node#process → tweens → App#update(dt)
  4. mixes audio
  5. clears, renders 3D (if a Camera3D is current), draws the 2D scene tree (with the current Camera2D), then App#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/.