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)
| Layer | Modules (under goud_engine/src/) | Responsibility |
|---|---|---|
| 1 — Foundation | core/ | Error types, math primitives, Handle<T>, provider trait definitions |
| 2 — Libs | libs/ | Graphics backend, platform windowing, native provider impls |
| 3 — Services | ecs/, assets/ | ECS (World, Entity, Component, Systems), asset loading |
| 4 — Engine | sdk/, rendering/, component_ops/, context_registry/ | Game API, render orchestration, context registry |
| 5 — FFI | ffi/, wasm/ | C-ABI exports consumed by external SDKs |
Beyond the engine crate, two additional layers complete the picture:
| Layer | Path | Responsibility |
|---|---|---|
| SDKs | sdks/ | Language-specific wrappers over FFI (all SDK languages under sdks/) |
| Apps | examples/ | 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
unsafeblock 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
| Component | Purpose |
|---|---|
Transform2D | Local 2D spatial transform: position, rotation, scale |
GlobalTransform2D | World-space 2D transform; written by the propagation system |
Sprite | Texture handle, color tint, flip flags |
RigidBody | Physics body: type (dynamic/kinematic/static), velocity, mass, gravity scale, damping |
Collider | Collision shape (circle, box, capsule, polygon), friction, restitution, sensor flag, layer/mask filtering |
AudioSource | Audio clip handle, playback state, volume, pitch, channel (Music/SFX/Ambience/UI/Voice), spatial positioning |
Text | Text content, font handle, font size, color, alignment, word-wrapping |
SpriteAnimator | Sprite sheet animation clip, frame timing, playback mode (loop/one-shot), frame events |
AnimationController | State machine with parametric transitions, blend duration, bool/float parameters |
AnimationLayerStack | Multi-layer animation blending with per-layer weight and blend mode (override/additive) |
Parent | Reference to this entity’s parent |
Children | List 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 IDChildren— 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’slayeris compared against the other body’smask. 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 whenmax_widthis 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:
| Range | Category |
|---|---|
| 0–99 | Context lifecycle |
| 100–199 | Resource management |
| 200–299 | Rendering |
| 300–399 | Physics |
| 400–499 | Audio |
| 500–599 | Animation |
| 600–699 | UI |
| 700–799 | Scene 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.mdin the repo root — high-level overview with mermaid diagrams