Extensibility Patterns in ArmorPaint: How `trait.c` Enables Modular Plugin Architecture
ArmorPaint implements a registry-based plugin architecture through trait.c that enables dynamic attachment of behaviors via function pointers, allowing developers to add gameplay mechanics without modifying core engine code.
ArmorPaint's extensibility relies on a lightweight trait system that decouples gameplay behaviors from the engine loop. By utilizing static registration tables and function pointer dispatch in paint/sources/trait.c, the system allows modules like trait_point_and_click_controller.c to integrate seamlessly while maintaining clean separation of concerns.
The Registry-Based Plugin Architecture
The core extensibility mechanism centers on a statically defined registry that maps string identifiers to lifecycle function groups. This pattern eliminates hard-coded dependencies between the engine main loop and specific gameplay features.
Static Trait Table and Function Pointer Interface
In paint/sources/trait.c, the system defines a uniform contract that all traits must implement:
typedef void (*trait_init_t)(char *object);
typedef void (*trait_run_t)(void);
typedef void (*trait_stop_t)(void);
typedef struct trait {
char *name;
trait_init_t init;
trait_run_t run;
trait_stop_t stop;
} trait_t;
The global registry static trait_t traits[] at lines 15-18 holds descriptors for every available trait, each containing a name and three function pointers. Adding a new trait requires only appending an entry to this array; the engine core never needs recompilation to recognize new behaviors.
Dynamic Dispatch Through Function Pointers
ArmorPaint achieves dynamic dispatch at runtime by invoking behaviors through function pointers stored in the registry. When script_add_trait("object_name", "trait_name") is called, the system looks up the name in traits[] and executes the corresponding init pointer. This decouples the engine from concrete implementations while guaranteeing all traits expose the same lifecycle interface.
Lifecycle Management and Idempotent Attachment
Safe composition requires preventing duplicate initialization when scripts attach traits multiple times.
Attachment Flag Array
The static bool trait_attached[TRAIT_COUNT]; array at lines 22-33 tracks which traits are active on specific objects. Before calling init, the system checks this flag, ensuring idempotent behavior—calling script_add_trait twice silently succeeds the second time without side effects.
Centralized Update Loops
The engine invokes trait_update() and trait_stop() functions (lines 39-53) to manage execution. These iterate over the registry, invoking run callbacks only for attached traits:
void trait_update(void) {
for (int i = 0; i < TRAIT_COUNT; i++) {
if (trait_attached[i] && traits[i].run != NULL) {
traits[i].run();
}
}
}
This centralized dispatch keeps the main game loop clean; it only needs to call trait_update() each frame rather than managing individual behavior states.
Concrete Implementation: trait_point_and_click_controller.c
The file paint/sources/traits/trait_point_and_click_controller.c demonstrates how complex subsystems conform to the trait interface. This controller implements A*-style pathfinding and mouse picking while exposing only the standard lifecycle functions.
Internal State Encapsulation
Despite containing rich internal logic—pathfinding data, movement flags, and position tracking—the controller exposes only three public functions matching the trait_t signature: trait_point_and_click_controller_init, trait_point_and_click_controller_run, and trait_point_and_click_controller_stop. Static helper functions (pac_*) encapsulate the pathfinding algorithm, keeping the public trait surface minimal.
Callback Pattern for Completion
At lines 25-30, the controller implements a lightweight observer mechanism through function pointer storage:
void *on_arrive;
void trait_point_and_click_controller_arrive(void (*callback)()) {
on_arrive = callback;
}
When the character reaches its destination, the controller invokes on_arrive, allowing external scripts to react to state changes without coupling to the internal movement logic.
Implementation Guide: Adding a New Trait
Creating a new behavior requires three steps following the established extensibility pattern:
1. Implement lifecycle functions in a new file (e.g., fly_controller.c):
void trait_fly_controller_init(char *object) {
// Setup flight physics for object
}
void trait_fly_controller_run(void) {
// Update position each frame
}
void trait_fly_controller_stop(void) {
// Cleanup resources
}
2. Register in the trait table in paint/sources/trait.c:
static trait_t traits[] = {
{"point_and_click_controller",
trait_point_and_click_controller_init,
trait_point_and_click_controller_run,
trait_point_and_click_controller_stop},
{"fly_controller",
trait_fly_controller_init,
trait_fly_controller_run,
trait_fly_controller_stop},
};
3. Attach via script:
// Attach to an object
script_add_trait("player", "fly_controller");
Summary
- Registry-based architecture: The
traits[]table intrait.cprovides a central catalog of behaviors using function pointers for decoupled integration. - Idempotent attachment: The
trait_attachedarray prevents duplicate initialization, ensuring safe dynamic composition at runtime. - Uniform lifecycle: All traits implement
init,run, andstopfunctions, enabling generic dispatch throughtrait_update()andtrait_stop(). - Callback hooks: Concrete implementations like
trait_point_and_click_controller.cstore function pointers to notify external code of state changes without exposing internals. - Zero core modification: New traits require only table registration and implementation file addition, never touching the engine's main loop.
Frequently Asked Questions
How does ArmorPaint prevent duplicate trait initialization on the same object?
The system uses a static bool trait_attached[TRAIT_COUNT] array to track which traits are active on specific objects. When script_add_trait is called, it checks this flag before invoking the init function, making the operation idempotent—subsequent calls with the same trait and object combination silently succeed without re-initialization.
What is the performance overhead of the trait dispatch system?
The overhead consists of a single array iteration in trait_update() combined with a boolean check per trait. Because the traits[] table is static and TRAIT_COUNT is known at compile time, the compiler can optimize the loop aggressively. The function pointer indirection adds minimal cost compared to direct calls while providing significant architectural flexibility.
Can traits communicate with each other or share state?
While the base trait.c system does not implement direct inter-trait messaging, individual traits can expose callback registration functions like trait_point_and_click_controller_arrive. Objects can store shared state in their own data structures, and traits can access object-specific data through the char *object parameter passed to init functions.
Is it possible to remove or swap traits at runtime?
The current implementation in trait.c supports stopping traits via trait_stop(), which iterates through attached traits and invokes their stop callbacks. However, removing a trait from the attachment array would require additional logic to unset the corresponding trait_attached index, which developers can extend following the existing pattern used for attachment.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →