Jungle Engine
Jungle is an experimental game engine built on C++26 reflection, exploring the application of modern C++ standards in game development.
Design Philosophy
- Zero-overhead abstraction: Compile-time reflection and template metaprogramming replace runtime type systems, with no dependency on RTTI or exceptions
- Type safety: Errors caught at compile time through concepts and strong type aliases
- Modular: Base library, ECS core, rendering, networking, etc., layered by dependency
Build Requirements
- GCC only (for now)
- C++26 standard
- Reflection (
-freflection) - RTTI disabled (
-fno-rtti) - Exceptions disabled (
-fno-exceptions)
Jungle Base Library
jungle-base is the infrastructure layer of the Jungle engine, providing the type system, serialization framework, reflection tools, test framework, and other low-level capabilities. All upper-layer modules (jungle-core, jungle-server, etc.) depend on this library.
Module Structure
include/jungle/
├── concepts.h # Concept constraints (Debug, is_enum, etc.)
├── debug.h # Reflection-based generic debug output
├── meta.h # Reflection metaprogramming utilities
├── panic.h # Unrecoverable error handling
├── preusing.h # Centralized common type alias imports
├── container/ # Containers
│ ├── hash_map.h # Custom hash map
│ └── mpsc.h # MPSC lock-free bounded queue
├── serde/ # Serialization/deserialization framework
│ ├── serde.h # Concept definitions and annotations
│ ├── serialize.h # SerializeTarget base class and serialize() free function
│ └── deserialize.h # DeserializeSource base class and deserialize() free function
├── test/ # Test framework
│ └── test.h # JUNGLE_SYNC_TEST macros and assertions
├── types/ # Fundamental types
│ ├── int.h # Fixed-width integer aliases (u8, i32, usize, etc.)
│ └── uchar.h # Unicode character and string (uchar, ustr)
└── util/ # Utilities
├── murmur.h # MurmurHash
├── parse.h # Base64 encoding view
├── type_id.h # Compile-time type ID
└── types.h # Type trait utilities
Build Requirements
- C++23 standard (enabled with
-std=c++23) - Reflection extension enabled (
-freflection) - RTTI and exceptions disabled (
-fno-rtti -fno-exceptions)
Key Components
Type System
types/int.h defines fixed-width integer aliases corresponding to std types:
| Alias | Corresponding Type |
|---|---|
u8 ~ u64 | std::uint8_t ~ std::uint64_t |
i8 ~ i64 | std::int8_t ~ std::int64_t |
usize | std::size_t |
isize | std::ptrdiff_t |
types/uchar.h provides uchar (Unicode code point) and ustr (UTF-8 string), with std::format support.
Reflection Utilities (meta.h)
Compile-time type introspection based on C++26 reflection:
has_annotation()— check whether a type/member has a given annotationhas_template_annotation()— check whether a type/member has an instance of a given template annotationnth_template_annotation_argument_of()— retrieve the Nth argument of a template annotationis_specialization_of_template()— check whether a type is an instance of a given templatenonstatic_data_members_with_annotation()— retrieve all non-static data members with a given annotation
Serialization Framework (serde/)
A reflection-based, type-safe serialization/deserialization framework. See Serde Serialization/Deserialization Framework.
Key features:
- Zero macros, zero external code generation — relies entirely on C++26 compile-time reflection
- CRTP design: implement
SerializeTargetorDeserializeSourceto integrate - Annotations control field participation policies (
[[=customized]]/[[=field]]) - Supports field-level customizers (
[[=customize<C>]]) - Recursively handles nested structs, containers, and
std::optional
Test Framework (test/)
Lightweight synchronous test framework:
JUNGLE_SYNC_TEST(my_test) {
JUNGLE_SYNC_ASSERT(condition, "failure message {}", args...);
JUNGLE_SYNC_SUCCESS();
}
- Tests are registered automatically via static initialization
- Assertion failures return structured error messages with source location
- Test results printed to stdout
Containers
hash_map
A custom open-addressing hash map with Robin Hood hashing, supporting tombstone reuse and automatic growth/rehash. See include/jungle/container/hash_map.h.
MPSC Queue
A lock-free bounded multiple-producer single-consumer queue. See MPSC Lock-Free Bounded Queue.
MPSC Lock-Free Bounded Queue
jungle::container::mpsc<T> is a multiple-producer single-consumer (MPSC) lock-free bounded queue. It is built on a ring buffer with atomic operations for data-race-free concurrency.
Feature Summary
- Lock-free: Both send and receive use only
std::atomic, with no mutexes - Multiple producers:
senderis freely copyable; concurrent sends from multiple threads are safe - Single consumer:
receiveris non-copyable; only one thread should callrecv()at a time - Bounded capacity: Minimum capacity specified at creation, internally rounded up to the next power of two
- Type compatible: Supports move-only, copy-only, and trivial types (dispatched automatically via
try_move_t) - No exceptions / no RTTI: Consistent with Jungle’s overall design
Creating a Queue
Use the static factory method queue() to create a sender / receiver pair:
#include "jungle/container/mpsc.h"
using jungle::container::mpsc;
// Default minimum capacity of 1023
auto [sender, receiver] = mpsc<int>::queue();
// Specify minimum capacity
auto [s, r] = mpsc<std::string>::queue(8);
The size parameter specifies the minimum capacity guarantee. Internally, the actual capacity is rounded up to the next power of two no less than size, so the real number of slots may exceed the requested value.
Due to the ring buffer’s full/empty discrimination requiring one reserved slot, the effective capacity is actual capacity minus one.
Queue storage is managed by std::shared_ptr; all sender and receiver instances share the same underlying buffer.
Sending
sender is both copyable and movable. Multiple sender copies may call send() concurrently:
auto [s1, receiver] = mpsc<int>::queue();
auto s2 = s1; // sender is copyable, shares the same queue
auto s3 = s1;
s1.send(10); // sent from thread A
s2.send(20); // sent from thread B
s3.send(30); // sent from thread C
send Signature
[[nodiscard]] bool send(try_move_t<T> value);
- The parameter type
try_move_t<T>automatically selects the optimal passing convention:- Move-only types →
T&&(move semantics) - Copy-only types →
const T&(copy semantics) - Trivial types (
int, etc.) →T(pass by value)
- Move-only types →
- Returns
trueon success,falseif the queue is full
if (!sender.send(data)) {
// Queue is full; retry or drop
}
Receiving
receiver is non-copyable, movable only. Only one thread should call recv() at a time:
auto val = receiver.recv();
if (val.has_value()) {
// process *val
}
recv Signature
[[nodiscard]] std::optional<T> recv();
- Returns
std::optional<T>:- A value: the front element has been successfully dequeued
std::nullopt: the queue is empty
Elements are received in FIFO (first-in-first-out) order.
Capacity Semantics
auto [s, r] = mpsc<int>::queue(4);
// Actual capacity ≥ 4 (may be 7, 15, etc., depending on power-of-2 rounding)
// At least 4 sends are guaranteed to succeed
for (int i = 0; i < 4; ++i) {
s.send(i); // all succeed
}
// Do not assume the 5th send will fail —
// actual capacity may be larger
To determine when the queue is truly full, loop send() until it returns false.
Thread Safety
| Operation | Safety |
|---|---|
send | Multi-thread safe (concurrent producers) |
recv | Single-thread (same receiver must not be used concurrently) |
send + recv | Safe (producers and consumer may run concurrently) |
Usage Examples
Basic Send and Receive
auto [sender, receiver] = mpsc<int>::queue();
sender.send(42);
auto val = receiver.recv();
// *val == 42
Multiple Producers
auto [s1, receiver] = mpsc<int>::queue();
auto s2 = s1;
auto s3 = s1;
s1.send(10);
s2.send(20);
s3.send(30);
// Received in send order: 10, 20, 30
receiver.recv(); // 10
receiver.recv(); // 20
receiver.recv(); // 30
Move-Only Types
auto [sender, receiver] = mpsc<std::unique_ptr<int>>::queue();
sender.send(std::make_unique<int>(42));
auto ptr = receiver.recv();
// *ptr == 42
Draining the Queue
while (auto val = receiver.recv()) {
process(*val);
}
Notes
receivermust be obtained from thequeue()return tuple; there is no default-constructedreceiver- Once all senders are destroyed, queue memory is freed by whichever
receiverorsenderlast holds the shared state - This is a bounded queue; it is not suitable for scenarios where producer throughput consistently exceeds consumer throughput
Serde Serialization/Deserialization Framework
Jungle’s serde is a C++26 reflection-based serialization/deserialization framework. It does not rely on macros or external code generation — all type information is obtained at compile time through reflection.
Interfaces, Behavior, and Constraints
Core Concepts
The framework decouples “how data is represented” from “what the data is”:
- Target: Receives type-safe data write calls and produces some output format
- Source: Reads from some input format and writes into existing variables
- Customizer: Transforms the value of a specific field during serialization/deserialization
Type Dispatch Order
The serialization and deserialization free functions perform compile-time dispatch on types in the following order:
boolstd::integralstd::floating_pointenumstd::optional<T>std::ranges::range(container/range types)class/struct
If a type does not match any of the above categories, a compile-time static_assert error is raised.
Serialization
Free Functions
// Write into an existing target
template<SerializeTargetImpl Target, typename T>
void serialize(const T &value, Target &target);
// Create a target and return the result
template<SerializeTargetImpl Target, typename T>
typename Target::target_type serialize(T &&value);
serialize(value, target) writes value into target. The convenience overload serialize<Target>(value) default-constructs Target, calls the above function, and returns the final product via target.deliver_result().
SerializeTarget Base Class
All Targets must inherit from SerializeTarget<Target> (CRTP) and satisfy the SerializeTargetImpl concept:
- Define
target_type(the type of the final product) - Implement
deliver_result() -> target_type - Implement
spawn_subtarget() -> Target(creates a child target for nested structures) - Implement
serialize_*methods for each type (called by the base class via CRTP)
Template methods provided by the SerializeTarget base class and their requires constraints:
| Base Class Method | Called Derived Class Methods |
|---|---|
serialize_bool(const bool &) | serialize_bool(const bool &) -> void |
serialize_integral(const I &) | serialize_integral(const I &) -> void |
serialize_floating_point(const F &) | serialize_floating_point(const F &) -> void |
serialize_enum(const T &) | serialize_enum(const T &) -> void |
serialize_optional(const optional<T> &) | serialize_optional_nonnull() -> void, serialize_optional_nullopt() -> void |
serialize_range(const R &) | serialize_range_head() -> void, serialize_range_element_end() -> void, serialize_range_tail(usize) -> void |
serialize_class_object(const T &) | serialize_class_head(string_view) -> void, serialize_class_field(string_view) -> void, serialize_class_field_end() -> void, serialize_class_tail(string_view) -> void |
Class Serialization Flow
For class types, the framework uses reflection to iterate over all non-static data members:
- Without
[[=customized]]annotation: all non-static data members are serialized - With
[[=customized]]annotation: only members marked with[[=field]]participate - For members annotated with
[[=customize<C>]], the customizerC’sserializemethod takes over the field’s output
Reflection uses std::meta::access_context::unchecked() to access private members.
Deserialization
Free Functions
// Construct a new value from payload (T must be default_constructible)
// Returns the value on success, unexpected(Source::error_type) on failure
template<typename T, DeserializeSourceImpl Source>
requires std::is_default_constructible_v<T>
std::expected<T, typename Source::error_type>
deserialize(const typename Source::source_type &source_payload);
// Write from payload into an existing variable
template<typename T, DeserializeSourceImpl Source>
[[nodiscard]] std::expected<void, typename Source::error_type>
deserialize(const typename Source::source_type &source_payload, T &value);
// Operate directly on a source, writing into an existing variable
template<typename T, DeserializeSourceImpl Source>
[[nodiscard]] std::expected<void, typename Source::error_type>
deserialize(Source &source, T &value);
All deserialization operations indicate success/failure via std::expected: overloads that write into an existing variable return expected<void, Source::error_type>, and the overload that constructs a new value returns expected<T, Source::error_type>. Errors propagate from deserialize_* up to these free functions. The caller is responsible for checking the return value.
DeserializeSource Base Class
All Sources must inherit from DeserializeSource<Source> (CRTP) and satisfy the DeserializeSourceImpl concept:
- Define
source_type(the raw input type) - Define
error_type(the error type reported on deserialization failure) - Implement
provide_source(const source_type &) -> void - Implement
spawn_subsource() -> Source - Implement
deserialize_*methods for each type; all methods returnstd::expected<void, error_type>
Template methods provided by the DeserializeSource base class and their requires constraints (E stands for Source::error_type):
| Base Class Method | Called Derived Class Methods |
|---|---|
deserialize_bool(bool &) -> expected<void, E> | deserialize_bool(bool &) -> expected<void, E> |
deserialize_integral(I &) -> expected<void, E> | deserialize_integral(I &) -> expected<void, E> |
deserialize_floating_point(F &) -> expected<void, E> | deserialize_floating_point(F &) -> expected<void, E> |
deserialize_enum(T &) -> expected<void, E> | deserialize_enum(T &) -> expected<void, E> |
deserialize_optional(OptionalT &) -> expected<void, E> | deserialize_optional_nonnull() -> expected<void, E>, deserialize_optional_nullopt() -> expected<void, E> |
deserialize_range(R &) -> expected<void, E> | deserialize_range_head() -> expected<void, E>, deserialize_range_has_element() -> expected<void, E>, deserialize_range_element_end() -> expected<void, E>, deserialize_range_tail() -> expected<void, E> |
deserialize_class_object(T &) -> expected<void, E> | deserialize_class_head() -> expected<void, E>, deserialize_class_field() -> expected<void, E>, deserialize_class_field_end() -> expected<void, E>, deserialize_class_tail() -> expected<void, E> |
Unlike serialization, all deserialization structural methods (
head,field,element_end, etc.) returnexpected<void, E>rather thanvoid. When an illegal format is encountered, they can returnunexpectedto allow the upper layer to roll back or report an error, rather than terminating the process via panic.
deserialize_optional_nonnull/deserialize_optional_nulloptanddeserialize_range_has_elementuse success to mean “this case matched / more elements remain”, andunexpectedto mean “this case did not match / no more elements”. The base class propagates the latter error when both optional paths fail; for ranges, a failedhas_elementends the loop and parsing continues at the tail.
Range Deserialization Flow
if (auto r = deserialize_range_head(); !r) return r; → abort and propagate on failure
while deserialize_range_has_element() succeeds:
spawn_subsource() → create child source
if (auto r = deserialize(subsource, elem); !r) return r; → recursively deserialize element
if (auto r = deserialize_range_element_end(); !r) return r;
if (auto r = deserialize_range_tail(); !r) return r;
Class Deserialization Flow
Symmetric with serialization, but deserialize_class_head() and deserialize_class_field() only return expected<void, E> (success/failure), not the parsed type name/field name — these strings serve only as validation in deserialization, with failure handling unified by the base class control flow.
The customizer receives a member reference and a child source, writes into the member in place, and returns expected; the base class checks that result and propagates it:
if (auto r = customizer_instance.deserialize(value.[:m:], subsource); !r) {
return r;
}
Annotations
| Annotation | Purpose |
|---|---|
[[=customized]] | Marks a class for customized serialization — only [[=field]] members participate |
[[=field]] | Marks a member to participate in serialization (only meaningful in [[=customized]] classes) |
[[=customize<C>]] | Specifies a field-level customizer C; the field’s serialization/deserialization is handled by C |
Annotations may be combined:
struct [[= customized]] S {
[[= field]] int normal = 1;
[[= field]] [[= customize<PlusThousand>]] int boosted = 100;
int skipped = 999; // no [[=field]], excluded from serialization
};
Customizer Concept
template<template<typename> typename Custr>
concept Customizer =
std::is_default_constructible_v<Custr<int>> &&
requires(Custr<int> c, int value, detail::TraitTargetSource &target) {
{ c.serialize(value, target) } -> std::same_as<void>;
{ c.deserialize(value, target) }
-> std::same_as<std::expected<void, typename detail::TraitTargetSource::error_type>>;
};
A customizer is a single-parameter template: template<typename T> struct MyCustomizer { ... }. The framework instantiates MyCustomizer<FieldType> for each field that uses the customizer.
serialize(const T &value, auto &target): writesvaluetotargetafter transformation, returnsvoiddeserialize(T &value, auto &source): reads the raw value fromsourceintovalueand writes the transformed value in place, returningstd::expected<void, typename Source::error_type>
deserialize must propagate errors from the deserialize_* calls it delegates to; the framework checks that return value and continues propagating it.
Implementing Source/Target and Customizer
Implementing TextTarget
The following is a Target implementation that serializes data into a human-readable text format (found in the unit test directory; for reference only):
class TextTarget : public SerializeTarget<TextTarget> {
public:
using target_type = ustr;
TextTarget() = default;
target_type deliver_result() { return std::move(m_result); }
// spawn_subtarget lets nested structures share the same output buffer
TextTarget spawn_subtarget() { return TextTarget{m_result}; }
void serialize_bool(const bool &value) {
m_result.append(value ? "true" : "false");
}
template<std::integral I>
void serialize_integral(const I &value) {
m_result.append(ustr::format("{}", value));
}
template<std::floating_point F>
void serialize_floating_point(const F &value) {
m_result.append(ustr::format("{}", value));
}
template<concepts::is_enum E>
void serialize_enum(const E &value) {
m_result.append(debug(value));
}
void serialize_optional_nonnull() { m_result.append("optional##"); }
void serialize_optional_nullopt() { m_result.append("optional##nullopt"); }
void serialize_range_head() { m_result.append("["); }
void serialize_range_element_end() { m_result.append(","); }
void serialize_range_tail(usize) { m_result.append("]"); }
void serialize_class_head(std::string_view ident) {
m_result.append(ustr::format("{}{{", ident));
}
void serialize_class_field(std::string_view ident) {
m_result.append(ustr::format("{}:", ident));
}
void serialize_class_field_end() { m_result.append(","); }
void serialize_class_tail(std::string_view) { m_result.append("}"); }
private:
TextTarget(ustr &external) : m_storage{std::nullopt}, m_result{external} {}
std::optional<ustr> m_storage{ustr{}};
ustr &m_result{*m_storage};
};
static_assert(SerializeTargetImpl<TextTarget>);
Key design points:
spawn_subtarget()shares the underlying buffer by reference; child target output is appended to the same string- Uses
ustr::formatto format numerics,debug()to format enums - The output format of each
serialize_*method must be consistent with the correspondingTextSourcedeserialize_*parsing logic
Implementing TextSource
class TextSource : public DeserializeSource<TextSource> {
public:
using source_type = ustr;
enum class error_type { mismatch };
TextSource() = default;
void provide_source(const source_type &source) {
m_source = source.view();
*m_cursor = 0;
}
TextSource spawn_subsource() { return TextSource{m_source, m_cursor}; }
std::expected<void, error_type> deserialize_bool(bool &value) { /* parse "true"/"false" */ }
template<std::integral I>
std::expected<void, error_type> deserialize_integral(I &value) { /* from_chars parse */ }
template<std::floating_point F>
std::expected<void, error_type> deserialize_floating_point(F &value) { /* from_chars parse */ }
template<concepts::is_enum E>
std::expected<void, error_type> deserialize_enum(E &value) { /* match enum item by name */ }
std::expected<void, error_type> deserialize_optional_nonnull() { /* consume "optional##"; succeed if not "nullopt" */ }
std::expected<void, error_type> deserialize_optional_nullopt() { /* consume "nullopt" */ }
std::expected<void, error_type> deserialize_range_head() { /* consume "[" */ }
std::expected<void, error_type> deserialize_range_has_element() { /* check if next char is "]" */ }
std::expected<void, error_type> deserialize_range_element_end() { /* consume "," */ }
std::expected<void, error_type> deserialize_range_tail() { /* consume "]" */ }
std::expected<void, error_type> deserialize_class_head() { /* consume "TypeName{" */ }
std::expected<void, error_type> deserialize_class_field() { /* consume "fieldName:" */ }
std::expected<void, error_type> deserialize_class_field_end() { /* consume "," */ }
std::expected<void, error_type> deserialize_class_tail() { /* consume "}" */ }
private:
TextSource(std::string_view source, usize *cursor)
: m_source{source}, m_cursor{cursor} {}
std::string_view m_source;
usize m_owned_cursor{0};
usize *m_cursor{&m_owned_cursor};
};
static_assert(DeserializeSourceImpl<TextSource>);
Key design points:
- All methods return
std::expected<void, error_type>: return{}on successful parse,unexpectedon format mismatch (no panic) spawn_subsource()shares a cursor: child source reads automatically advance the parent source’s positiondeserialize_optional_nonnull()consumes the"optional##"prefix then only peeks (does not consume)"nullopt"; if it is indeed nullopt, returnsunexpectedanddeserialize_optional_nullopt()consumes itdeserialize_class_head()anddeserialize_class_field()only returnexpected<void, error_type>, not the parsed type name/field name — these values are unused on the deserialization path
Implementing a Customizer
The following customizer adds 1000 to the value during serialization and subtracts 1000 during deserialization:
template<typename T>
struct PlusThousand {
void serialize(const T &value, auto &target) const {
target.serialize_integral(value + 1000);
}
auto deserialize(T &value, auto &source) const
-> std::expected<void, typename std::remove_cvref_t<decltype(source)>::error_type> {
if (auto r = source.template deserialize_integral<T>(value); !r) {
return r;
}
value -= 1000;
return {};
}
};
static_assert(Customizer<PlusThousand>);
Usage:
struct S {
[[= customize<PlusThousand>]] int score = 42;
};
A customizer must satisfy:
- It is a single-parameter template (
template<typename> struct) serializereceivesconst T &and a target reference, returnsvoiddeserializereceivesT &and a source reference, returnsstd::expected<void, typename Source::error_type>, and updates the value in placedeserializeinternally calls the source’s value-writing method (e.g.,deserialize_integral) to obtain the raw value, and propagates that error unchanged
Usage Examples
// Serialization
ustr text = serialize<TextTarget>(my_object);
// Deserialization (construct new value, returns expected<T, error_type>)
auto result = deserialize<MyStruct, TextSource>(text);
if (result.has_value()) {
// use *result
}
// Deserialization (write into existing variable, returns expected<void, error_type>)
MyStruct obj;
auto ok = deserialize<MyStruct, TextSource>(text, obj);
// Deserialization (operate directly on source)
TextSource src;
src.provide_source(text);
auto ok = deserialize(src, obj);
type_mutate — Runtime Type-Mutable Base Class
jungle::util::type_mutate<T> is a CRTP base class that provides compile-time-safe runtime type querying and downcasting for derived classes. It does not depend on RTTI, instead using type_id for precise type identity comparison.
Design Intent
In scenarios like ECS, Component<> and Manager<> need to flow through the system as untyped base class references, while also needing to be safely recovered to concrete derived types at runtime. type_mutate solves this by storing a type_id in each instance and providing cast methods with compile-time constraints.
Core Mechanism
Every derived class must define a static member template static_mutatable, declaring which types are valid variants of that derived class:
template<>
class Component<> : public util::type_mutate<Component<>> {
public:
template<typename C>
static constexpr bool static_mutatable = ComponentImpl<C>;
// ...
};
type_mutate internally references this constraint via requires clauses:
template<typename U>
requires static_mutatable<U>
constexpr bool is() const { ... }
This means the compiler rejects queries against invalid types at compile time — if you try base.is<WrongType>(), the compiler will report an error rather than silently returning false at runtime.
Construction
protected:
constexpr type_mutate(type_id type)
: m_type{type} {}
The constructor is protected, callable only by derived classes. Derived classes must pass the correct type_id during construction (typically type_id::of<DerivedType>()).
API
| Method | Description |
|---|---|
is<U>() const -> bool | Whether the current instance is of type U. Compile-time requires U satisfies static_mutatable |
is(type_id) const -> bool | Whether the current instance’s type ID matches the given type_id (no compile-time type constraint) |
as<U>() -> U & | Downcast to U&. JUNGLE_ASSERT(is<U>()), checked in Debug mode only |
as<U>() const -> const U & | const overload |
try_as<U>() -> U * | Safe downcast; returns nullptr on type mismatch |
try_as<U>() const -> const U * | const overload |
type() const -> type_id | Returns the type ID stored in the current instance |
is
// Compile-time type-checked version — U must satisfy static_mutatable
template<typename U>
requires static_mutatable<U>
constexpr bool is() const;
// Unconstrained version — accepts any type_id, suitable for data-driven type comparison
constexpr bool is(type_id type) const;
The former is used for type-safe compile-time dispatch; the latter is for scenarios requiring runtime type_id comparison (e.g., reading type identifiers from configuration or network).
as
template<typename U>
requires static_mutatable<U>
constexpr U &as();
as<U>() performs a downcast. In Debug mode JUNGLE_ASSERT(is<U>()) verifies that the type matches and triggers panic() on mismatch; in Release mode the assertion is removed entirely and no check is performed.
Typical usage:
void process(Component<> &base) {
if (base.is<HealthComponent>()) {
auto &health = base.as<HealthComponent>();
health.hp -= 10;
}
}
try_as
template<typename U>
requires static_mutatable<U>
constexpr U *try_as();
try_as<U>() is the safe, non-panicking version — returns nullptr on type mismatch. Suitable for cases where type matching cannot be guaranteed in advance or assertion panics are undesirable:
void maybe_process(Component<> &base) {
if (auto *health = base.try_as<HealthComponent>()) {
health->hp -= 10;
}
// or:
// auto *health = base.try_as<HealthComponent>();
// if (health) { ... }
}
The const overloads behave identically, returning const U * / const U &.
type
type_id type() const;
Directly returns the stored type ID, primarily used for logging, debugging, or type routing.
Relationship with Component<> and Manager<>
type_mutate has two primary consumers in Jungle ECS:
Component<>: all concrete Components inherit from this;static_mutatableis constrained to types satisfying theComponentImplconceptManager<>: all concrete Managers inherit from this;static_mutatableis constrained to types satisfying theManagerImplconcept
This allows the system to operate uniformly on Component<> & or Manager<> & across different types, and safely recover concrete types when needed.
Type Safety Tiers
| Tier | Mechanism | Failure Behavior |
|---|---|---|
| Compile-time | static_mutatable constraint | Compilation error |
| Debug | JUNGLE_ASSERT(is<U>()) | panic() aborts process |
| Release | No check (assertion removed) | Undefined behavior |
| Runtime-safe | try_as<U>() returns nullptr | Caller checks null pointer |