Files
aego/ARCHITECTURE.md
2026-09-26 16:32:40 +03:00

52 KiB
Raw Permalink Blame History

Aego — Architecture

Status: active architecture specification
Language: Go 1.27+
Repository model: monorepo, single root Go module aego
Scope: 2D-first engine with a planned 2.5D path, native desktop/mobile targets, built-in editor, custom scripting language, and compile-time native Go extensions.


1. Purpose

Aego is a 2D/2.5D game engine written in Go.

The engine is designed around five long-term goals:

  1. A real native engine, not a wrapper around another game engine.
  2. A built-in editor with a Unity/Godot-like workflow.
  3. A custom gameplay language with hot reload and editor integration.
  4. Native Go extensions for users who need to extend the engine itself.
  5. Portable runtime architecture that can target desktop and mobile without making the renderer, scene system, editor, or gameplay code depend on a specific OS or graphics API.

Aego deliberately uses a small number of low-level external pieces where reimplementing platform plumbing provides little engine value:

  • SDL3 for OS integration;
  • generated OpenGL bindings;
  • build-time tooling such as the font generator.

The engine itself owns scene management, rendering, batching, UI, assets, scripting, editor tooling, physics, audio mixing, networking, persistence, animation, materials, and export.


2. Architectural principles

2.1 Core rules

  • No global engine singletons.
  • Low-level packages never import high-level packages.
  • Runtime code must not depend on editor/dev-only packages.
  • Persistent identity is never represented by a runtime handle.
  • Runtime handles are generational and never serialized.
  • Source assets are not runtime assets.
  • Source file paths are not asset identity.
  • Scene data is text and Git-friendly.
  • Export/runtime data is binary and optimized.
  • Renderer does not know about scenes.
  • Scene does not know about the editor.
  • User Go code imports only aego/sdk.
  • Native extensions are compiled into the project executable; they are not dynamically loaded with plugin.Open.
  • Every stage ends in a runnable, testable result. Calendar deadlines do not define architectural completion.

2.2 Engine layers

This diagram shows architectural ownership and the main dependency direction, not every legal Go import. The exact package rules are defined in §4 Dependency rules and enforced by tools/depcheck.

flowchart BT
    core["core"]

    pinput["platform/input"]
    platform["platform"]
    gpu["gpu"]
    vfs["vfs"]
    schema["schema"]
    format["format"]

    engine["engine / services"]
    render["render"]
    ui["ui"]

    loader["asset/loader"]
    scene["scene"]
    sceneio["sceneio"]

    gameplay["script · physics · audio · anim · net · store"]

    sdk["aego/sdk<br/>public native-extension surface"]

    runtime["runtime"]
    editor["editor"]

    project["project"]
    pipeline["asset/pipeline"]
    apprun["app/run"]

    entry["cmd/* · demos · examples · tools · templates"]

    core --> pinput
    core --> platform
    core --> gpu
    core --> vfs
    core --> schema
    core --> format

    pinput --> platform
    platform --> engine
    gpu --> engine
    vfs --> engine

    gpu --> render
    pinput --> ui
    render --> ui
    format --> ui

    vfs --> loader
    schema --> scene
    loader --> scene

    format --> sceneio
    schema --> sceneio
    loader --> sceneio
    scene --> sceneio

    engine --> gameplay
    schema --> gameplay
    scene --> gameplay

    scene --> sdk
    schema --> sdk
    gameplay --> sdk

    sdk --> runtime
    sdk --> editor

    vfs -. dev-only .-> project
    format -. dev-only .-> project
    project -. dev-only .-> pipeline
    loader -. dev-only .-> pipeline

    project -. dev-only .-> apprun
    pipeline -. dev-only .-> apprun
    engine -. dev-only .-> apprun

    project -. editor-only .-> editor
    pipeline -. editor-only .-> editor

    runtime --> entry
    editor --> entry
    apprun --> entry

Layer meaning

Layer Responsibility
Foundation core: handles, math, IDs, clocks, logging, errors, statistics, deterministic utilities.
Platform contracts platform/input, platform, gpu, vfs: OS/GPU/filesystem boundaries and their backend implementations.
Metadata contracts schema, format: type metadata and serialization primitives. They remain independent from scene/editor code.
Orchestration engine: lifecycle, worlds, phases, modules, services, main-thread work and frame execution.
Presentation render, ui: scene-agnostic rendering and immediate-mode UI.
Runtime content asset/loader, scene, sceneio: runtime assets, Node+Components world model and scene serialization.
Gameplay systems script, physics, audio, anim, net, store: feature systems layered over the stable runtime model.
Extension surface aego/sdk: the only supported API imported by user native/ code. It is a facade, not an internal storage layer.
Applications runtime, editor: executable-facing composition of the lower layers.
Development-only project, asset/pipeline, app/run, CLI/tooling: source-project scanning, importing, rebuilding and supervision. These never enter exported runtime dependency graphs.

The important boundary is not that every box imports only the box directly below it; the important boundary is that the graph remains a DAG and lower/runtime layers never acquire reverse dependencies on editor/dev-only code.


3. Decision registry

Decision IDs are stable references for design discussions, code reviews, migrations, and ADR-style changes.

3.1 Foundation and runtime

