Skip to content

Build a Simple Game Engine

A game engine is a program structure for running interactive simulations.

A game engine is a program structure for running interactive simulations.

A game usually repeats the same cycle:

read input
update world
draw frame
repeat

That cycle is called the game loop.

In this project, we will build a very small terminal game engine. It will not use graphics, windows, audio, or GPU APIs. It will run in the terminal so the core ideas stay visible.

The Goal

We will build a small engine that moves a player on a 2D grid.

The world looks like this:

..........
..........
....@.....
..........
..........

The @ is the player.

The player can move:

w -> up
s -> down
a -> left
d -> right
q -> quit

This project teaches:

game loop
world state
input handling
frame rendering
bounds checking
separating engine code from game code

The World

Start with a simple position type:

const Vec2 = struct {
    x: i32,
    y: i32,
};

Now define the game state:

const Game = struct {
    width: i32,
    height: i32,
    player: Vec2,
    running: bool,
};

The game stores the world size, the player position, and whether the game should continue running.

Initialize the Game

Add:

fn initGame() Game {
    return .{
        .width = 10,
        .height = 5,
        .player = .{
            .x = 4,
            .y = 2,
        },
        .running = true,
    };
}

This creates a 10 by 5 grid with the player near the center.

Updating the Game

An update changes the game state.

For this first engine, input is one byte:

fn update(game: *Game, input: u8) void {
    var next = game.player;

    switch (input) {
        'w' => next.y -= 1,
        's' => next.y += 1,
        'a' => next.x -= 1,
        'd' => next.x += 1,
        'q' => game.running = false,
        else => {},
    }

    if (next.x >= 0 and
        next.x < game.width and
        next.y >= 0 and
        next.y < game.height)
    {
        game.player = next;
    }
}

The player moves only if the new position stays inside the world.

This is bounds checking.

Without it, the player could move outside the grid.

Rendering the Game

Rendering means drawing the current state.

In a terminal game, rendering means printing text.

fn render(game: Game) void {
    std.debug.print("\x1B[2J\x1B[H", .{});

    var y: i32 = 0;
    while (y < game.height) : (y += 1) {
        var x: i32 = 0;
        while (x < game.width) : (x += 1) {
            if (game.player.x == x and game.player.y == y) {
                std.debug.print("@", .{});
            } else {
                std.debug.print(".", .{});
            }
        }

        std.debug.print("\n", .{});
    }

    std.debug.print("\nuse w/a/s/d to move, q to quit\n", .{});
}

This line clears the terminal and moves the cursor to the top-left:

std.debug.print("\x1B[2J\x1B[H", .{});

It uses ANSI escape codes. Most modern terminals support them.

Reading Input

For a simple terminal program, we can read a line and use the first character.

fn readInput() !u8 {
    var stdin_buffer: [128]u8 = undefined;
    var stdin_reader = std.fs.File.stdin().reader(&stdin_buffer);
    const stdin = &stdin_reader.interface;

    var line_buffer: [128]u8 = undefined;

    const line = (try stdin.takeDelimiterExclusive(
        '\n',
        &line_buffer,
    )) orelse return 'q';

    if (line.len == 0) {
        return 0;
    }

    return line[0];
}

This means the user must press Enter after each command.

A real-time game would read keys immediately without waiting for Enter. That requires terminal raw mode, which is more platform-specific. For this beginner project, line input is enough.

The Game Loop

Now we can build the loop:

pub fn main() !void {
    var game = initGame();

    while (game.running) {
        render(game);

        const input = try readInput();

        update(&game, input);
    }

    std.debug.print("goodbye\n", .{});
}

The shape is the important part:

while running:
    render
    read input
    update

Many real engines use a slightly different order:

while running:
    read input
    update
    render

Both are fine for this simple terminal game.

Complete Program

Put this in src/main.zig:

const std = @import("std");

const Vec2 = struct {
    x: i32,
    y: i32,
};

const Game = struct {
    width: i32,
    height: i32,
    player: Vec2,
    running: bool,
};

fn initGame() Game {
    return .{
        .width = 10,
        .height = 5,
        .player = .{
            .x = 4,
            .y = 2,
        },
        .running = true,
    };
}

