Skip to main content

Your first code-only game

Create a cube and camera in Zig, move the cube with WASD or a controller, and draw an instruction label over the game. You will build and run it without authoring a scene file.

Before you begin​

You need Zig 0.17.0, a code editor, and an Eggy game package containing src/main.zig, build.zig, and build.zig.zon. You can use a project created by the project setup guide or an existing Zig package. This lesson assumes you can edit and build Zig source files.

1. Set up the package​

If you created the package with the editor, keep its build files. A plugin in src/root.zig is optional; remove its addPlugin call and its entry in addGame.plugins if you do not need it.

For an existing Zig package, fetch Eggy and use the following build.zig:

zig fetch --save=eggy git+https://github.com/eggyengine/eggy
const std = @import("std");
const eggy = @import("eggy");

pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
eggy.addGame(b, .{
.name = "little_game",
.game = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
}),
.target = target,
.optimize = optimize,
});
}

Keep src and your build files in build.zig.zon.paths. Include resources, eggy.zon, and controls.zon there too if the package uses them. addGame supplies the eggy import and the runtime entry point; do not write a separate main or poll SDL yourself.

2. Build the world in code​

Replace src/main.zig with this game. init creates a cube and camera, update moves the cube, and gameUi draws the instruction label:

const std = @import("std");
const eggy = @import("eggy");

const PlayerInput = eggy.input.Map(enum { move }, .{
.key = "player",
.controls = &.{
.{ .control = .move, .kind = .vector, .bindings = &.{
.{ .keys_2d = .{} },
.{ .controller_stick = .{} },
} },
},
});

pub const Game = struct {
player: eggy.EntityRef(eggy.Transform),
pub const input_maps = .{PlayerInput};

pub fn init(_: std.mem.Allocator, app: *eggy.App) !Game {
const player = eggy.findNamed(&app.world, "Player") orelse
try app.world.spawn(.{
eggy.Name.init("Player"),
eggy.Transform{},
eggy.Mesh{ .color = .{ 1, 0.55, 0.1 } },
});
if (eggy.findNamed(&app.world, "Camera") == null) {
_ = try app.world.spawn(.{
eggy.Name.init("Camera"),
eggy.Camera{},
eggy.Transform{ .position = .{ 0, 3, 7 }, .rotation_deg = .{ -20, 0, 0 } },
});
}
return .{ .player = .init(player) };
}

pub fn update(self: *Game, app: *eggy.App) !void {
const move = try app.input.map(PlayerInput).vector(.move);
const player = self.player.get(&app.world) orelse return;
const step = 3 * app.time.delta_s;
player.translate(.{ move.x * step, 0, move.y * step });
app.world.markChanged(self.player.entity.?, eggy.Transform);
}

pub fn gameUi(_: *Game, _: *eggy.App, ui: *eggy.ui.Context) !void {
ui.theme = .dark;
ui.label("Move with WASD or the controller's left stick.", .{});
}
};

The world owns spawned entities. EntityRef stores an ID; get checks it again each update before returning the component. The names let init reuse entities if you later open this game in the editor or load a scene, avoiding duplicates on restart. For a required entity, return an explicit game error when it is missing or incompatible.

Transforms use metres, degree rotations, Y up, and forward along -Z. Mapped movement has right/down-positive axes, so W's negative Y value moves this cube toward world -Z. The camera looks down -Z from its transform. Built-in shapes need no model or image files.

3. Keep startup free of scenes​

For this lesson, do not select a startup scene or leave scene files in resources/. A selected scene loads before Game.init, so it could add entities to the world before the code above runs. Normal runtime builds select a scene from -Dscene, then eggy.zon.default_scene, then the editor's last open scene, then the first resource scene. See startup configuration for lookup.

Other resources are still packaged normally.

4. Run without the editor​

From the project directory, build and launch the runtime. These commands use little_game, the name in the build file above. If you kept an existing build file, use its game's executable name:

zig build runtime
./zig-out/bin/little_game

Or use zig build run-runtime. zig build run opens the desktop editor instead. Use -Doptimize=fast for an optimized runtime.

You should see the orange cube and the instruction label. Press W and S to move it along world Z, and A and D to move it sideways. If a controller is connected, its left stick should move the same cube. The camera stays in place in this example, so the cube can move out of view.

5. Prepare the runtime for distribution​

Distribute the installed directory, not just the executable.

The current zig build export/File > Export workflow requires a startup scene even for a game that spawns everything itself. Use an empty startup scene for that workflow, or build and distribute the runtime installation directly. Code-only spawning does not require changing physics or adding a separate entity framework.

Next​

The input-map declaration supplies defaults without a settings file. Map player controls explains how to use a project-root controls.zon to change bindings without changing game logic; the build embeds that file.

Use ordinary Zig state and value timers for gameplay. For a UI-only game, you can omit the camera and mesh: a gameUi or draw hook still renders.