Entities, handles and the ECS
If you come from Paper, this is the biggest mental shift. In Bukkit a Player or Zombie is an object with methods and a lifetime managed by the JVM. In Ramjet the world is an ECS (flecs): an entity is only an id, its data lives in components stored in flat arrays, and behaviour lives in systems that run over those arrays. That layout is why the server can keep thousands of players on one process, and it shapes the plugin API.
Your plugin reaches the ECS through Ecs (plugin.Ecs): your own components, queries and bare entities. Game objects have typed handles of their own: Player, World and Entity.
Entities are handles
Section titled “Entities are handles”Entity is a 64-bit id (ulong Id) wrapped in a struct. It is blittable, so it can sit in an event, a component or a collection at no cost, and holding one never keeps anything alive.
Entity e = player.Entity; // a Player wraps its entityif (e.IsNull) return; // default / "no entity" (e.g. no attacker for fall damage)if (!e.IsAlive) return; // destroyed? (one native call)e.Set(new Mana { Value = 20 });e.Destroy(); // only for entities made with Ecs.Spawn(); after this the handle is staleA handle stays valid across component changes (the id never changes when you add or remove components). When the entity is destroyed the handle goes stale: Has<T>() is false and Get<T>() throws, rather than touching the wrong entity, because ids carry a generation. A Player handle that goes stale is safer still: its reads return defaults and its actions do nothing (player.IsOnline is false). A player who rejoins gets a new handle.
| Paper / Bukkit | Ramjet |
|---|---|
Player player object, player.getHealth() |
Player handle: player.Health, player.Position, player.SendMessage(...) |
player.setMetadata("mana", ...) / PersistentDataContainer |
your own component: player.Set(new Mana { ... }) |
UUID lookups |
player.Uuid; Server.GetPlayer(Uuid) |
player.isOnline() |
player.IsOnline; for any entity, entity.IsAlive |
Components: plain data you own
Section titled “Components: plain data you own”A component is an unmanaged struct (no references, no string, no arrays) that you declare and register under a global key:
public struct Mana { public int Value; public int Max; }
protected override void OnEnable(){ Ecs.RegisterComponent<Mana>("mymana:mana"); // key: "plugin:name"}- The key is global and stable. Registering the same key with the same layout (field types and offsets) after a hot reload keeps every entity’s values. A changed layout drops the values, and is refused while another plugin is also bound to the key (see hot reload).
- A component type must be owned by one plugin. The API caches one id per CLR type for the whole process, so do not register a shared type (such as
Vec3d) under different keys from different plugins; a second binding throws. - Strings and variable data do not fit in an unmanaged struct. Keep them in a side dictionary keyed by
Entity(clean it up when the entity dies) or write them to disk, and keep only a numeric id in the component. - Tags are empty structs:
public struct Frozen { },entity.Set(new Frozen()),entity.Has<Frozen>(). - Components belong to their entity. Entities made with
Ecs.Spawn()survive a reload of your plugin; the component bindings and queries do not.
Reading and writing:
| Call | Meaning |
|---|---|
entity.Set(new T { ... }) |
add or overwrite a copy |
entity.Has<T>() |
present? |
entity.TryGet<T>(out var copy) |
copy out; false when missing |
ref var c = ref entity.Get<T>() |
in-place access; throws when missing |
ref var c = ref entity.GetOrAdd<T>() |
in-place access, adding a zeroed component first |
ref var c = ref entity.TryGetRef<T>() |
a null ref when absent: test with Unsafe.IsNullRef(ref c) |
entity.Remove<T>() |
remove |
Player has the same members (Get, TryGet, Has, Set, Remove) for your components. A component that the engine registers under its own name can also be bound, if the layout matches. Writes through that binding change only a copy, so engine state is changed through the typed API instead.
The rule that bites: refs die on structural change
Section titled “The rule that bites: refs die on structural change”A ref T from Get, GetOrAdd, TryGetRef or a query column points into the table that stores the entity. It is valid only until the next structural change touching that table: adding or removing a component on any entity of the same archetype, destroying one, or creating one that lands there. Other code (a system, an event handler, another plugin) can make such a change between two lines of your code, so a ref you keep can dangle.
// WRONG: the ref may dangle after Set<Frozen> moves the entity to another tableref var mana = ref player.Get<Mana>();player.Set(new Frozen());mana.Value--; // undefined
// RIGHT: copy what you need, or re-fetch after the structural changeint value = player.Get<Mana>().Value;player.Set(new Frozen());player.Get<Mana>().Value = value - 1;Practical habits:
- Keep the
Entity(orPlayer) in your data structures, never theref; re-fetch when you need it. - Take the value out first when you must call other APIs in between.
- Do not add, remove or destroy components of the entities you are iterating in a query loop. Collect the entities first and change them after the loop.
- Register components in
OnEnableand add them at spawn or join time, so a laterSetdoes not move the entity.
Queries
Section titled “Queries”A query is a cached, indexed view over all entities that have a set of components. Create it once with Ecs.Query<...>() and iterate it every tick. The query belongs to your plugin and is disposed when the plugin unloads.
public struct Mana{ public int Value; public int Max;}
[PluginInfo("ManaPlugin", Version = "1.0.0")]public sealed class ManaPlugin : Plugin{ private Query<Mana> _mana = null!;
protected override void OnEnable() { Ecs.RegisterComponent<Mana>("mymana:mana"); _mana = Ecs.Query<Mana>(); // up to three components: Query<T1, T2, T3> Systems.Add("regen", Phase.Tick, _ => Regen()); }
private void Regen() { foreach (var chunk in _mana) // one chunk per archetype: a Span<T> per component { var mana = chunk.Field<Mana>(0); // term index = order of the type arguments for (int i = 0; i < mana.Length; i++) if (mana[i].Value < mana[i].Max) mana[i].Value++; } }}chunk.Entities gives the entity handles in the same order as the columns, and chunk.Count the number of rows. Leaving the foreach early releases the native iterator. For the convenient form, a little slower:
_mana.Each((Entity entity, ref Mana m) => { if (m.Value > m.Max) m.Value = m.Max; });Count() iterates the query and returns the number of matching entities.
Bare entities that are not game objects come from Ecs.Spawn() (no position, never sent to clients). Ecs.GetEntity(ulong id) wraps an id you stored.
Systems
Section titled “Systems”A system is a callback the engine runs once per tick in a chosen phase: Systems.Add(name, Phase.Tick, callback). See systems and phases.
Game entities
Section titled “Game entities”Players, mobs, items and projectiles are also entities. For a game entity Entity offers:
TypeKey("minecraft:zombie"),IsGameEntity, andTryGetLocation(out Location)orLocation;Teleport(Location)for mobs, items and the like (players usePlayer.Teleport, which fires their teleport event);Despawn()(no death logic) andKill()(the/killpath, with death events and drops);GetNbt()andSetNbt(data, merge), the data/data get entityshows.
Destroy() is only for entities you made yourself. On a game entity it is ignored with a warning. Players cannot be despawned: use Player.Kick.
What is not exposed
Section titled “What is not exposed”Engine state (a player’s health, position, inventory) is not a set of raw components you can query. It is read and changed through typed handles: Player (Health, Position, GameMode, Inventory, …) and World (blocks, entities, time and weather). Those members look the entity up on each call, so a plugin never holds a pointer into an engine table. The entity reference and the player reference list every member.