Writing a Component
- The smallest one that works
- What the Inspector can draw
- Attributes
- Pointing at another entity
- Registration
- What survives a save
A component is a plain struct that derives Reflect and implements Component. That is the
whole contract — the Inspector, scene serialisation and the Add Component menu all follow from
the derive.
The smallest one that works
#![allow(unused)]
fn main() {
use kooch::kooch_ecs::Reflect;
use kooch::kooch_ecs::component::Component;
/// How much damage this entity can still take.
#[derive(Default, Reflect)]
pub struct Health {
pub current: f32,
pub max: f32,
}
impl Component for Health {}
}
Create it from the editor, which drops this scaffold in src/ — or write the file yourself
in any editor. Either way the registrations regenerate on their own: the editor polls src/
and rewrites them within a second of a save.
⚠️ Regenerating is not rebuilding. The Inspector lists what your project’s last build
contained, so a component added a moment ago is in registrations.rs and in no binary yet.
The toolbar’s Resync button pulses while the two disagree.
Public fields show up in the Inspector automatically. No attribute is required to opt in; attributes exist to opt out, or to say something the type alone cannot.
What the Inspector can draw
Each field’s Rust type maps to a FieldKind, and the kind decides the widget:
| Rust type | Widget |
|---|---|
f32, f64 | Drag value |
u8…u64, i8…i64 | Drag value, clamped to the type |
bool | Checkbox |
String | Text field |
Vec2, Vec3, Vec4 | Component-wise drag values |
Quat | Euler angles, in degrees |
Mat4 | Decomposed to translation / rotation / lossy scale, read-only |
Option<Guid> + #[reflect(asset = "…")] | Typed asset picker |
Option<EntityRef> | Entity picker, and a drop target for a drag from the World panel |
Entity, Option<Entity> | Same widget, but see “Pointing at another entity” below |
A struct that also derives Reflect | Nested, drawn inline |
The maths types come from the prelude.
Vec3,QuatandMat4areglamtypes, andkooch::preludere-exports them so a project never declares its ownglamdependency — which is the point, since aQuatfrom a different version is a different type and the compiler error would name two types spelled identically.
Hover a field name in the Inspector to see its doc comment. The derive harvests
///straight off the field, so documenting a component for the next reader also documents it for whoever is authoring the scene — there is no second place to write it and no second place for it to go stale.
Anything outside that list — Vec<T>, HashMap<K, V>, your own enums — is not supported
yet. Recursive reflection for nested types and collections is
#649. Until it lands, a field of an
unsupported type needs #[reflect(skip)] or the derive will not compile.
Attributes
On the struct
#![allow(unused)]
fn main() {
#[derive(Default, Reflect)]
#[reflect(category = "Gameplay")] // groups it in the Add Component menu
#[reflect(inspector = "read_only")] // "hidden" | "read_only" | "editable" (default)
pub struct Health { … }
}
On a field
#![allow(unused)]
fn main() {
#[derive(Default, Reflect)]
pub struct Weapon {
/// Not shown, not serialised. Use for runtime caches and for types
/// the Inspector has no representation for.
#[reflect(skip)]
cached_target: Option<Entity>,
/// A typed asset picker instead of a raw Guid text field.
#[reflect(asset = "Mesh")]
pub projectile: Option<Guid>,
/// A dropdown of named values instead of a bare integer.
#[reflect(choices = FIRE_MODE_CHOICES)]
pub fire_mode: u32,
/// A row of checkboxes instead of a bitmask you compute in your head.
#[reflect(bits = DAMAGE_TYPE_BITS)]
pub damage_types: u32,
/// Only drawn when another field says it is relevant.
#[reflect(shown_when = BURST_ONLY)]
pub burst_count: u32,
/// A reference the picker will only let you point at an entity
/// carrying a `PhysicsBody`.
#[reflect(requires = "PhysicsBody")]
pub anchored_to: Option<EntityRef>,
}
}
choices, bits and shown_when take a path to a constant, not a string literal — so the
same table is used by the Inspector and by your code, and they cannot drift apart.
shown_when is what keeps a component with many mutually-exclusive fields readable: the
engine’s own Joint uses it so a hinge does not show you spring stiffness.
requires names a component, by its short name, that the target has to carry. The picker
filters by it and refuses a drop that fails it, saying why — a reference accepted but inert
is indistinguishable from a broken one.
Pointing at another entity
Use Option<EntityRef>.
#![allow(unused)]
fn main() {
use kooch_ecs::reflect::EntityRef;
#[derive(Default, Reflect)]
pub struct Turret {
pub target: Option<EntityRef>,
}
}
Three things assign it, and all three write the same value: your code
(turret.target = Some(EntityRef::live(entity))), the Inspector’s picker, and dragging an
entity from the World panel onto the field.
EntityRef is two states, because a reference means two different things depending on where
it lives:
Live— an index and a generation. What a running component holds, and whatEntityRef::entity()gives you back for a query or a lookup.Persistent— an identity that survives a reload. What a scene file holds.
You do not convert between them. Saving resolves live to persistent, loading resolves back,
and a reference whose target’s scene is not open stays persistent until it is — which is why
the field is Option<EntityRef> and not Option<Entity>. An Entity field has nowhere to
put an unresolved reference, so it loses the link instead of keeping it.
Entity and Option<Entity> still reflect, for a handle the engine resolves itself
(Parent is one). They refuse to store anything but a live reference.
Registration
You do not write it. The editor scans src/, finds impl Component for Health, and
regenerates registrations.rs with both halves:
#![allow(unused)]
fn main() {
// Registers the type with the running ECS — scene save/load and the Inspector.
registry.register_cpu_reflected::<Health>();
// Describes the type to a standalone editor that loaded this dylib.
declare_component::<Health>(engine);
}
The component’s name comes from std::any::type_name::<T>(), so there is exactly one name
for a type and no way for two sides to disagree about it.
What survives a save
A component is saved as its reflected fields. Two consequences worth knowing before you design a component:
#[reflect(skip)]fields are not saved. They are reconstructed by your code, or they are gone.- A reference to another entity is saved as an identity, not as a handle. The save path
resolves it and assigns the target a persistent id if it has none, which is why saving a
scene can modify the world. Nothing is asked of you beyond using
Option<EntityRef>; a handle reaching a file is refused by name, and the save fails rather than writing a reference that would load pointing at some other entity.