Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

257 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

A performant, cross-platform game engine

What is Aether?

Aether is a game engine written in Zig. It is platform-agnostic -- there should be no difference running between Windows, Linux, macOS, and consoles like the PSP or Switch.

User code is structured as hooks into the engine via a State interface. You implement the game logic; the engine handles the platform details.

Features

  • Cross-platform: Windows, Linux, macOS (PSP/Switch planned)
  • Multiple graphics backends: OpenGL 4.5, Vulkan -- selected at compile time, overridable with -Dgfx=opengl
  • Fixed-step game loop: 144 Hz updates, 20 Hz ticks, uncapped rendering
  • Action-based input system: keyboard, mouse, and gamepad with callback bindings
  • Generic mesh & pipeline API: define vertex layouts from structs using comptime reflection
  • Budgeted memory pools: render, audio, game, user, and scratch -- no hidden heap allocations

Requirements

  • Zig 0.16.0-dev or later (see build.zig.zon for exact minimum)
  • Vulkan SDK (for Vulkan headers; on macOS, MoltenVK is used)

Desktop windowing, input, and audio use SDL3, which the build compiles from vendored source and statically links -- no system windowing library is needed.

Getting Started

Add Aether as a dependency in your build.zig.zon:

.dependencies = .{
    .engine = .{
        .url = "https://github.com/user/aether/archive/<commit>.tar.gz",
        .hash = "...",
    },
},

Then set up your build.zig:

const std = @import("std");
const Aether = @import("engine");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    // Optional: allow overriding the graphics backend with -Dgfx=opengl
    const overrides: Aether.config.Config.Overrides = .{
        .gfx = b.option(Aether.config.Gfx, "gfx", "Graphics backend override (default: auto-detect from target)"),
    };

    const config = Aether.config.Config.resolve(target, overrides);

    const ae_dep = b.dependency("engine", .{
        .target = target,
        .optimize = optimize,
    });

    // Create a game executable -- this wires up the engine module
    // and all platform-specific dependencies (SDL3/Vulkan/OpenGL/pspsdk)
    const exe = Aether.modules.addGame(ae_dep.builder, b, .{
        .name = "my_game",
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
        .overrides = overrides,
    });

    // Export the artifact (produces EBOOT.PBP for PSP, install artifact otherwise)
    Aether.packaging.exportArtifact(ae_dep.builder, b, exe, config, .{
        .title = "My Game",
    });

    const run_step = b.step("run", "Run the game");
    const run_cmd = b.addRunArtifact(exe);
    run_step.dependOn(&run_cmd.step);
}

The first argument to modules.addGame and packaging.exportArtifact is the dependency's builder (ae_dep.builder), and the second is your project's builder (b). This lets Aether resolve its own internal dependencies (SDL3, Vulkan, Slang, pspsdk) from its build.zig.zon while building artifacts that belong to your project.

The returned executable root is Aether's platform entry shim. Add imports for your game root through userRootModule:

Aether.modules.userRootModule(exe).addImport("my_module", my_module);

Then write your game code:

const std = @import("std");
const ae = @import("aether");

pub const aether_options: ae.Options = .{
    .title = "My Game",
    .app_name = "my_game",
    .psp = .{
        .module_name = "MyGame",
        .stack_size = 512 * 1024,
    },
};

const MyState = struct {
    fn init(ctx: *anyopaque, engine: *ae.Engine) anyerror!void { _ = ctx; _ = engine; }
    fn deinit(ctx: *anyopaque, engine: *ae.Engine) void { _ = ctx; _ = engine; }
    fn tick(ctx: *anyopaque, engine: *ae.Engine) anyerror!void { _ = ctx; _ = engine; }
    fn update(ctx: *anyopaque, engine: *ae.Engine, dt: f32, _: *const ae.Util.BudgetContext) anyerror!void { _ = ctx; _ = engine; _ = dt; }
    fn draw(ctx: *anyopaque, engine: *ae.Engine, dt: f32, _: *const ae.Util.BudgetContext) anyerror!void { _ = ctx; _ = engine; _ = dt; }

    pub fn state(self: *MyState) ae.Core.State {
        return .{ .ptr = self, .tab = &.{
            .init = init, .deinit = deinit,
            .tick = tick, .update = update, .draw = draw,
        }};
    }
};

pub fn main(init: std.process.Init) !void {
    const memory = try init.gpa.alignedAlloc(u8, .fromByteUnits(16), 32 * 1024 * 1024);
    defer init.gpa.free(memory);

    var my_state: MyState = undefined;
    var engine: ae.Engine = undefined;
    try engine.init(init.io, init.environ_map, memory, &.{
        .memory = .{
            .render = 8 * 1024 * 1024,
            .audio = 2 * 1024 * 1024,
            .game = 2 * 1024 * 1024,
            .frame = 4 * 1024 * 1024,
            .user = 16 * 1024 * 1024,
        },
        .title = "My Game",
        .app_name = ae.AppOptions.resolveAppName(aether_options),
    }, &my_state.state());
    defer engine.deinit();
    engine.run() catch |err| switch (err) {
        error.StateTransitionFailed => {
            if (engine.last_transition_failure()) |state_err| {
                ae.Util.game_logger.err("state transition failed: {s}", .{@errorName(state_err)});
            }
            return error.EngineStateTransitionFailed;
        },
        else => return err,
    };
}

Platform and graphics backend are available as comptime constants for per-platform configuration:

const memory_config: ae.Util.MemoryConfig = switch (ae.platform) {
    .psp => .{ .render = 512 * 1024, .audio = 256 * 1024, .frame = 128 * 1024, ... },
    else => .{ .render = 8 * 1024 * 1024, .audio = 2 * 1024 * 1024, .frame = 4 * 1024 * 1024, ... },
};

Set .frame = 0 if you want to disable the per-frame scratch allocator.

Building

# Build and run
zig build run

# Run tests
zig build test

# Override graphics backend
zig build run -Dgfx=opengl

# Build for PSP
zig build -Dtarget=mipsel-psp

# Build in release mode
zig build -Doptimize=ReleaseFast

Input System

Actions are registered by name and bound to one or more input sources:

const actions = try ae.Core.input.register_action_set("gameplay");
const jump = try ae.Core.input.add_action(actions, "jump", .button);
try ae.Core.input.bind_action(jump, &.{ .source = .{ .key = .Space } });
try ae.Core.input.install_action_set(actions);

if (ae.Core.input.button(jump).pressed()) {
    // jump this frame
}

Supported sources: keyboard keys, mouse buttons, mouse scroll, mouse relative movement, and gamepad buttons/axes.

On desktop, keyboard keys are physical-position based (SDL scancodes): Key.W is always the key above Key.A on a QWERTY layout, regardless of the OS keyboard layout.

Rendering

Build editable CPU geometry in MeshData, upload or bind it through Mesh, and draw with an explicit render state:

const Vertex = ae.Rendering.Vertex;
const MeshData = ae.Rendering.MeshData(Vertex);
const Mesh = ae.Rendering.Mesh(Vertex);

var data = try MeshData.init(render_alloc);
defer data.deinit(render_alloc);

var mesh = try Mesh.init(&.{});
defer mesh.deinit();

try data.add_tri(render_alloc, a, b, c);
mesh.update(&data);

ae.Rendering.set_state(&.{
    .texture = texture.handle,
    .proj = projection,
    .view = view,
    .depth_write = false,
});
mesh.draw(&model);

On PSP and 3DS, mesh data is borrowed directly by the backend, so keep the MeshData alive and call mesh.update(&data) after edits that may reallocate. Static textures can use the default .cpu_access = .none; request .read_write when you need set_pixel or direct CPU buffer access.

Build API Reference

Aether.modules.addGame(owner, b, opts) -> *Compile

Creates a game executable with the engine module and platform dependencies wired up.

Option Type Description
name []const u8 Executable name
root_source_file LazyPath Path to your main source file
target ResolvedTarget Build target
optimize OptimizeMode Optimization level (default: .Debug)
overrides config.Config.Overrides Graphics/display mode overrides (default: .{})

Aether.packaging.exportArtifact(owner, b, exe, config, opts)

Exports the build artifact. For PSP targets, produces an EBOOT.PBP. For desktop, installs the artifact normally.

Option Type Description
title []const u8 Application title (used in PSP EBOOT.PBP)
output_dir ?[]const u8 Output directory name (optional)
icon0 ?LazyPath PSP icon (optional)
pic0, pic1 ?LazyPath PSP background images (optional)
snd0 ?LazyPath PSP startup sound (optional)

Aether.config.Config.resolve(target, overrides) -> Config

Resolves the full engine configuration (platform, graphics backend, audio, input) from the build target and any user overrides. Pass the result to packaging.exportArtifact.

Aether.config.Config.Overrides

Field Type Description
gfx ?config.Gfx Graphics backend override (null = auto-detect)
psp_display_mode ?config.PspDisplayMode PSP display mode (null = rgba8888)

License

See LICENSE.

About

A cross-platform game engine written in Zig

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages