Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Architecture Deep-Dive

This guide is for contributors and advanced users who need to understand how GoudEngine works internally. For a shorter overview of the SDK-first design and codegen pipeline, read SDK-First Architecture first.


Layer Hierarchy

GoudEngine enforces a strict dependency hierarchy. Dependencies flow down only. No layer may import from a layer above it.

Within goud_engine/src/, the lint-layers tool enforces 5 internal layers:

Layer 5 — FFI        ffi/, wasm/
               |
Layer 4 — Engine     sdk/, rendering/, component_ops/, context_registry/
               |
Layer 3 — Services   ecs/, assets/
               |
Layer 2 — Libs       libs/  (graphics, platform, providers)
               |
Layer 1 — Foundation core/  (error, math, handle, providers traits)
LayerModules (under goud_engine/src/)Responsibility
1 — Foundationcore/Error types, math primitives, Handle<T>, provider trait definitions
2 — Libslibs/Graphics backend, platform windowing, native provider impls
3 — Servicesecs/, assets/ECS (World, Entity, Component, Systems), asset loading
4 — Enginesdk/, rendering/, component_ops/, context_registry/Game API, render orchestration, context registry
5 — FFIffi/, wasm/C-ABI exports consumed by external SDKs

Beyond the engine crate, two additional layers complete the picture:

LayerPathResponsibility
SDKssdks/Language-specific wrappers over FFI (all SDK languages under sdks/)
Appsexamples/Example games that use SDK APIs

Enforcement

The lint-layers tool (tools/lint_layers.rs) scans all use statements across source files and fails if any upward import is found. It runs in two places:

  • Step 2 of ./codegen.sh (cargo run -p lint-layers)
  • The pre-commit hook

A use goud_engine:: statement inside libs/ or a use crate::ffi:: inside libs/graphics/ are examples of violations the tool will catch.

To generate a visual dependency graph:

./graph.sh   # outputs docs/diagrams/module_graph.png and docs/diagrams/module_graph.pdf

Rust-First Design

All game logic lives in Rust. SDKs are thin wrappers: they marshal data and call FFI functions. They never implement game mechanics, physics, math, or rendering logic.

If you find a method in an SDK that does computation instead of calling an FFI function, that logic belongs in Rust. Move it to goud_engine/src/, expose it via a new FFI function, then have the SDK call that.

DRY validation: when adding features, search for duplicate logic between Rust core and SDK code. If the same calculation appears in both places, one of them is wrong.

Math-in-SDK exception (TypeScript)

Simple value-type operations (Vec2.add, Color.fromHex) are computed locally in the TypeScript SDK to avoid FFI round-trips for trivial math. These are generated by codegen from goud_sdk.schema.json, not hand-written, so they remain consistent across SDK updates. The napi layer accepts f64 at the JS boundary (JavaScript’s native number type) and casts to f32 internally where the Rust engine expects it.

This exception applies only to pure value-type math. Any operation that touches world state, entities, or components must go through FFI.


Dependency Diagram

All paths below are relative to goud_engine/src/.