ID Decision
D01 Go 1.27+; the exact toolchain is pinned in go.mod.
D02 SDL3 is pinned and used only for windowing, GL context creation, events, timing, text input, clipboard, drag/drop, lifecycle and audio device output. SDL_Renderer and SDL_GPU are not used.
D03 OpenGL 3.3 Core is the first GPU backend. Public gpu APIs remain backend-neutral.
D04 No global engine singletons. Construction is Builder → Engine.
D05 All runtime resources use generational handles. Public engine APIs do not expose long-lived pointers to internal storage.
D06 Main thread owns platform, GPU and authoritative game/editor state. Workers compute and return results through queues.
D07 Headless is a first-class engine configuration, not a special build hack.
D08 2D world: +X right, +Y down, radians internally, positive rotation clockwise, UV (0,0) top-left.
D09 One world unit is not defined as one pixel. Camera scale controls world-to-screen size.
D10 Shader math is linear; color textures and backbuffer use sRGB semantics.
D11 Fixed timestep uses an accumulator with interpolation and explicit MaxFrameDelta / MaxSteps.
D12 Logs are structured and channel-based. Errors are typed. panic is reserved for violated programming invariants.
D13 File-format versions are independent from engine versions.
D14 Dependency direction is strictly bottom-up. Low-level packages never import high-level packages.
D15 Stages close by acceptance criteria, not by a fixed calendar duration.

3.2 Coordinates, targets and GPU

ID Decision
D16 Projection matrices in mathx use GL clip-space convention z ∈ [-1,1]. Backend differences are neutralized through gpu.Capabilities.ClipZeroToOne and a fixup in render/view, never in mathx.
D17 Offscreen targets render in native backend orientation. For GL, window viewport/scissor is flipped at the backend boundary; offscreen targets stay GL-native. render/view is the only place allowed to compensate target Y orientation. Render-target textures are exposed top-down with UV (0,0) top-left.
D18 Texture color is premultiplied once during upload. Runtime shaders do not multiply alpha again. Standard blending is ONE / ONE_MINUS_SRC_ALPHA.
D19 Transparent visual order is painter order: layer → order → z → submission. Batching only merges adjacent compatible draws and never reorders by texture/sampler/blend/clip.
D20 Sprite backend uses an instanced unit quad generated from gl_VertexID. This is an implementation detail of render/sprite, not public API. Sprite z exists only in sorting metadata.
D21 GL 3.3 buffer streaming uses orphaning (BufferData(NULL) + update). Persistent mapping is not part of the baseline.
D22 SamplerDesc is independent from Texture. Renderer caches samplers by descriptor.
D23 TextureRegion is the drawable texture unit: full texture, spritesheet frame, atlas cell or glyph.
D24 Renderer is an ordered list of Pass objects with PassDesc{ID, Before, After}. Pass order is topologically resolved and frozen once rendering starts. Built-ins initially include sprites and debug.
D25 Camera2D.Zoom means pixels per world unit.
D26 Screen/UI coordinates are Y-down logical pixels. PushClip is expressed in logical target pixels and never rotates with the camera.
D27 Stage 2 text is a built-in bitmap font mounted from engine://. Full editor text support arrives later.
D28 Golden rendering tests use exact comparison for nearest/pixel-stable scenes and tolerance comparison otherwise. Linux reference rendering uses Mesa llvmpipe with SDL offscreen video.

3.3 Scene, schema and identity

ID Decision
D29 Scene model is Node + Components. Transform, hierarchy, name, enabled state and visibility layers are built into the node. Everything else is a component.
D30 Components use an independent sparse-set store per type. Aego does not use archetype ECS storage.
D31 A node can contain at most one component of a given type.
D32 A typed *T obtained from ComponentStore[T] is valid only for the current phase/query window. Adds enter a pending store and are visible through direct lookup immediately but not through active iteration until flush. Removes become logically invisible immediately and are physically compacted at flush.
D33 Structural mutations are deferred to phase flush. Destroy is logically immediate and idempotent; physical removal is deferred.
D34 NodeID is 128-bit persistent identity. NodeHandle is runtime-only and never serialized.
D35 Files store full type/field names. Runtime uses hashes. FormerName / FormerFieldName support schema renames. Hash collisions are build errors during Freeze().
D36 Source formats use canonical aetext and contain no comments. Runtime/export artifacts use binary formats.
D37 Unknown components are preserved as parsed semantic blocks in engine.Unknown and survive load/save even when the defining extension is missing.
D38 Unresolved NodeRef / AssetRef preserve their persistent IDs and produce diagnostics; they do not silently become null.
D39 Nodes store LocalEnabled; EffectiveEnabled is derived during the hierarchy/transform pass.
D40 Node names are unique among siblings, interned, and indexed by (parent, name).
D41 Visibility uses Node.VisibilityLayers and Camera.Mask.
D48 Built-in scene systems have stable IDs. engine.transform performs flush/hierarchy/transform/effective-enabled work. engine.sprite_extract extracts render commands. View construction belongs to Prepare rather than a separate camera phase.
D49 FieldDescriptor may use direct offset fast-path only for POD-like field kinds. Reference/GC-sensitive fields use typed accessors.
D50 Scenes are assets. project.aego references the main scene by AssetID.

3.4 Assets, VFS and development pipeline

ID Decision
D42 Assets are split into runtime asset/loader and dev-only asset/pipeline. Scene depends only on asset.Resolver.
D43 Sidecars are named <filename>.<ext>.meta; moving source + sidecar preserves identity.
D44 Import cache is content-addressed by source bytes, settings, importer version, pipeline version and dependency artifact hashes.
D45 Hot reload is transactional: import → validate → runtime/GPU preparation → atomic swap. Failure preserves the previous working resource.
D52 Initial watcher is stdlib polling over (path, size, mtime) behind a Watcher interface. Known limitation: a same-size rewrite inside filesystem mtime granularity may be missed.
D54 Raw os filesystem access is allowed only in vfs, platform, tools, cmd, project, asset/pipeline, engine/bootstrap and app/run. Runtime-path packages (engine, render, scene, sceneio, asset/loader, ui, sdk) use VFS so the same code can run over .aepak.

