← Devblog

ECS, DynVMT, Logical Threads, and Pharaoh

English translation of the article originally published on Habr by Sergei Kushnirenko (dalerank). All in-game screenshots are from the open-source Akhenaten build.

In the late 1990s Sierra's historical city-builder series was at the height of its popularity — strong reviews, many imitators, and a long line of descendants from Children of the Nile through Banished (2014), Pharaoh: A New Era (2023), Nebuchadnezzar (2021), and the sadly cancelled Builders of Egypt. The genre's grandparent, in many ways, is still the 1999 original.

Pharaoh shipped two years after the beloved Caesar III. It was the first game in the line to move the setting from Rome to Egypt and offered — or rather, largely repeated — a complex loop that still did not depend on micromanaging every building and citizen individually. Many players remember these games through hundreds of failed missions: an angry emperor sending troops, or the kingdom revoking your title over unpaid debts. Simon Bradbury, the studio's technical lead and the soul of the project, left to found Firefly Studios and give us Stronghold a year later.

Digging through the binaries of both Caesar and Pharaoh turns up plenty of interesting legacy — technical solutions you still see in other projects, games included. It may look crude next to a modern engine, but there is real elegance here, and remember: these games ran at a respectable 15–30 FPS on single-core Pentiums, 586s, and early Athlons with as little as 32 MB of RAM total, not cache. Fast, pretty, and on one core.

Pharaoh city view in Akhenaten

Ancient Egyptian ECS

How does this relate to modern ECS frameworks? Entity–Component–System ideas existed in games long before ENTT went mainstream around 2017. In the Caesar/Pharaoh engine they were never branded as a separate framework, but the principles were already clear and copied across subsystems — one reason the sim could squeeze those 15–20 FPS out of the weakest machines. Today we can afford to rebuild a GPU frame from scratch every tick; on the APIs of the late 1990s that was expensive, and tricks like updating only the dirty screen region or splitting the map into staggered work zones mattered a lot. (It is hard not to grumble when UE 5.1 needs two minutes and six gigabytes of RAM to open an empty scene across twelve threads.)

In classic OOP you inherit data and behaviour together. A cartpusher inherits from a person, who inherits from a movable object, who inherits from something that plays animations. A trader inherits from a cartpusher because cartpushers already carry things, and so on.

The same pattern appears on the map: you need to know whether a tile is water, land, trees, dunes, and so on. Two problems follow. First, inheritance is rigid when types combine — rocks you can build on, or shoreline tiles that are water but sometimes allow construction — and the tree breaks. Second, cache efficiency: every frame the game walks all map tiles to update state, resources, or other fields. In the naive approach each tile is a fat struct:

struct Tile {
    int i, j;
    int resources;
    int state;
    bool isOccupied;
    ...
};

Updating one field pulls unrelated neighbours into the cache — wasteful on hardware with almost no margin. ECS separates the hot little fields into dense arrays processed by dedicated systems. Map trees go to one grid, rocks to another, buildings to a third, tile animations to a fourth. Architecture gets harder and cross-system dependencies appear, but that was a fair price for speed on a 32 MB machine.

Simon Bradbury and Mike Gingevich had already settled on the answer by 1998. Game entities sit in arrays indexed by tile offset. The map is fixed at 228×228; the playable area can be smaller, with unused slots marked so systems skip them. Desirability, water access, and per-tile animation all work this way.

City map with desirability overlay Desirability grid as raw data
Left: desirability on the live map. Right: the same data as a flat array — the layout the engine actually iterates.
Water access map Water access grid
Water availability uses the same pattern: one byte (or similar) per tile, updated by systems and read during house evolution.

Per-tile animation indices invert the relationship: at render time the engine reads this array to pick sprites; writing the array updates the map display without touching heavier tile objects.

Per-tile animation index grid

Desirability updates illustrate the payoff. In Akhenaten the grid and helpers look like this (structure preserved from the original; names modernised):

grid_xx g_desirability_grid = {0, {FS_INT8, FS_INT8}};

void map_grid_add(grid_xx* grid, uint32_t at, uint32_t v) {
    if (at >= GRID_SIZE_TOTAL)
        return;
    ((uint8_t*)grid->items_xx)[at] += v;
}

void desirability_t::update_buildings() {
    int max_id = building_get_highest_id();
    for (int i = 1; i <= max_id; i++) {
        building* b = building_get(i);
        if (b->state != BUILDING_STATE_VALID)
            continue;
        const model_building* model = model_get_building(b->type);
        map_grid_add(&g_desirability_grid, grid_offset, model->desirabilty);
        ...
    }
}

A desirability pass becomes indexed array writes instead of chasing fat tile objects. Save/load can memcpy whole grids without reconstructing graphs of pointers — another win on period hardware.

Ancient DynVMT