core/ (Layer 1 — Foundation)
  ├── error.rs              — GoudError, GoudErrorCode, GoudResult
  ├── math.rs               — Vec2, Vec3, Color, Rect, Mat3x3 (#[repr(C)])
  ├── handle.rs             — generational Handle<T>
  └── providers/            — provider trait definitions, null impls, registry, builder

libs/ (Layer 2 — Libs)
  ├── graphics/
  │     ├── backend/opengl/        — all raw gl:: calls live here
  │     ├── renderer2d/           — sprite batching, Camera2D
  │     └── renderer3d/           — 3D primitives, Camera3D, lighting
  ├── platform/
  │     ├── glfw_platform.rs       — desktop window + input
  │     └── winit_platform.rs     — alternative platform backend
  └── providers/
        └── impls/                — native provider impls (OpenGL, Rodio, GLFW)

ecs/ (Layer 3 — Services)
  ├── world.rs              — World, EntityAllocator
  ├── sparse_set.rs         — O(1) component storage
  ├── archetype.rs          — ArchetypeGraph for cache-friendly layout
  ├── query.rs              — typed Query, QueryIter
  ├── systems/transform.rs  — TransformPropagationSystem
  └── components/           — Transform2D, GlobalTransform2D, Sprite, etc.

assets/ (Layer 3 — Services)
  ├── asset_server.rs       — central loader + hot-reload coordinator
  └── loaders/              — texture (PNG/JPG), shader (GLSL), audio (WAV/OGG)

sdk/ (Layer 4 — Engine)
  ├── goud_game.rs          — GoudGame, native Rust API (zero FFI overhead)
  └── entity_builder.rs     — EntityBuilder
rendering/                  — render orchestration
component_ops/              — component operation helpers
context_registry/           — GoudContextId → GoudContext (Mutex-guarded)

ffi/ (Layer 5 — FFI)
  ├── context/                    — goud_context_create / goud_context_destroy
  ├── entity/                     — goud_entity_spawn_empty / goud_entity_despawn
  ├── component/                  — generic component add/remove/query
  ├── component_transform2d/      — Transform2D FFI operations
  ├── component_sprite/           — Sprite FFI operations
  ├── renderer/                   — 2D rendering lifecycle
  ├── renderer3d/                 — 3D rendering lifecycle
  ├── input/                      — keyboard, mouse state queries
  ├── window/                     — frame lifecycle (begin/end frame, swap buffers)
  ├── collision.rs                — collision detection results
  └── types.rs                    — #[repr(C)] shared types (FfiVec2, GoudResult, etc.)
wasm/                             — WASM bindings

--- external to goud_engine crate ---

sdks/                   — all SDK language wrappers (see sdks/ for full list)

examples/
  ├── csharp/         — flappy_goud, 3d_cube, goud_jumper, isometric_rpg, hello_ecs
  ├── python/         — main.py, flappy_bird.py
  └── typescript/     — flappy_bird desktop + web

FFI Boundary

Context-based design

Every FFI call takes a GoudContextId (an opaque u64) as its first argument. The context registry maps that ID to an isolated engine instance under a Mutex. Using a stale ID after context destruction returns an error — it never accesses freed memory.

#![allow(unused)]
fn main() {
// From goud_engine/src/ffi/context/lifecycle.rs
#[no_mangle]
pub extern "C" fn goud_context_create() -> GoudContextId {
    let mut registry = match get_context_registry().lock() {
        Ok(r) => r,
        Err(_) => {
            set_last_error(GoudError::InternalError(
                "Failed to lock context registry".to_string(),
            ));
            return GOUD_INVALID_CONTEXT_ID;
        }
    };
    match registry.create() {
        Ok(id) => id,
        Err(err) => {
            set_last_error(err);
            GOUD_INVALID_CONTEXT_ID
        }
    }
}
}

Function conventions

Every public FFI function must follow these rules:

  • Marked #[no_mangle] pub extern "C"
  • Returns i32 (0 = success, negative = error code) or a sentinel value for handle returns
  • Null-checks every pointer parameter before dereferencing
  • Every unsafe block has a // SAFETY: comment explaining why it is sound

Type requirements

Structs shared across the FFI boundary must use #[repr(C)]. Signatures accept only C-compatible types: primitive integers, floats, *const T, *mut T, and bool. No String, Vec, Option, or other Rust-only types appear at the boundary.

Error propagation

FFI functions return GoudResult (an i32). Detailed error messages are stored in thread-local storage and retrieved by callers via goud_get_last_error_message(). This avoids passing string pointers across the boundary for the common case.

Memory ownership

The default rule is: caller allocates, caller frees. Any deviation must be documented on the function. Box-allocated values returned to callers require a corresponding _free function.

Call flow

C# / Python / TypeScript SDK
        |
        |  goud_*(ctx_id, ...)
        v
FFI function (goud_engine/src/ffi/)
        |
        |  get_context_registry().lock() -> GoudContextHandle
        v
context_registry resolves GoudContextId -> GoudContext
        |
        |  context.world_mut().{spawn_empty, insert, query, ...}
        v
ECS World operation
        |
        v
Graphics / Physics / Asset backend

ECS Architecture

Core concepts

World owns all entities and components. It is the central ECS container. Access it through the context: context.world_mut().

Entity is a generational ID: an index packed with a generation counter. The generation counter detects stale references — if you store an entity ID and the slot is reused after despawn, the generation will not match. Never store raw u64 bits and compare them as if they were stable identifiers; always use the entity API.

Component is a plain data struct with no side-effecting methods. Derive Debug and Clone at minimum. The Component trait is a marker — implementing it registers the type with the ECS.

System is a function that takes a &mut World reference and operates on components via typed queries. Systems run in a declared order within each frame.

Query provides typed, cache-friendly iteration over components. Filter to the minimal set of components the system needs. Never downcast or use Any to access component data.

Storage

The ECS uses two complementary storage strategies:

  • SparseSet — O(1) insert, remove, and lookup. Iteration is cache-friendly because the dense array holds components contiguously.
  • ArchetypeGraph — groups entities with identical component sets into archetypes. This improves iteration performance when systems query large numbers of entities with the same component set.

Built-in components

ComponentPurpose
Transform2DLocal 2D spatial transform: position, rotation, scale
GlobalTransform2DWorld-space 2D transform; written by the propagation system
SpriteTexture handle, color tint, flip flags
RigidBodyPhysics body: type (dynamic/kinematic/static), velocity, mass, gravity scale, damping
ColliderCollision shape (circle, box, capsule, polygon), friction, restitution, sensor flag, layer/mask filtering
AudioSourceAudio clip handle, playback state, volume, pitch, channel (Music/SFX/Ambience/UI/Voice), spatial positioning
TextText content, font handle, font size, color, alignment, word-wrapping
SpriteAnimatorSprite sheet animation clip, frame timing, playback mode (loop/one-shot), frame events
AnimationControllerState machine with parametric transitions, blend duration, bool/float parameters
AnimationLayerStackMulti-layer animation blending with per-layer weight and blend mode (override/additive)
ParentReference to this entity’s parent
ChildrenList of child entity IDs

Transform Propagation

Local vs. world-space

Transform2D holds the local transform relative to the entity’s parent. If an entity has no parent, local and world-space are identical. GlobalTransform2D holds the computed world-space transform. The rendering system reads GlobalTransform2D — never Transform2D directly.

Always mutate Transform2D. Read GlobalTransform2D for world-space positions. The propagation system keeps them in sync.

Parent/Children hierarchy

Hierarchy relationships use two components:

  • Parent — attached to a child, stores the parent entity ID
  • Children — attached to a parent, stores a list of child entity IDs

The system traverses the tree top-down: it processes root entities first, then recurses into children. This ensures each child’s GlobalTransform2D is computed using an already-updated parent GlobalTransform2D.

TransformPropagationSystem

TransformPropagationSystem (in goud_engine/src/ecs/systems/transform.rs) runs both 2D and 3D propagation in a single run() call:

#![allow(unused)]
fn main() {
fn run(&mut self, world: &mut World) {
    propagate_transforms_2d(world);
    propagate_transforms(world);   // 3D
}
}

It declares reads on Transform2D, Parent, and Children, and writes on GlobalTransform2D. The access declaration is used by the scheduler to detect conflicts with other systems.

When it runs

In the game loop, TransformPropagationSystem runs after the user update callback and before the rendering system. The sequence per frame:

poll_events()
    |
    v
InputManager updated
    |
    v
User update callback
  - query World, read/write Transform2D
  - spawn / despawn entities
    |
    v
TransformPropagationSystem::run()
  - Transform2D + Parent/Children hierarchy -> GlobalTransform2D
    |
    v
Rendering system
  - queries (GlobalTransform2D, Sprite) -> screen-space quads
    |
    v
SpriteBatch::flush()
  - group by texture, sort by z-order -> OpenGL draw calls
    |
    v
swap_buffers()

Concrete example

A parent entity at position (100, 50) with a child at local position (20, 10). After propagation:

parent.Transform2D.position  = (100, 50)
parent.GlobalTransform2D.translation = (100, 50)   -- root: local == world

child.Transform2D.position   = (20, 10)             -- local, relative to parent
child.GlobalTransform2D.translation  = (120, 60)    -- world = parent_world + child_local

The test test_2d_propagation_parent_child in goud_engine/src/ecs/systems/transform.rs verifies this (abbreviated):

#![allow(unused)]
fn main() {
// parent at (100, 50)
let parent = spawn_2d(&mut world, Vec2::new(100.0, 50.0));
// child at local (20, 10) -> global (120, 60)
let child = spawn_2d(&mut world, Vec2::new(20.0, 10.0));
set_parent(&mut world, parent, child);

system.run(&mut world);

let child_global = world.get::<GlobalTransform2D>(child).unwrap();
assert!((child_global.translation().x - 120.0).abs() < 0.001);
assert!((child_global.translation().y - 60.0).abs() < 0.001);
}

Provider System

The provider pattern (goud_engine/src/core/providers/) lets subsystems swap implementations at engine initialization time. All provider traits require Send + Sync + 'static so they can be stored in ProviderRegistry and accessed from worker threads during asset streaming. The exception is WindowProvider, which is !Send + !Sync because GLFW requires main-thread access.

Provider lifecycle has five phases: create, init, per-frame update, shutdown, and drop. Null implementations in providers/impls/ enable headless testing without a real window, GPU, or audio device.

See docs/src/architecture/providers.md for the full provider API reference.


Phase 2 Subsystems

Physics

The engine supports 2D physics via Rapier2D and 3D physics via Rapier3D. Both backends run through a PhysicsWorld resource attached to the context. The simulation advances on a fixed timestep accumulator defaulting to 1/60 s; the remainder carries forward to the next frame.

Key behaviors:

  • Bodies that have not moved for several frames enter a sleep state and stop consuming simulation time. They wake automatically when a force is applied or a collision occurs.
  • Gravity scale is set per RigidBody — a value of 0.0 makes a body float freely without disabling collision response.
  • Layer-based collision filtering uses a bitmask on Collider: a body’s layer is compared against the other body’s mask. If the bitwise AND is zero, the pair is skipped entirely before narrow-phase.

FFI exports for physics are grouped under ffi/physics/.

Audio

RodioAudioProvider wraps the rodio crate directly and exposes five named channels: Music, SFX, Ambience, UI, and Voice. Each channel has an independent volume multiplier combined with a master volume at the provider level.

Spatial audio uses two attenuation modes:

  • Inverse-distance — gain falls off as 1/distance from the listener.
  • Linear-distance — gain decreases linearly between a reference distance and a maximum distance cutoff.

Volume is controllable at three granularities: per-instance on AudioSource, per-channel via the provider API, and globally via the master volume setting.

Animation

Three systems handle different levels of animation complexity:

SpriteAnimator drives frame-by-frame sprite sheet animation. It reads an animation clip (start frame, end frame, frame duration) from the component and advances the Sprite texture region each tick. Frame events fire user callbacks at specified frame indices.

AnimationController implements a state machine on top of SpriteAnimator. States are named clips; transitions are edges with conditions evaluated against bool or float parameters set at runtime. Blend duration controls how long a cross-fade lasts when entering a new state.

AnimationLayerStack composes multiple AnimationController outputs by weight. Each layer specifies a blend mode:

  • Override — the layer replaces lower layers for the bones it controls.
  • Additive — the layer’s pose is added on top of the lower result, scaled by weight.

Standalone tweens (not attached to an animator) apply easing functions to arbitrary scalar or Vec2 values: Linear, EaseIn, EaseOut, EaseInOut, EaseInBack, EaseOutBounce.

Text Rendering

TrueType fonts load as FontAsset via the standard asset server pipeline. Bitmap fonts use BitmapFontAsset with a pre-built glyph atlas texture and metrics file. Both types produce glyphs that are cached into a shared atlas; repeated text with the same font and size costs one texture lookup instead of re-rasterizing.

Text component options:

  • Alignment: left, center, right
  • Word-wrapping: enabled by setting max_width; disabled when max_width is zero
  • Line spacing: multiplier applied to the font’s natural line height

Scene Management

SceneManager is a per-context service that owns a collection of named scenes. Each scene contains an isolated ECS World; entities and components in one scene are not visible to queries in another.

Scene transitions use one of three modes:

  • Instant — the old scene is deactivated and the new one activates on the same frame.
  • Fade — the engine renders a fade-out over the outgoing scene, then a fade-in over the incoming scene.
  • Custom — a user-supplied callback controls the transition render.

Scene ID 0 is the default scene. It is created automatically with the context and cannot be destroyed.

UI System

UiManager maintains a hierarchical node tree. Each node is identified by a UiNodeId — a generational handle that detects use-after-free without unsafe code. Widgets are components attached to nodes; the tree structure determines layout propagation and event routing.

Cycle detection runs on every set_parent call. Attempting to create a circular hierarchy returns an error immediately; it does not silently corrupt the tree.

Error Diagnostics

Error codes are grouped into numeric ranges by category:

RangeCategory
0–99Context lifecycle
100–199Resource management
200–299Rendering
300–399Physics
400–499Audio
500–599Animation
600–699UI
700–799Scene management

Errors carry structured metadata: subsystem name, operation name, and a recovery hint string. Thread-local error state stores the most recent error so FFI callers can retrieve the full message after inspecting the GoudResult return code.

In diagnostic mode (enabled via a compile feature or runtime flag), errors capture a backtrace at the point they are created. ErrorSeverity classifies each error:

  • Fatal — engine cannot continue; context should be destroyed.
  • Recoverable — the operation failed but the engine remains in a valid state.
  • Warning — the operation succeeded but something unexpected occurred.

Cross-References

  • SDK-First Architecture — codegen pipeline, thin wrapper rule, schema files
  • Adding a New Language — step-by-step guide for new SDK targets
  • ARCHITECTURE.md in the repo root — high-level overview with mermaid diagrams