How TEngine's MVE Architecture Handles Data Flow Between Model and View

TEngine implements a strict Model-View-Event (MVE) pattern where the GameEvent module acts as a zero-GC intermediary, allowing Models to broadcast state changes via integer event IDs while Views consume these events through lifecycle-managed listeners that automatically clean up on destruction.

TEngine is an open-source Unity game framework that enforces strict separation of concerns through its Model-View-Event (MVE) architecture. In this design, the Model (game data and logic) never holds direct references to the View (UI components); instead, all communication flows through a lightweight event system. This article examines the exact data flow mechanism, referencing the source implementation in the alex-rachel/tengine repository.

Understanding the Model-View-Event Pattern

TEngine's MVE architecture enforces one-way communication: Model → Event → View. The Model manages game state and business logic, the View handles presentation and user input, and the Event layer acts as a decoupled messaging bus. This strict separation prevents circular dependencies and ensures that UI components can be destroyed or recreated without affecting underlying game systems.

According to the framework documentation in Books/2-框架概览.md, the architecture achieves zero-garbage-collection overhead by using int event IDs rather than string hashes. These IDs are generated via RuntimeId.ToRuntimeId() and serve as compile-time contracts between Models and Views.

Broadcasting State Changes from the Model

When a Model's state changes, it broadcasts an event through the global GameEvent facade located in Runtime/Core/GameEvent/GameEvent.cs. This static class provides the Send() method that distributes messages to all registered listeners via an internal EventMgr instance.

Defining Integer Event IDs

Models and Views share a contract through predefined integer constants. The framework recommends generating these from string identifiers for readability while maintaining runtime performance:

// PlayerEventDefined.cs
public static class PlayerEventDefined
{
    // Recommended: use int IDs for performance
    public static readonly int OnHpChange = RuntimeId.ToRuntimeId("Player.HpChange");
}

Sending Events via GameEvent

The Model calls GameEvent.Send() whenever relevant data mutates, passing the event ID and optional payload:

// PlayerManager.cs (model)
private void ApplyDamage(int dmg)
{
    hp -= dmg;
    // Notify everyone that HP changed, passing the new value
    GameEvent.Send(PlayerEventDefined.OnHpChange, hp);
}

Because GameEvent operates as a global singleton facade, any system component can broadcast state changes without knowing which Views (if any) are currently active.

Consuming Events in the View Layer

Views inherit from UIWindow or UIWidget, both of which derive from UIBase in the UI module (Runtime/Module/UI). These classes override RegisterEvent() to subscribe to specific event IDs using the AddUIEvent() method, which automatically binds listeners to the component's lifecycle.

Registering Listeners in UI Components

UI classes implement RegisterEvent() to establish subscriptions during initialization:

// BattleMainUI.cs (UIWindow)
public override void RegisterEvent()
{
    // AddUIEvent binds the listener to the UIWindow's lifecycle
    AddUIEvent(PlayerEventDefined.OnHpChange, RefreshHpBar);
}

// Called automatically when the event fires
private void RefreshHpBar(int currentHp)
{
    m_hpBar.value = (float)currentHp / maxHp;
}

The AddUIEvent method, implemented in the UI base classes, registers the handler with the global GameEvent system while maintaining a local reference for automatic cleanup.

Automatic Lifecycle Management

TEngine's UI architecture prevents memory leaks by automatically unregistering event listeners when a UI component is destroyed. When AddUIEvent is called, the framework associates the listener with the UI instance's lifetime scope. Upon destruction, the OnDestroy lifecycle method in UIBase automatically removes all registered handlers, preventing dangling callbacks and null reference exceptions.

Local Event Scopes for Component-Level Communication

For scenarios requiring isolated communication between specific objects, TEngine provides GameEventMgr through the MemoryPool system. This allows Models to maintain private event buses that do not broadcast globally, as detailed in Books/3-2-事件模块.md.

Object-Scoped Event Managers

Individual components can instantiate local event managers for encapsulated messaging:

// Player.cs (local model with its own event manager)
private readonly GameEventMgr _eventMgr = MemoryPool.Acquire<GameEventMgr>();
public GameEventMgr Event => _eventMgr;

public void UpdateLevel(int newLevel)
{
    // Send a local event, only listeners attached to this player will receive it
    _eventMgr.Send(PlayerEventDefined.OnLevelUp, newLevel);
}

Listening to Local Events

UI widgets attached to specific entities can subscribe to these local managers rather than the global event system:

// PlayerInfoPanel.cs (UIWidget attached to a Player instance)
public void SetPlayer(Player player)
{
    // Automatically unregister when the widget is destroyed
    AddUIEvent(PlayerEventDefined.OnLevelUp, OnLevelUp);
    // OR listen to the player's own manager:
    player.Event.AddEventListener<int>(PlayerEventDefined.OnLevelUp, OnLevelUp);
}

This dual approach—global events for system-wide state changes and local events for entity-specific updates—provides granular control over data flow scope while maintaining the architectural constraint that Models never directly reference Views.

Summary

  • TEngine's MVE architecture enforces strict separation between Models and Views, with all communication routed through the GameEvent event bus.
  • Integer event IDs generated via RuntimeId.ToRuntimeId() eliminate string hashing overhead and garbage collection pressure.
  • Models broadcast state changes using GameEvent.Send() without knowing which Views exist.
  • Views consume events through AddUIEvent() in their RegisterEvent() overrides, with automatic lifecycle management preventing memory leaks.
  • Local event scopes via GameEventMgr enable component-specific communication without global broadcasts.

Frequently Asked Questions

What is the difference between GameEvent and GameEventMgr in TEngine?

GameEvent is a static facade class located at Runtime/Core/GameEvent/GameEvent.cs that provides global event broadcasting across the entire application. GameEventMgr is an instance-based event manager typically acquired via MemoryPool.Acquire<GameEventMgr>() for local, object-scoped communication. Use GameEvent for system-wide state changes (like player death) and GameEventMgr for entity-specific updates (like a single enemy's health change) that should not pollute the global event space.

How does TEngine prevent memory leaks with UI event listeners?

The AddUIEvent method in UIWindow and UIWidget (derived from UIBase) automatically ties listener registrations to the UI component's lifecycle. When the UI object is destroyed, the framework automatically unregisters all associated event handlers, preventing dangling references that could otherwise keep destroyed GameObjects in memory or cause null reference exceptions when events fire after destruction.

Why does TEngine use integer event IDs instead of strings?

TEngine uses int event IDs—generated by RuntimeId.ToRuntimeId()—to achieve zero-GC messaging. String-based event systems require hashing operations that allocate memory and trigger garbage collection. Integer comparisons are CPU-efficient and allocation-free, making the event system suitable for high-frequency game loops where performance is critical, as implemented in the core runtime.

Can a Model directly reference a View in TEngine's MVE architecture?

No. The architecture explicitly prohibits Models from holding references to Views. According to the framework design documented in Books/2-框架概览.md and implemented in the UI module (Runtime/Module/UI), Models communicate only through the Event layer. This loose coupling allows Views to be created, destroyed, or modified without requiring changes to the underlying Model logic, adhering to strict separation of concerns.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →