Loading
Personal Project
Modern Engine: Enigma

Enigma Engine is a C++26 game engine built around modular architecture, runtime extensibility, and an Unreal-style API. It includes runtime DLL module loading with dependency resolution, a subsystem architecture for pluggable engine services, a composition-based scene and component framework with deterministic tick scheduling, and an Enhanced Input plugin with a modifier and trigger pipeline. A companion build toolchain written in C# .NET handles project scaffolding, CMake generation, Visual Studio solutions, and multi-configuration builds from Debug through Shipping.

EnigmaArcade demo

0:00
/0:00
EnigmaArcade running the ASCII renderer with Enhanced Input, tick-driven components, and runtime context switching

Engine architecture overview

The engine is organized into layered runtime modules, each compiled as a separate DLL with explicit dependency declarations. FEngineLoop drives the frame loop, FEngine owns the global FSubsystemCollection, and FGameInstance hosts the user-programmable game loop and scene management.

graph TD
    subgraph EngineLoop["FEngineLoop (Entry Point)"]
        EL[PreInit / Init / Tick / Exit]
    end

    subgraph Engine["FEngine (Global Singleton)"]
        SC[FSubsystemCollection]
        IS[FInputSubsystem - Priority 1000]
        TM[FTickTaskManager - Priority 500]
    end

    subgraph GameInstance["FGameInstance (User Game Logic)"]
        SM[FSceneManager]
        SI[SetupInput]
    end

    subgraph Scene["FScene (Object Container)"]
        GO[FGameObject]
        TC[FTransformComponent]
        CC[Custom Components]
        RC[FRenderComponent]
    end

    subgraph TickSystem["Tick Dispatch"]
        TG1[TG_PreUpdate]
        TG2[TG_Update]
        TG3[TG_PostUpdate]
    end

    subgraph ModuleManager["FModuleManager"]
        EM[Engine Modules]
        GM[Game Modules]
        PM[Plugin Modules]
    end

    EL --> Engine
    SC --> IS
    SC --> TM
    Engine --> GameInstance
    SI --> IS
    GameInstance --> SM
    SM --> Scene
    GO --> TC
    GO --> CC
    GO --> RC
    TM --> TickSystem
    TG1 --> TG2 --> TG3
    EL --> ModuleManager

Each frame follows an ordered sequence: the message pump processes OS events, FInputSubsystem evaluates the input pipeline and fires callbacks, FTickTaskManager dispatches component ticks across three ordered groups (PreUpdate, Update, and PostUpdate), and FGameInstance drives scene updates and rendering.

Module and plugin system

The cross-DLL module system follows Unreal Engine’s module architecture. Each module (Core, Engine, Launch, AsciiRenderer, game modules, and plugins) compiles into its own DLL with API export macros. Modules self-register during static initialization through the IMPLEMENT_MODULE macro, which links a factory function to a global module list at load time without needing a central manifest file.

FModuleManager manages the lifecycle: it calls LoadLibrary to load each DLL, locates the registered factory, instantiates IModuleInterface, and calls StartupModule(). Shutdown runs in reverse order (LIFO). Five loading phases (EarliestPossible through PostEngineInit) allow plugins to declare when they initialize during engine boot.

Plugins use .eplugin JSON descriptors to declare module names, types, loading phases, and dependencies. At build time, the BuildTool resolves the dependency graph and generates CMake link order and DLL copy rules.

