Add a first-class get-or-create operation to the single-component handle: given an EntityId, return the existing component's interior pointer, or insert a new one and return its pointer.
Proposed API
Two variants on Accessor[A], mirroring the eager/lazy split common in the ecosystem:
// GetOrAdd returns the interior pointer to the entity's component, adding the
// provided value first if the component is absent.
func (Accessor[A]) GetOrAdd(id EntityId, value A) *A
// GetOrAddFunc returns the interior pointer to the entity's component, adding a
// value built by make first if the component is absent. make is called only when
// the component does not already exist, so no value is constructed on the hit path.
func (Accessor[A]) GetOrAddFunc(id EntityId, make func() A) *A
Both return a single *A (no bool): the result is never nil and the "did it already exist" distinction is exactly what get-or-create exists to paper over. Like Get and iteration, the returned pointer is valid until the next structural change to that store.
Motivation
The migration of nrg to this module (trancecode/ecs, tracked in herve-quiroz/nrg#449) collapses nrg's XxxComponent(id, create bool) accessors into native handle calls. The create == false path is already exactly Get. The create == true path is get-or-create, and with only Get/Add available it expands at each site to the same boilerplate (because Add takes a value and the handle hands back interior pointers, it is get → add → re-get):
comp, ok := positions.Get(id)
if !ok {
positions.Add(id, Position{})
comp, _ = positions.Get(id)
}
An audit in nrg#449 narrowed the genuine get-or-create sites to 4 (the rest are provably-fresh entities that become plain Add calls): a component that churns in and out across frames, a component shared across repeated idempotent setter calls, and idempotent public setters that must fetch an existing component rather than duplicate it. GetOrAdd/GetOrAddFunc collapse each back to one line returning the interior pointer.
Prior art
Get-or-create returning a mutable handle is the mainstream answer across ECS frameworks — not bare upsert, precisely because callers want to mutate the existing-or-new value in place:
- EnTT (C++) —
registry.get_or_emplace<T>(entity, args…) returns a reference, constructing only if absent.
- flecs (C/C++) —
entity.ensure<T>() (formerly get_mut) returns a mutable pointer, adding a default if absent.
- Bevy (Rust) —
entity.entry::<T>().or_insert_with(f) / .or_insert(default), modelled on HashMap::entry.
- Unity has no built-in equivalent, so
GetOrAddComponent is one of the most frequently hand-rolled extension methods in its ecosystem — reinvented precisely because it is missing.
The eager/lazy pair also matches Go's own precedent: GetOrAdd parallels sync.Map.LoadOrStore (eager value), and GetOrAddFunc parallels Bevy's or_insert_with (lazy construction, so no default is built on the hit path).
Scope notes
- Single-component handle (
Accessor[A]) only. The join handles (Accessor2/Accessor3) are read/iterate-oriented and out of scope here.
- Additive: no change to existing
Get/Has/Add/Remove/All semantics.
- Should honour the same deferred-structural-change rules as
Add (immediate outside iteration, deferred-and-auto-flushed during an All() loop), so the returned pointer stays valid for the rest of the loop.
- Worth a short addition to the README's "API at a glance" once implemented.
✨ Content generated by Claude AI.
Add a first-class get-or-create operation to the single-component handle: given an
EntityId, return the existing component's interior pointer, or insert a new one and return its pointer.Proposed API
Two variants on
Accessor[A], mirroring the eager/lazy split common in the ecosystem:Both return a single
*A(nobool): the result is never nil and the "did it already exist" distinction is exactly what get-or-create exists to paper over. LikeGetand iteration, the returned pointer is valid until the next structural change to that store.Motivation
The migration of
nrgto this module (trancecode/ecs, tracked in herve-quiroz/nrg#449) collapsesnrg'sXxxComponent(id, create bool)accessors into native handle calls. Thecreate == falsepath is already exactlyGet. Thecreate == truepath is get-or-create, and with onlyGet/Addavailable it expands at each site to the same boilerplate (becauseAddtakes a value and the handle hands back interior pointers, it is get → add → re-get):An audit in nrg#449 narrowed the genuine get-or-create sites to 4 (the rest are provably-fresh entities that become plain
Addcalls): a component that churns in and out across frames, a component shared across repeated idempotent setter calls, and idempotent public setters that must fetch an existing component rather than duplicate it.GetOrAdd/GetOrAddFunccollapse each back to one line returning the interior pointer.Prior art
Get-or-create returning a mutable handle is the mainstream answer across ECS frameworks — not bare upsert, precisely because callers want to mutate the existing-or-new value in place:
registry.get_or_emplace<T>(entity, args…)returns a reference, constructing only if absent.entity.ensure<T>()(formerlyget_mut) returns a mutable pointer, adding a default if absent.entity.entry::<T>().or_insert_with(f)/.or_insert(default), modelled onHashMap::entry.GetOrAddComponentis one of the most frequently hand-rolled extension methods in its ecosystem — reinvented precisely because it is missing.The eager/lazy pair also matches Go's own precedent:
GetOrAddparallelssync.Map.LoadOrStore(eager value), andGetOrAddFuncparallels Bevy'sor_insert_with(lazy construction, so no default is built on the hit path).Scope notes
Accessor[A]) only. The join handles (Accessor2/Accessor3) are read/iterate-oriented and out of scope here.Get/Has/Add/Remove/Allsemantics.Add(immediate outside iteration, deferred-and-auto-flushed during anAll()loop), so the returned pointer stays valid for the rest of the loop.✨ Content generated by Claude AI.