fn update(game: *Game, input: u8) void {
    var next = game.player;

    switch (input) {
        'w' => next.y -= 1,
        's' => next.y += 1,
        'a' => next.x -= 1,
        'd' => next.x += 1,
        'q' => game.running = false,
        else => {},
    }

    if (next.x >= 0 and
        next.x < game.width and
        next.y >= 0 and
        next.y < game.height)
    {
        game.player = next;
    }
}

fn render(game: Game) void {
    std.debug.print("\x1B[2J\x1B[H", .{});

    var y: i32 = 0;
    while (y < game.height) : (y += 1) {
        var x: i32 = 0;
        while (x < game.width) : (x += 1) {
            if (game.player.x == x and game.player.y == y) {
                std.debug.print("@", .{});
            } else {
                std.debug.print(".", .{});
            }
        }

        std.debug.print("\n", .{});
    }

    std.debug.print("\nuse w/a/s/d to move, q to quit\n", .{});
}

fn readInput() !u8 {
    var stdin_buffer: [128]u8 = undefined;
    var stdin_reader = std.fs.File.stdin().reader(&stdin_buffer);
    const stdin = &stdin_reader.interface;

    var line_buffer: [128]u8 = undefined;

    const line = (try stdin.takeDelimiterExclusive(
        '\n',
        &line_buffer,
    )) orelse return 'q';

    if (line.len == 0) {
        return 0;
    }

    return line[0];
}

pub fn main() !void {
    var game = initGame();

    while (game.running) {
        render(game);

        const input = try readInput();

        update(&game, input);
    }

    std.debug.print("goodbye\n", .{});
}

Run:

zig build run

You should see:

..........
..........
....@.....
..........
..........

use w/a/s/d to move, q to quit

Type:

d

Then press Enter.

The player moves right.

Separating Engine and Game Code

Right now, the code is small. But even here, there are two kinds of code.

Engine-like code:

main loop
input reading
rendering frame

Game-specific code:

player position
movement rules
world size

A larger engine tries to keep these separate.

For example:

fn engineLoop(game: *Game) !void {
    while (game.running) {
        render(game.*);
        const input = try readInput();
        update(game, input);
    }
}

Then main becomes:

pub fn main() !void {
    var game = initGame();
    try engineLoop(&game);
}

This seems like a small change, but it teaches an important design habit: isolate the loop from the rules of the game.

Adding Walls

A world becomes more interesting when some cells are blocked.

Add a function:

fn isWall(x: i32, y: i32) bool {
    return (x == 3 and y >= 1 and y <= 3) or
        (x == 7 and y == 2);
}

Update movement:

if (next.x >= 0 and
    next.x < game.width and
    next.y >= 0 and
    next.y < game.height and
    !isWall(next.x, next.y))
{
    game.player = next;
}

Update rendering:

if (game.player.x == x and game.player.y == y) {
    std.debug.print("@", .{});
} else if (isWall(x, y)) {
    std.debug.print("#", .{});
} else {
    std.debug.print(".", .{});
}

Now the map may look like this:

..........
...#......
...#@..#..
...#......
..........

The player cannot move through #.

Adding a Goal

Add a goal position:

goal: Vec2,
won: bool,

Update initGame:

.goal = .{
    .x = 9,
    .y = 4,
},
.won = false,

Then in update:

if (game.player.x == game.goal.x and game.player.y == game.goal.y) {
    game.won = true;
    game.running = false;
}

Render the goal:

if (game.player.x == x and game.player.y == y) {
    std.debug.print("@", .{});
} else if (game.goal.x == x and game.goal.y == y) {
    std.debug.print("G", .{});
} else if (isWall(x, y)) {
    std.debug.print("#", .{});
} else {
    std.debug.print(".", .{});
}

After the loop:

if (game.won) {
    std.debug.print("you win\n", .{});
} else {
    std.debug.print("goodbye\n", .{});
}

Now the project is a tiny game.

What a Real Game Engine Adds

A real engine usually adds:

window creation
graphics API
audio
asset loading
timing
input devices
physics
animation
entity systems
collision detection
scripting
scene management

But the center remains the game loop.

Even a large game engine still repeats:

collect input
advance simulation
draw result

The project here keeps everything small so you can see that shape clearly.

What You Learned

You built a small terminal game engine.

You represented world state with structs.

You wrote a game loop.

You handled input.

You updated player state.

You rendered a frame.

You checked world bounds.

You separated loop structure from game rules.

This is the base pattern behind interactive programs. A game engine is not magic. It is a disciplined loop around state, input, update, and output.