BuildTool (C# .NET 9)

The build toolchain is a standalone C# CLI for project scanning, dependency resolution, CMake generation, compilation, and packaging. It supports five build configurations:

ConfigurationOptimizationLink Mode
Debug/OdModular (DLL)
DebugGame/O1Modular (DLL)
Development/O1Modular (DLL)
Shipping/O2Monolithic
Test/O2Modular (DLL)

Scaffolding commands (create-project, create-module, create-plugin) generate boilerplate projects, module descriptors, export headers, and CMake files.

Subsystem architecture

The subsystem architecture adapts Unreal’s USubsystem model for a non-UObject, cross-DLL environment. The base interface is ISubsystem, which provides lifecycle hooks (Initialize, PostInitialize, Tick, Deinitialize) and per-frame ticking through IsTickable() and GetTickPriority().

FSubsystemCollection manages subsystem lifecycles using string keys from each subsystem’s static GetStaticName(). This avoids std::type_index, which can disagree across DLL boundaries on Windows. Initialization runs in four steps:

  1. Filter subsystems with ShouldCreateSubsystem() for conditional creation
  2. Call Initialize() on each, allowing InitializeDependency<T>() for ordered setup
  3. Call PostInitialize() after all subsystems are ready
  4. Build a priority-sorted tick list for per-frame dispatch

Shutdown runs in reverse initialization order (LIFO) so dependent services remain available during teardown. The engine includes two built-in subsystems: FInputSubsystem (priority 1000, runs before gameplay) and FTickTaskManager (priority 500, runs after input to dispatch component ticks).

Scene, GameObject, and component framework

The gameplay framework uses a pure composition model. FGameObject is marked final and acts strictly as an entity container, with behavior implemented entirely through attached FComponent subclasses. Every FGameObject includes a built-in FTransformComponent that stores position, rotation, and scale in an FTransform.

Component lifecycle

Components follow a two-phase initialization pattern:

stateDiagram-v2
    [*] --> OnAttach: AddComponent
    OnAttach --> BeginPlay: Scene has begun play
    BeginPlay --> Update: Each frame if enabled
    Update --> OnDetach: RemoveComponent / Destroy
    OnAttach --> OnDetach: Removed before BeginPlay
    OnDetach --> [*]

    note right of Update: Called every frame by TickSystem

OnAttach() fires immediately when a component is added to an FGameObject, handling self-contained setup that does not depend on sibling components. BeginPlay() runs once before the first Update(), when all sibling components are attached and can be queried with GetComponent<T>(). This two-step sequence avoids ordering bugs during entity assembly.

FScene object container

FScene owns all FGameObject instances and drives their lifecycle. It registers render components during FRenderComponent::OnAttach, separating render traversal from the update loop. Objects marked for destruction are cleaned up at the end of the frame through deferred deletion, preventing iterator invalidation during gameplay logic.

FSceneManager handles transitions through double buffering: LoadScene() sets the incoming scene as pending, and the swap occurs at the next frame boundary so the active scene is not replaced mid-tick.

Tick system

Component updates run through FTickTaskManager, which sorts work into three tick groups separated by synchronization barriers:

Tick GroupPurposeExample
TG_PreUpdateInput results, AI decisionsInput processing
TG_UpdateGameplay logic, movementFArcadeMovementComponent
TG_PostUpdateCamera follow, UI sync, cleanupFArcadeBoundsClampComponent

Prerequisite dependencies

Within a tick group, components can declare ordering constraints with AddPrerequisite(). The manager runs DFS cycle detection as prerequisites are added, catching circular dependencies during registration instead of hanging at runtime. Cross-group prerequisites are ignored because the tick groups already enforce that order.

Parallel dispatch

The tick system connects to the engine task graph (FThreadPool and FTaskGraph). In multithreaded mode, tick functions within a group run as tasks on the FTaskGraph, with prerequisites mapped directly to task dependencies. In single-threaded mode, a fallback uses Kahn’s algorithm for topological sorting, keeping execution order deterministic.

FComponentTickFunction connects the tick system to components. When a component sets bCanEverTick = true, OnAttach creates and registers a tick function. The tick function runs BeginPlay() once, then calls Update() on subsequent frames. Components can also specify a TickInterval if they do not need to tick every frame.

Enhanced Input system

The Enhanced Input plugin (EnhancedInput.eplugin) loads during the PostEngineInit phase. Modeled after Unreal Engine 5’s input architecture, it processes raw input through composable modifiers, stateful triggers, and runtime-switchable mapping contexts.

Pipeline flow

flowchart LR
    A[Physical Key Event] --> B[FInputMessageBridge]
    B --> C[FInputSubsystem SetKeyState]
    C --> D[Process Resolved Mappings]
    D --> E[Apply Modifiers]
    E --> F[Evaluate Triggers]
    F --> G[State Machine]
    G --> H[Emit Events]
    H --> I[Fire Bound Callbacks]

Core abstractions

FInputAction defines an action with a value type (Boolean, Axis1D, Axis2D, or Axis3D) along with optional action-level modifiers and triggers. FInputMappingContext groups key-to-action bindings that can be activated or deactivated at runtime, with each mapping supporting its own modifiers and triggers.

FInputSubsystem manages the pipeline. When mapping contexts change, it flattens the active contexts and actions into a lookup table. Every frame, it reads the resolved mappings, applies modifier chains to transform raw input values, evaluates trigger state machines, and dispatches events through TDelegate callbacks.

Built-in modifiers and triggers

Modifiers alter input values before triggers evaluate them:

  • FInputModifierNegate: negates selected axes
  • FInputModifierScalar: scales axes by a vector
  • FInputModifierDeadZone: applies axial or radial dead zones
  • FInputModifierSwizzleAxis: remaps axes
  • FInputModifierScaleByDeltaTime: scales input by delta time for frame-rate independence

Triggers evaluate whether an action has fired, transitioning between None, Ongoing, and Triggered states:

  • FInputTriggerDown: fires every frame while a key is held
  • FInputTriggerPressed: fires once on press
  • FInputTriggerReleased: fires once on release
  • FInputTriggerHold: fires once the input is held past a threshold duration

ASCII renderer

The AsciiRenderer module provides character-cell rendering for the terminal. IAsciiRenderBackend defines the interface for console output, backed by two implementations: ClassicConsoleBackend (Win32 WriteConsoleOutput with a 16-color palette) and VTConsoleBackend (ANSI escape sequences with 256 colors). An Auto mode checks for VT sequence support at startup and falls back to the Classic backend on older terminals.

FAsciiSpriteComponent inherits from FRenderComponent to draw filled character rectangles. It reads positions from the parent FGameObject transform and submits draw commands to the renderer each frame, supporting DisplayChar, foreground and background colors (FColor), dimensions, and a ZOrder for depth sorting. FScene maintains a dedicated render component registry, so rendering visits only registered visual components instead of traversing the whole scene graph.

EnigmaArcade sample game

EnigmaArcade is an example project that ties these systems together. It implements a small arcade demo where a player character moves and resizes in the terminal using keyboard input across two hot-swappable input modes.

The scene uses an FGameObject with three components:

  • FAsciiSpriteComponent renders the player as a character rectangle
  • FArcadeMovementComponent (running in TG_Update) applies velocity to the transform each frame, then clears it
  • FArcadeBoundsClampComponent (running in TG_PostUpdate) clamps the transform to the console window after movement finishes

Two FInputMappingContext assets define the controls. The Move context maps WASD to continuous movement with FInputTriggerDown, using FInputModifierNegate and FInputModifierSwizzleAxis to turn key presses into 2D directional vectors in a Y-up coordinate space. The Resize context binds the same keys with FInputTriggerPressed for single-step size adjustments. Pressing TAB swaps the active context in FInputSubsystem at runtime.

Design decisions

Composition over inheritance

FGameObject is marked final and cannot be subclassed. Attaching components provides all behavior, which avoids deep inheritance hierarchies and allows dynamic changes to entity behavior at runtime.

Cross-DLL type safety

The engine avoids std::type_index and RTTI for cross-module type identification because virtual tables and type metadata can mismatch across DLL boundaries. Subsystems, components, and modules register with a static string name instead.

Deterministic frame ordering

Three tick groups (PreUpdate, Update, PostUpdate) enforce ordering across systems, and intra-group prerequisites order components within the same phase. Input is handled before gameplay logic, and post-processing like camera follow and bounds clamping runs after movement.

Composable input pipelines

Enhanced Input treats modifiers and triggers as reusable units. A single key binding can change from raw button presses to clamped, scaled, or swizzled directional vectors by changing the modifier chain, without rewriting callback handlers.

Conditional ticking

Components that leave bCanEverTick = false register no tick function and incur no per-frame dispatch overhead, participating only in BeginPlay and OnDetach.