The engine is plain C; comments and constants recovered from the binary put Pharaoh at engine version 6.4 (Caesar III was 6.1). There were no C++ virtual tables, but the team built the same idea with callbacks — dynamic dispatch before it had a fashionable name.

Static dispatch binds one implementation at compile time. Virtual methods need runtime lookup when a pointer might point at a derived type:

struct B {
    virtual void bar() { printf("B::bar"); }
};
struct C : public B {
    virtual void bar() override { printf("C::bar"); }
};
B* b = new C();
b->bar();  // must call C::bar, not B::bar

The UI needed something VMT-like for pseudo windowing. Only one window was truly active — drawing every panel every frame would have dropped framerate to 2–3 FPS on target machines. The solution is a struct of function pointers set in each window's constructor:

struct window_type {
    e_window_id id;
    void (*draw_background)() = nullptr;
    void (*draw_foreground)() = nullptr;
    void (*handle_input)(const mouse* m, const hotkeys* h) = nullptr;
    void (*get_tooltip)(tooltip_context* c) = nullptr;
    void (*draw_refresh)() = nullptr;
};

That removed giant switch statements, split implementations across files, and gave a real window stack. The advisors screen reuses one shell and swaps the draw callbacks per advisor — in C++ you would need a class per advisor or a pile of overrides; here you just replace pointers.

void window_file_dialog_show(file_type type, file_dialog_type dialog_type) {
    static window_type window = {
        WINDOW_FILE_DIALOG,
        window_draw_underlying_window,
        draw_foreground,
        handle_input
    };
    init(type, dialog_type);
    window_show(&window);
}
Window stack and callback dispatch in the original UI model

Akhenaten is migrating these windows to MuJS autoconfig — same separation of data and behaviour, but hot-reloadable. The DynVMT pattern is what we are slowly replacing, not what we are copying verbatim.

Logical Threads from the Last Century

For the last twenty years games often use OS threads for parallel work. Before the mid-2000s most titles were single-threaded for simulation; threads handled audio decode, IO, and texture upload, not city ticks. An event loop still dispatches input — as today — but the sim also had to look like it did many jobs per frame without actually running them all at once.

Pharaoh's answer is a logical thread: split the city update into steps (fire risk, tree growth, immigrant generation, well coverage, …) and run one step per game tick in a round-robin. Over ten ticks you complete a full cycle; animations still advance every frame, so the player does not feel the stretch. Roughly 140 ms of work spread across ten ticks becomes ~14 ms per frame plus render — 7 FPS becomes 20–30 FPS.

Akhenaten still carries the skeleton of that design:

void game_t::update_city(int ticks) {
    g_city.buildings.update_tick(game.paused);

    switch (gametime().tick) {
    case 1:
        g_city.religion.update();
        g_city.coverage_update();
        break;
    case 2:
        g_sound.music_update(false);
        break;
    case 3:
        widget_minimap_invalidate();
        break;
    case 4:
        g_city.kingdome.update();
        break;
    case 5:
        formation_update_all(false);
        break;
    case 6:
        map_natives_check_land();
        break;
    ...
    }
Game tick phases spread across frames

God Object and the Memory Budget

A god object is the anti-pattern everyone warns about — one type that knows and does too much. I have yet to see a game engine completely avoid some version of it. Pharaoh's figure struct is the textbook case: one blob for every walker type, with type-specific state packed in a union to save RAM. On low-memory mode the game capped figures at 2 000 instead of 4 000 so more people could play at all.

The upside ends quickly; the downside is maintenance pain and the occasional midnight corruption. Archaeology has its rules — to stay compatible with original saves you tolerate awkward layouts for a while. Akhenaten is peeling types apart into real figure classes, but the legacy shape is still visible in src/figure/figure.h:

class figure {
public:
    e_resource resource_id;
    uint16_t resource_amount_full;
    uint16_t home_building_id;
    uint16_t immigrant_home_building_id;
    uint16_t destination_building_id;
    uint16_t id;
    uint16_t sprite_image_id;
    ...
    union {
        struct { ... } herbalist;
        struct { ... } taxman;
        struct { ... } flotsam;
        struct { ... } fishpoint;
        ...
    } local_data;
};
Figure data layout — shared fields plus per-type union

Eternal Pyramids

Pyramids are still hard — the ASM tangle for construction logic is a project of its own. Simpler monuments are further along: mastabas already build in-engine. When the original article was written that was the frontier; RA 0.27 has since pushed monument rating, temple complexes, and script-driven placement much further. The pyramid stack remains on the todo list.

Mastaba construction in Akhenaten Monument work in progress
Mastabas in the open-source build — the pyramid logic the article hoped for is still catching up.

Thanks for reading. If the project interests you, issues and PRs are welcome on GitHub — we are still building pyramids, one layer at a time.

See also: Reverse-engineering Caesar III: City Rendering (Part 2), Reverse-engineering Caesar III: Game Logic (Part 3), How to Build a Mastaba.

Akhenaten RA 0.27 — Release Notes →