3.5 Native Go extensions and services

ID Decision
D46 aego/sdk is the only supported import surface for project-native Go code. tools/depcheck enforces this in examples.
D47 Native project builds use generated bootstrap code under .aego/generated. Build cache keys include engine/SDK/Go versions, target and native source state.
D51 Engine services are accessed through typed service registration keyed by stable string IDs. This allows upper layers to attach Scene, Renderer, Loader and editor services without reverse imports.
D55 aego run supervises the dev executable. Native source changes trigger a rebuild. Compile errors leave the current process alive. Successful rebuilds use a stdin restart protocol; the child serializes restart state and exits, then the new binary launches.
D57 engine.World is a service container. Systems declare scope in {Play, Editor, Frame}. Engine.RunPhase(world, phase, scope) executes a phase for a specific world.

3.6 Editor

ID Decision
D56 Play mode clones the edit scene into a separate play world through scene serialization. Edit scene is immutable during Play. Stop destroys the play world.
D58 Editor UI fonts are pre-baked by tools/genfont; runtime TTF rasterization is not required for the Stage 4 editor. render/text exposes the common Font API (UTF-8 + kerning), BitmapFont.Font() adapts the Stage 2 debug font to that API, and editor font selection uses text.FontSet.Closest(px, scale).
D59 Stage 4 uses one OS window. Docking exists entirely inside it. DockTree is serializable and does not know about native windows.
D60 Editor is a UI over scene/schema/assets/render. Nothing below editor/ imports editor/. Runtime and app/run do not link editor code.
D61 All editor mutations go through history commands: property/transform/create/delete/reparent/rename/enabled/layers/component changes. A drag gesture is one transaction. Delete stores a serialized fragment preserving NodeIDs.
D62 Document dirty state is derived from History state vs saved History state. Scene.Revision exists only for cache invalidation/change tracking.
D63 Selection stores persistent NodeID, never NodeHandle.
D64 Scene editor camera is editor state, not a scene node.
D65 Scene View and Game View render into pooled render targets in 64-pixel buckets. Runner target/view overrides redirect extraction without changing scene ownership.
D66 Reparent preserves world transform by default. Non-invertible parent transform yields SceneBadReparent. Shear created by non-uniform parent scale is not preserved.
D67 Editor-only project Go code is compiled with build tag aego_editor.
D68 Local editor state lives under .aego/user, .aego/session, .aego/recovery, plus .aego/editor.lock; none is committed.
D69 Input routing order: modal → focused widget → hovered-panel shortcuts → global shortcuts → Scene View → Game View. Active widgets/gizmos own pointer capture.
D70 Text entry uses SDL text input/editing events. Text input starts on text-field focus and stops on blur. Enter/blur commit; Escape cancels.
D71 Editor extension callbacks are protected by recover. A panicking extension is disabled and reported instead of taking down the editor.
D72 Editor UI is not required to be allocation-free. Stage 4 idle budget is ≤64 KiB/frame and the target is 60 FPS with a 10k-node Hierarchy. ui/draw.List has independent base/popup/modal/top streams.

4. Dependency rules

The import graph must remain acyclic. The layer diagram in §2.2 is intentionally conceptual; this section contains the rules that are actually enforced.

4.1 Global rules

  • core/... is the lowest engine layer and uses only the standard library.
  • platform/input is a leaf-style input model. platform/... may import it; platform/input never imports the platform backend.
  • render/... does not import platform, engine, ui, scene or editor.
  • render/module is the single integration package allowed to connect renderer registration with engine.
  • schema/... depends only on core/.... Asset and node references are recognized through schema-facing interfaces, not by importing high-level packages.
  • scene/... never imports editor/... or asset/pipeline/....
  • scene/... resolves assets only through asset.Resolver / runtime loader-facing contracts.
  • sceneio/... owns scene source serialization and may use scene, schema, format and runtime asset-reference contracts; scene itself does not import sceneio.
  • asset/loader/... is runtime-safe.
  • asset/pipeline/... and project/... are development-only and never enter exported runtime dependency graphs.
  • script/... may use schema, scene and stable runtime services, but never editor/....
  • User project native/ code imports only aego/sdk.
  • Nothing below editor/... imports editor/....
  • core/stats remains a leaf utility package; renderer-specific statistics remain in render.
  • cgo and direct OS access are restricted to explicitly approved packages.

4.2 Stage 1–2 clarifications

  • core → platform/input → platform; there is no reverse import from platform/input into platform.
  • Event-to-state conversion is performed by platform.ApplyInput.
  • render and its normal subpackages do not import platform, engine or ui.
  • render/module is the only exception for engine integration.
  • ui/... may import render/... and platform/input, but not the full platform backend and not engine.
  • engine/res is mounted as engine:// during Build().
  • core/stats does not absorb renderer counters; render.Stats remains owned by render.

4.3 Stage 3 clarifications

  • schema/... depends only on core/....
  • Asset and node references are recognized through schema.AssetRef and schema.NodeRef-style contracts implemented by asset and scene.
  • scene/... knows assets only through asset.Resolver; it never imports asset/pipeline.
  • asset/loader/... never imports asset/pipeline/....
  • project/... owns source-project metadata (project.aego), project roots and native-build inputs; it is dev-only.
  • examples/** represents user-facing SDK projects and may import only aego/sdk from the engine.
  • render remains scene-agnostic; scene-to-render extraction lives in scene/systems.

4.4 Stage 4 clarifications

  • ui/... may import format/... for serialization/deserialization of docking and UI layout state.
  • editor/... may import the runtime/dev package families it composes: core, platform/input, gpu, render, ui, schema, scene, asset/..., format, project and engine. No package imports editor/... from below.
  • app/run, runtime/..., and examples/** when built without the aego_editor tag are forbidden from importing editor/...; tools/depcheck treats this as an error.
  • render/text exposes Font as the common text-facing abstraction with UTF-8 and kerning support. BitmapFont.Font() converts/adapts the Stage 2 built-in bitmap debug font to the same Font API.
  • Editor-only project Go code is isolated by the aego_editor build tag.

4.5 Enforcement

tools/depcheck enforces at least:

  • forbidden package-family edges;
  • runtime → editor/dev-only imports;
  • app/run, runtime/..., and non-editor examples/** importing editor/...;
  • examples/** bypassing aego/sdk;
  • os.* outside vfs, platform, tools, cmd, project, asset/pipeline, engine/bootstrap, and app/run;
  • import "C" outside approved native bridges such as platform/sdl and gpu/gl33/gl;
  • obvious mutable global engine/service singletons.

5. Repository layout

The number in parentheses is the stage where the package first becomes real. Empty placeholder packages are not created.

aego/
├── go.mod
├── ARCHITECTURE.md
├── ROADMAP.md
├── Taskfile.yml
├── .github/workflows/ci.yml
│
├── core/                                  # stdlib-only
│   ├── handle/                            (1)
│   ├── mathx/                             (1)
│   ├── clock/                             (1)
│   ├── log/                               (1)
│   ├── errs/                              (1)
│   ├── stats/                             (1)
│   ├── bits/                              (2)
│   ├── ids/                               (3)
│   └── rng/                               (3)
│
├── engine/                                (1)
│   ├── res/                               (2) embedded engine:// resources
│   ├── bootstrap/                         (3) dev/native bootstrap integration
│   └── ...
│
├── platform/                              (1)
│   ├── input/
│   ├── sdl/
│   ├── headless/
│   └── android/                           (14)
│
├── gpu/                                   (1)
│   ├── gl33/
│   │   ├── gl/                            generated, DO NOT EDIT
│   │   └── internal/glstate/
│   ├── null/
│   ├── gles30/                            (14)
│   └── shaderc/                           (12)
│
├── vfs/                                   (1)
│   ├── osfs/
│   ├── pakfs/                             (13)
│   └── assetfs/                           (14)
│
├── render/                                (2)
│   ├── view/
│   ├── texture/
│   ├── sprite/
│   ├── culling/
│   ├── debug/
│   ├── text/                              (2 BitmapFont, 4 Font UTF-8+kerning / FontSet)
│   ├── gpuprof/
│   ├── module/
│   ├── material/                          (12)
│   ├── postfx/                            (12)
│   └── light/                             (12)
│
├── ui/                                    (2 core, 4 editor-complete)
│   ├── draw/
│   ├── core/
│   ├── widgets/
│   ├── overlay/
│   ├── layout/                            (3)
│   ├── textedit/                          (4)
│   ├── dock/                              (4)
│   └── theme/                             (4)
│
├── schema/                                (3)
│   └── reflectgo/
│
├── format/                                (3)
│   ├── aetext/                            source parser/writer + canonicalizer
│   ├── binary/                            import/export artifact primitives
│   └── pak/                               (13) .aepak index/container format
│
├── project/                               (3) project.aego, source project model, native build inputs
│
├── asset/                                 (3)
│   ├── loader/                            runtime AssetID → artifact/resource
│   ├── pipeline/                          dev-only scan/meta/import/watch/cache
│   ├── importer/
│   │   ├── png/
│   │   ├── atlas/
│   │   ├── font/                          (4)
│   │   ├── audio/                         (9)
│   │   └── shader/                        (12)
│   └── pak/                               (13)
│
├── scene/                                 (3)
│   ├── components/
│   ├── systems/
│   └── prefab/
│
├── sceneio/                               (3)
│
├── script/                                (5+)
│   ├── token/
│   ├── ast/
│   ├── parser/
│   ├── types/
│   ├── checker/
│   ├── ir/
│   ├── compiler/
│   ├── bytecode/
│   ├── vm/
│   ├── runtime/
│   ├── bind/
│   ├── stdlib/
│   ├── reload/
│   ├── lang/
│   └── docgen/                            (7)
│
├── physics/                               (8)
├── audio/                                 (9)
├── anim/                                  (11)
├── net/                                   (13)
├── store/                                 (13)
│
├── sdk/                                   (1 boundary, populated from 3)
│
├── editor/                                (4)
│   ├── app/
│   ├── project/
│   ├── document/
│   ├── command/
│   ├── history/
│   ├── selection/
│   ├── playmode/
│   ├── gizmo/
│   ├── ext/
│   ├── build/
│   └── panels/
│       ├── hierarchy/
│       ├── inspector/
│       ├── sceneview/
│       ├── gameview/
│       ├── assets/
│       ├── console/
│       ├── profiler/
│       ├── scripteditor/                  (6)
│       ├── physicsdebug/                  (8)
│       ├── audiomixer/                    (9)
│       ├── pixel/                         (10)
│       ├── spritesheet/                   (10)
│       ├── animation/                     (11)
│       ├── shader/                        (12)
│       ├── settings/
│       ├── export/                        (13)
│       └── help/                          (7)
│
├── runtime/                               (13)
│   └── app/
│
├── app/
│   └── run/                               (3) dev runner/supervisor; never imports editor
│
├── cmd/
│   ├── sandbox/                           (1)
│   ├── aego/                              (3)
│   ├── aego-editor/                       (4)
│   └── aego-runtime/                      (13)
│
├── tools/
│   ├── bootstrap-native.sh                (1)
│   ├── gen-gl.sh                          (1)
│   ├── depcheck/                          (1)
│   ├── genfont/                           (2, nested module)
│   ├── golden/                            (2)
│   ├── bench/                             (2)
│   └── docsite/                           (7)
│
├── templates/                             (13)
│   ├── project/
│   ├── desktop/
│   └── android/                           (14)
│
├── examples/                              (3) user-facing projects; engine imports only via aego/sdk
│
├── demos/
│   ├── basic input resize headless        (1)
│   ├── sprites camera layers text
│   │   offscreen stress                   (2)
│   ├── scene hotreload native             (3)
│   ├── editor                             (4)
│   ├── platformer                         (5+)
│   └── physics audio anim shaders net     later stages
│
├── tests/
│   ├── integration/
│   ├── gl/
│   ├── golden/
│   └── bench/
│
├── third_party/
│   └── sdl/version.txt
│
├── docs/                                  (7)
└── .cache/                                ignored

5.1 Important correction: source vs binary format

Source scene/material/animation/prefab/project files are not chunked binary containers.

project.aego
*.aescene
*.aeprefab
*.aemat
*.aeanim

use canonical aetext.

Chunk tables such as string/reference/node/property tables belong only to runtime/export/import artifacts, primarily .aepak and binary cache formats.


6. User project layout

Created by:

aego new MyGame
MyGame/
├── project.aego
├── res/                         mounted as res://
│   ├── scenes/
│   ├── scripts/
│   ├── sprites/
│   ├── audio/
│   └── shaders/
│
├── native/                      optional Go extensions
│   ├── module.go                imports only aego/sdk
│   └── editor_*.go              optional; `//go:build aego_editor`
│
├── .aego/                       ignored
│   ├── cache/
│   ├── generated/
│   ├── build/
│   ├── user/
│   ├── session/
│   └── recovery/
│
└── exports/

The engine treats res:// as project content, not as a direct OS path.


7. File formats

Extension Purpose Representation
project.aego Project metadata and main scene reference canonical aetext
.aescene Scene source canonical aetext
.aeprefab Prefab source canonical aetext
.aemat Material source canonical aetext
.aeanim Animation source canonical aetext
.aes Aego gameplay script text
.aeshader User shader source text
.meta Source asset sidecar canonical aetext
.aepak Export package binary

Every format owns its own version.

Example:

engine_version   = 0.8.0
native_sdk       = 3
scene_format     = 4
material_format  = 2
dsl_version      = 5
pak_format       = 2

These values are intentionally independent.


8. Handles and lifetime

8.1 Handle rules

type Handle[T any] struct {
    Index uint32
    Gen   uint32
}
  • Generation starts at 1.
  • Zero handle is always invalid.
  • Reusing an index increments generation.
  • Persistent data never stores a runtime handle.
  • A pointer returned by an arena/store is a temporary fast-path view, not ownership.

8.2 Arena pointer lifetime

A pointer returned by Arena.Get is valid only until an operation that may grow/move the arena.

Callers that need stable identity keep the handle and re-resolve it.

8.3 GPU lifetime

  • GPU destruction during a frame is deferred until a safe point at EndFrame.
  • Backend state caches key resources by full {Index, Gen}, never raw index.
  • Recreated resources with reused indices must invalidate cached backend bindings.

9. Scene model

9.1 Node contents

Every node owns:

NodeID                 persistent 128-bit identity
Name                   interned StringID
LocalEnabled
EffectiveEnabled       derived
VisibilityLayers
Transform2D            local
WorldTransform         derived
Parent
FirstChild
NextSibling
PrevSibling

Transform2D is built into the node and cannot be removed.

9.2 Node templates are editor conveniences

Names such as:

Sprite2D
Camera2D
AudioSource2D

are editor creation templates, not serialized node types.

For example:

Sprite2D template
  → create Node
  → attach SpriteRenderer

The scene file stores only the node and its components.

9.3 Components

A component type has an independent sparse-set:

denseNodes[]
denseData[]
sparse[]

The model intentionally avoids archetype/chunk ECS.

Rules:

  • one component of each type per node;
  • direct typed iteration for systems;
  • schema metadata exists independently from hot-path storage;
  • structural changes are deferred.

9.4 Structural mutation semantics

Destroy(h):

  1. marks the node logically dead immediately;
  2. IsAlive(h) becomes false immediately;
  3. queries skip it immediately;
  4. physical component/tree removal happens during the structural flush;
  5. repeated Destroy(h) is harmless.

Adds/removes follow D32 semantics so a system cannot accidentally iterate newly-added instances and loop over its own structural writes.

9.5 Hierarchy traversal

The scene stores a derived pre-order index list.

Structural flush rebuilds it only when hierarchy structure changes.

Transform/effective-enabled evaluation is a single linear pass where a parent always appears before its children.

No recursive transform traversal is required per frame.


10. Schema and reflection

schema is the single metadata source for:

  • serialization;
  • editor Inspector;
  • undo/history;
  • DSL bindings;
  • native extension discovery;
  • property validation;
  • references;
  • future replication metadata.

Conceptually:

type TypeDescriptor struct {
    ID         TypeID
    FullName   string
    Version    uint32
    Fields     []FieldDescriptor
    FormerNames []string
}
type FieldDescriptor struct {
    ID       FieldID
    Name     string
    Kind     FieldKind
    Flags    FieldFlags
    Offset   uintptr
    Size     uintptr
    FastPath bool
    FormerNames []string
}

Reflection may be used while registering Go types.

After Registry.Freeze():

  • full-name uniqueness is validated;
  • type/field hash collisions are rejected;
  • lookup tables are fixed;
  • hot paths use IDs and precomputed descriptors rather than repeated reflect work.

POD-like fields may use offset-based fast access.

GC-sensitive/reference fields use typed accessors so the runtime does not bypass Go write barriers.


11. References and identity

11.1 Node reference

A serialized NodeRef stores NodeID.

Runtime may cache a NodeHandle, but that cache is derived.

Missing target:

ID remains present
runtime handle is invalid
diagnostic is emitted

Saving the scene does not erase the unresolved reference.

11.2 Asset reference

AssetRef[T] follows the same model:

persistent AssetID
+
derived runtime resource handle

Source file paths never appear in scene identity.


12. Asset architecture

12.1 Runtime loader

asset/loader exists in editor, dev run and exported games.

Responsibilities:

AssetID
  ↓
artifact index
  ↓
runtime resource
  ↓
residency / hot swap

It knows nothing about:

  • source .png;
  • .meta;
  • filesystem watcher;
  • import settings editor;
  • source folder scans.

12.2 Development pipeline

asset/pipeline exists only in editor/dev tools.

Responsibilities:

  • source scan;
  • sidecar management;
  • source → AssetID mapping;
  • importers;
  • dependency graph;
  • content-addressed cache;
  • watcher;
  • batched reimport;
  • generation of runtime artifacts.

12.3 Sidecars

player.png
player.png.meta

Moving both files preserves AssetID.

12.4 Import cache key

Conceptually:

hash(
    source bytes,
    normalized import settings,
    importer version,
    pipeline version,
    dependency artifact hashes,
)

12.5 Transactional reload

source changed
    ↓
debounced dirty set
    ↓
dependency closure
    ↓
import temporary artifact
    ↓
validate
    ↓
prepare runtime/GPU resource
    ↓
success?
    ├─ yes → atomic swap
    └─ no  → old resource stays active

Watcher events are coalesced before import, so editor saves and large operations such as git checkout do not trigger pathological reimport storms.


13. VFS

Engine-facing code uses URI-like schemes:

res://
engine://
user://
cache://

Rules:

  • res:// and engine:// support overlay mounts; latest mount wins reads.
  • user:// and cache:// have exactly one writable mount.
  • runtime code never assumes res:// is an OS directory.
  • .aepak later mounts into the same VFS surface.

Allowed raw OS filesystem users are limited to vfs, platform, tools, cmd, project, asset/pipeline, engine/bootstrap, and app/run.


14. Renderer architecture

14.1 Separation

scene/systems
    ↓ extracts
ViewFrame / SpriteDraw
    ↓
render
    ↓
gpu
    ↓
backend

Renderer never receives Scene or Node types.

14.2 Sprite order

Visual order:

layer
  ↓
order
  ↓
z
  ↓
submission

Batching is a post-order optimization only.

A texture/material/sampler change may split a batch but must never change visual order.

14.3 Texture semantics

CPU image decoding may begin as straight alpha.

Uploader converts to premultiplied representation.

Runtime blending expects premultiplied color.

14.4 Render targets

Window and offscreen targets share a common render target abstraction.

Backend orientation differences are contained by render/view and backend conversion rules.

ReadPixels always returns:

RGBA8
top row first
no row padding

regardless of backend.

14.5 UI/editor rendering

Scene View and Game View render into pooled render targets.

Targets are bucketed to 64-pixel boundaries to avoid constant reallocation while splitters are dragged.


15. GPU contracts

15.1 Uniforms and samplers

Engine convention:

UBO blocks: ub0 .. ub15
samplers:   tex0 .. tex14
texture unit 15 reserved for engine upload/internal work

Stage 12 shader tooling emits these conventions automatically.

15.2 GPU input memory

Data passed through cgo-backed GPU functions must live in pointer-free byte allocations when needed.

Do not pass a subfield whose containing Go object includes pointers and assume cgo checks only the field.

15.3 Queries

Only one active GPU query is allowed per device in the baseline implementation.

Nested query scopes are forbidden.

EndQuery must occur in the same render pass in which the query began.


16. Engine construction and modules

Construction:

Builder
  ↓ register modules / systems / services
Build()
  ↓ initialize platform, VFS, workers, GPU
  ↓ freeze registries
ModuleIniter.Init(*Engine)
  ↓
Engine

Modules register systems during Builder.Use.

Runtime resource creation happens during module initialization after low-level backends are available.

16.1 Services

Services are stable-ID entries inside a world/service registry.

They allow high-level packages to attach:

  • renderer;
  • asset resolver/loader;
  • scene;
  • editor context;
  • other world-local services;

without creating reverse package imports.


17. Worlds and execution scopes

A World is a set of world-local services and state.

System scopes:

Play
Editor
Frame

Examples:

  • transform system: Editor + Play;
  • sprite extraction: Editor + Play;
  • user gameplay AI: Play;
  • editor maintenance system: Editor;
  • platform/frame statistics: Frame.

The engine can run the same phase graph for different worlds without conflating edit state and play state.


18. Frame lifecycle

High-level order:

FrameBegin
    drain OnMain
    stats.begin

PollEvents
    platform.PollEvents
    input.Apply
    resize / DPI metrics
    app event dispatch

Input

FixedBegin
    × zero or more fixed steps
    PrePhysics
    Physics
    app.fixed
    PostPhysics
FixedEnd

PreUpdate
app.update
PostUpdate

Animation

RenderPrepare
    structural flush as required
    transforms
    effective enabled
    views
    sprite extraction

Render
    gpu.BeginFrame
    renderer begin
    app.render
    render systems/passes

RenderUI
    UI / editor overlays
    renderer end

gpu.EndFrame
window swap

FrameEnd
    stats.end

Reserved system IDs include:

app.fixed
app.update
app.render

engine.transform
engine.sprite_extract

Extension systems order themselves with stable Before / After IDs.


19. Input model

Input has separate concepts for:

  • physical scan code;
  • layout-dependent key code;
  • Unicode text input;
  • pointer state;
  • recorded InputFrame.

Focus loss clears held button/key state.

Gameplay/editor code never synthesizes text by translating KeyDown.

Text fields use platform text/IME events.

Recorded InputFrame exists to support replay, tests and future networking/debugging.


20. Editor architecture

20.1 Editor is a client of the engine

Editor does not own a parallel scene/render/asset model.

It uses:

scene
schema
asset/pipeline
asset/loader
render
ui
history
sdk extension registry

20.2 Documents

Open scene files are documents.

A scene document owns:

  • loaded edit scene;
  • history state;
  • persistent selection;
  • Scene View state;
  • file path/AssetID;
  • dirty state.

20.3 Play mode

Edit Scene
    ↓ serialize/clone through sceneio
Play Scene / Play World
    ↓ mutable gameplay
Stop
    ↓ destroy Play World

The edit scene is not rolled back because it is never mutated by gameplay.

20.4 Undo/redo

Editor changes use commands rather than direct mutation.

Core command families:

SetProperty
SetTransform
CreateNode
DeleteNode
ReparentNode
RenameNode
SetEnabled
SetLayers
AddComponent
RemoveComponent
AssignAsset

Continuous gestures begin one history transaction and commit once.

Delete stores a semantic serialized fragment with original NodeIDs so undo restores references.

20.5 Selection

Selection stores persistent IDs.

It survives scene reload/native rebuild whenever the corresponding object still exists.

20.6 Docking

Stage 4 provides docking inside one OS window.

Dock tree is editor state and can later be extended toward multi-window support without changing panel identity.

20.7 Editor extensions

Project-native Go may add:

  • panels;
  • commands;
  • inspectors;
  • gizmos;
  • creation templates;
  • editor tools.

Editor-only code is compiled under aego_editor.

Extension callbacks are panic-isolated.


21. Native Go project extensions

A project may contain:

native/
└── module.go

and import:

import "aego/sdk"

only.

Native extensions may provide:

  • components;
  • systems;
  • asset importers;
  • native DSL bindings;
  • renderer features through stable extension points;
  • editor tools under aego_editor.

They are statically compiled into the project build.

Aego does not rely on Go dynamic plugins.

21.1 Dev rebuild

aego run acts as supervisor:

watch native/*.go
    ↓
go build
    ↓
failure?
    ├─ yes → keep old process alive
    └─ no  → ask child to serialize restart state
              ↓
             child exits
              ↓
             start new binary

Stage 4 editor reuses the same idea but persists richer editor/session state.


22. UI architecture

UI is immediate-mode with retained internal state keyed by stable UI IDs.

State includes:

  • focus;
  • text-edit buffer;
  • scroll;
  • active item;
  • pointer capture;
  • dock tree;
  • popup/modal state.

Input routing priority:

modal
  ↓
focused widget
  ↓
hovered panel shortcuts
  ↓
global shortcuts
  ↓
Scene View
  ↓
Game View

Draw list streams:

base
popup
modal
top

Each stream maintains its own clip balance.


23. Fonts and text

Stage 2 uses a built-in bitmap debug font.

render/text exposes a common Font abstraction. From Stage 4 that contract guarantees the text layer expected by the editor:

  • UTF-8 decoding;
  • glyph lookup;
  • kerning;
  • metrics needed for layout and cursor placement.

The Stage 2 BitmapFont remains usable through:

font := bitmapFont.Font()

BitmapFont.Font() adapts the built-in debug font to the common Font API rather than creating a parallel text path.

Stage 4 editor fonts are pre-generated by tools/genfont and collected into text.FontSet; DPI-aware selection uses:

font := fontSet.Closest(px, displayScale)

The editor requires:

  • UTF-8;
  • Cyrillic coverage;
  • kerning;
  • DPI-aware font selection;
  • stable golden-test fonts.

Runtime TTF rasterization is intentionally not required by the Stage 4 architecture.

More advanced shaping can be introduced later without changing the Font abstraction.


24. Logging and errors

24.1 Logging

Structured example:

channel = asset
level   = error
message = "texture import failed"
asset   = 0x...
path    = res://sprites/player.png
cause   = ...

Typical channels:

engine
platform
gpu
render
asset
scene
script
physics
audio
network
editor
game

Logging/formatting in hot frame paths is prohibited.

24.2 Errors

Errors are typed through errs.Error.

Fatal termination is reserved for conditions where the application cannot continue, such as failing to initialize the required platform/graphics backend.

Shader compile, asset import, editor extension and scene load errors are recoverable diagnostics wherever possible.


25. Allocation and performance policy

Runtime hot paths

Targets:

  • zero steady-state engine allocations/frame where practical;
  • no frame-path logging;
  • no accidental slice growth in steady-state renderer/scene loops;
  • explicit capacity overflow statistics rather than panics.

Bulk sprite submission uses slice APIs.

Single-item convenience APIs may copy values.

Editor

Editor is allowed to allocate.

Stage 4 targets:

  • ≤64 KiB/frame while idle;
  • no unbounded per-frame garbage growth;
  • 60 FPS with a 10k-node Hierarchy on the project baseline machine.

26. Build modes

Normal native build

Includes SDL and graphics backend.

aego_nonative

Build tag excludes SDL/OpenGL-native packages and is used for pure-Go CI/tests/tools.

This is distinct from runtime:

Config.Headless = true

A normal native binary may still run headless.

Hidden GPU window

WindowDesc.Hidden creates a GPU-capable hidden window/context for golden and benchmark runs.


27. SDL/native dependency policy

SDL version is pinned in:

third_party/sdl/version.txt

Native bootstrap prepares per-target artifacts into cache/build locations.

SDL is not built implicitly as an opaque side-effect of ordinary package compilation.

Generated OpenGL bindings live under:

gpu/gl33/gl

and are excluded from manual editing and selected static-analysis checks.


28. Testing strategy

28.1 Unit tests

Cover:

  • handles/generation;
  • math;
  • fixed stepper;
  • sparse sets;
  • hierarchy ordering;
  • schema registration/freeze;
  • canonical serialization;
  • VFS;
  • asset dependency graph;
  • editor history;
  • UI layout/input state.

28.2 Contract tests

Real-GL tests verify the public GPU contract against the GL backend.

Headless/null-backend tests verify engine behavior without platform graphics.

28.3 Golden rendering

tests/golden/

Examples:

  • alpha blending;
  • sprite rotation;
  • nearest sampling;
  • clipping;
  • offscreen target orientation;
  • sRGB behavior;
  • camera transforms;
  • editor gizmos/UI.

28.4 Scene/format tests

Required:

load → save → canonical same representation
load → save → load semantic equivalence
format 0 → format 1 migration
unknown component survives round-trip
moved asset with same AssetID does not break scene
missing NodeRef keeps its NodeID

28.5 Performance tests

Stage 2 baseline:

50k world sprites
~10k visible
one atlas/material
0 steady-state renderer allocations
minimal draw calls

Stage 3 baseline adds:

50k nodes
sparse component stores
transform pass
sprite extraction

The scene layer must not materially destroy Stage 2 renderer behavior.

28.6 Dependency tests

tools/depcheck runs in CI.


29. CI expectations

The CI pipeline should eventually cover:

go test ./...
go vet ./...
staticcheck
depcheck

aego_nonative build
native desktop builds

Linux GL contract tests
golden tests via llvmpipe
bench regression check

Generated bindings may have explicit static-analysis exclusions.


30. Naming conventions

Packages

Prefer capability/domain names over implementation-role names.

Good:

render/view
asset/loader
asset/pipeline
sceneio
platform/input

Avoid generic dumping grounds:

utils
helpers
common
manager
misc

Stable IDs

Engine-owned systems/services/passes use namespace-qualified IDs:

engine.transform
engine.sprite_extract
render.sprites
render.debug
editor.save
editor.undo

Project extensions should use project/plugin namespaces.


31. Architecture invariants checklist

A code review changing engine architecture should be able to answer yes to all relevant items:

  • Does runtime remain independent from editor/dev-only packages?
  • Is persistent identity separated from runtime handles?
  • Can the same code path work when res:// comes from .aepak?
  • Is backend-specific behavior confined to backend/view boundaries?
  • Does render remain independent from scene?
  • Are structural scene writes safe during iteration?
  • Are unknown extension components preserved?
  • Are native extensions using only aego/sdk?
  • Does a failed hot reload keep the previous working state?
  • Is the file/source representation canonical and versioned?
  • Does an edit participate in history rather than mutate editor state invisibly?
  • Can the feature run in headless/tests where appropriate?
  • Is the public API not accidentally exposing backend/runtime-storage details?

32. Stage ownership summary

Stage Architectural ownership
1 Core, platform, SDL bridge, input, VFS, Builder/Engine, phases, handles, headless, GPU/RHI, logging/errors, worker/main-thread boundary
2 2D renderer, camera/views, sprite batching, culling, targets, debug draw, GPU profiling, UI foundation, golden rendering
3 Schema, Node+Components scene model, sparse sets, project, asset loader/pipeline, aetext source formats, scene IO, native Go extensions, app/run, aego run/build
4 Editor v1, docking, hierarchy, inspector, Scene/Game views, gizmos, undo/redo, asset browser, play world, editor extensions
5–7 DSL core, editor integration/hot reload, docs/playground
8–12 Physics, audio, pixel/sprite tools, animation, materials/shaders/lighting
13+ Runtime packaging/export, networking/store, Android, 2.5D path

33. Definition of architectural stability

The architecture is considered ready for feature growth after Stage 4 when all of the following are true:

  1. A project can be created, run and edited through Aego tooling.
  2. A scene can be built from nodes/components and saved canonically.
  3. Assets survive moves through persistent IDs.
  4. Native Go can add components/systems without changing engine source.
  5. The editor automatically exposes registered schema.
  6. Edit mode and Play mode are isolated worlds.
  7. Renderer accepts extracted render data rather than scene objects.
  8. Headless tests use the same core engine model.
  9. Runtime packages remain independent from editor/import pipeline code.
  10. Dependency and OS/cgo boundaries are machine-checked.
  11. Hot reload failure is non-destructive.
  12. Golden, serialization and performance regression tests are part of CI.

At that point later systems—DSL, physics, audio, animation, materials, networking and 2.5D—plug into established boundaries rather than redefining them.


34. Binary names

Development/tool binaries:

aego
sandbox
aego-editor
aego-runtime

aego is the user-facing CLI.

Expected command family:

aego new
aego edit
aego run
aego build
aego export
aego bootstrap
aego dump
aego test
aego bench
aego docs

Commands become functional at the stage that owns their implementation.


35. Final rule

When choosing between a locally convenient shortcut and preserving a boundary that later stages depend on, preserve the boundary.

Aego is deliberately structured so that:

platform/GPU can change
without rewriting render,

render can change
without rewriting scene,

scene can change
without rewriting editor data ownership,

editor can evolve
without entering runtime,

and user projects can extend the engine
without forking the engine core.

That separation is the primary architectural constraint of the project.