Skip to content

Anatomy of an entity

Every other page in this section describes one system. This one describes the thing all of them hang off: the entity. Read it first if you have ever wondered why R.hp and R.stat reach into completely different places, or why a stat you successfully changed still displayed as 0.

A hero, a gnoll, a barrel, a shrine, a dropped item and the boss are all the same C++ class: oCEntity — class id 0x05146457, 0x640 bytes.

What is surprising the first time you look at one is how little an entity holds. There is no health field on it, no damage, no name, no faction. It owns a transform, a few links to the world around it, and a bag of components:

Offset What it is
+0x08 owner / back-pointer used by components (component+0x08 is the owning entity)
+0x28 the definition it was built from — the template, see below
+0x30 the scene it belongs to
+0x38 the spawner that placed it
+0x208 the on-spawned functor copied from its spawn data
+0x324 position (rotation and scale follow)
+0x500 the event pool its components register listeners on
+0x5e8 / +0x5f0 / +0x600 the component map — control bytes, slots, mask

Everything a thing in Ravenswatch can do is a component in that map. An entity with a HitPoint component can be killed; one without it cannot be damaged at all, no matter what the attack does.

Mods almost never touch a live entity. They edit the definition — the authored, cooked template — and let the engine build instances from it.

flowchart LR
  A["Cooked file<br/>(enemydef, entity, herodef)"] --> B["oCEntitySettings<br/>the template<br/>0x1e8 bytes"]
  B --> C["EntityStore_CreateEntity"]
  C --> D["oCEntity instance<br/>template kept at +0x28"]
  D --> E["components activate"]

EntityStore_CreateEntity (FUN_1406f5dc0) is the one spawn primitive, proven in game on 2026-09-13: give it a spawner, a template and a spawn-data record and it returns a live entity that the engine then treats as its own. R.spawn is built directly on it.

This split is why Enemies notes that HP, damage and move speed are not on the enemy definition: oCDtEnemyDefinition spends its 0x350 bytes on tags, tier curves and resource references, and the actual stat block lives on the oCEntitySettings it points at. Change the numbers by editing the referenced entity, not the enemydef.

There are 188 component classes in the shipped registry (data/class_ids.json, mined by tools/mine_class_ids.py). They follow a naming convention that is worth learning, because it tells you where a value lives before you open a disassembler:

Suffix Count Meaning
(none) 188 the live component — runtime state
…Settings 176 the authored data it is built from — this is what a mod edits
…NetworkData 17 the slice that replicates to other players
…PersistentData 14 the slice that survives a chapter transition

So oCEntityCpntHitPoint is the live health component, …HitPointSettings is the starting values a designer typed in, and …HitPointPersistentData is what carries across a chapter. When you are hunting for “where is the note count stored”, the PersistentData list is only 14 entries long and is the right place to look first.

The components that matter most:

Component Class id Size Role
oCEntityCpntHitPoint 0x0fdffbf9 0x238 current and max health. Every damageable entity has one, enemies included.
oCDtEntityCpntCharacterController 0x1560fd89 0xba0 the hub: links to the HitPoint component and to the entity-value store
oCDtEntityCpntHeroController 0x155aac59 0x1e48 the hero brain — abilities, dream shards, HUD, per-hit log
oCDtEntityCpntEnemyController 0x1561073c 0x118 the enemy counterpart, and how R.damage tells an enemy from a prop
oCDtEntityCpntHittable 0x15596096 0x2b0 the receiving half of the hit pipeline
oCEntityCpntNetwork — — replication identity (RakNet under the hood)

The engine never reads a component from a fixed offset. It asks the class:

  • Entity_GetComponentFast (FUN_1406e31a0) — resolve through the type→index hash map, then confirm with a virtual IsKindOf. Needs the 32-bit class key.
  • Entity_GetComponentByTester (FUN_1406e3210) — linear scan, confirming each candidate against a type tester. Use this when the key is unknown.

This is the part that catches everyone. There is no single “stat block”. A gameplay quantity lives in one of three places, and they are updated at different times by different code.

flowchart TD
  D["definition<br/>(authored base values)"] --> S
  M["modifiers<br/>(items, talents, melodies)"] --> S
  S["2 · entity-value store<br/>keyed, folded on recompute"] --> P
  P["1 · plain field on a component<br/>the hot path the game reads"] --> C
  C["3 · display cache<br/>what the UI prints"]

1 — A plain field on a component. The hot path. Health is a float at hitpoint+0xe8 with its maximum at +0xec. Dream shards are a float at heroController+0x15c8. These are read every frame by gameplay code, so they are plain, unkeyed and fast. A mod reads and writes them through R.hp and R.shards.

2 — The entity-value store. Almost everything else: attack power, cooldown reduction, status-effect stacks, the hundreds of item and talent effects. Each value is addressed by a 32-bit key, and the stored number is computed: EntityValueStore_Recompute seeds each key from the definition’s base value and then folds in every registered modifier in priority order. See Entity values for the layout and Stats & XP for the 221-key catalog.

3 — A display cache. The in-run stat strip does not read the store. It reads three floats out of a cached report object, refreshed only when the engine folds a modifier. This is exactly why a working attack-power change once showed 0 on the strip: the store had the new number, the damage code used it, and the cache nobody had refreshed still held the old one. R.stat.cached reads that cache so a mod can see what the player sees.

Which to use:

Quantity Lives in Read / write it with
Health, max health HitPoint component R.hp.get / set / heal / damage
Dream shards hero controller field R.shards.get / add / spend
Attack power, cooldowns, ~200 more entity-value store R.stat.get / R.stat.modify
Status stacks (ignite, chilled, …) entity-value store R.stat with a status_* key
Armour nowhere addressable not exposed — see below
What the stat strip prints display cache R.stat.cached
Experience XP component R.xp

The chain, measured in game on 2026-09-18:

heroController + 0x2f8 -> oCDtEntityCpntCharacterController
... + 0x78 -> oCEntityCpntHitPoint
... + 0xe8 = current HP (f32)
... + 0xec = max HP (f32)
... + 0x08 = the owning oCEntity

Note that +0x2f8 is the character controller, not the entity — and the same object carries the entity-value store at +0x4c8. One hop serves both systems.

HitPoint_SetHitPoints (FUN_140822db0) is the real setter, and it does more than store a float: it clamps to [0, max], fires the death listeners at +0x108 when the value crosses zero, fires the change listeners at +0xf0, replicates through the entity’s net component, and writes current and max into the UI bar. Thirteen gameplay readers take exactly this path and compute +0xe8 / +0xec as a fraction — “increase damage at critical health” is one of them.

R.hp re-checks the link on every access: the RTTI of the component must name a HitPoint class, and component+0x08 must equal the hero’s entity. R.hp.diagnose prints the whole walk with class names when something looks wrong.

Damage is not a number you set. It is a hit object that travels a pipeline:

flowchart LR
  A["attacker acts"] --> B["Entity_ResolveAttackHits<br/>builds oCEntityHitData inline"]
  B --> C["Entity_DispatchHit(target, hit)"]
  C --> D["target's Hittable component"]
  D --> E["HitPoint_SetHitPoints"]
  E --> F["listeners + replication + UI"]

The amount is not a plain float on the hit: it lives in a hit-value object hanging off hitData+0xa0, as a float at +0x08. The resolver returns that same number, which is how R.damage reads every hit without touching the struct at all.

There is no low-arity “deal N damage to entity E” function, and no standalone constructor for the hit data — the struct is built inline inside the resolver. Fabricating one means inventing a hit-value object with the right vtable, a valid refcounted handle, the source net id, and the position/normal block; any mistake corrupts the pipeline or desyncs multiplayer. The path that works is to ride an attack the hero already made. The read-only half of that ships today as R.damage. Full detail, including the corrected layout, is in Combat & damage.

They are the same class with different components. What actually differs:

Hero Enemy
Controller HeroController (0x1e48 bytes) EnemyController (0x118 bytes)
Health HitPoint component HitPoint component — the same one
Entity values full store, hundreds of keys store present, far fewer keys
HUD mirror +0x1d80, local player only none
“Is this me” byte at +0x1d88 n/a
Damage accounting HeroStats_OnDamageDealt / …Taken n/a
Authored by oCDtHeroDefinition (no class UID — Heroes) oCDtEnemyDefinition + tribe + tier

The HUD mirror at +0x1d80 is the single most useful discriminator in the whole engine: only the local player’s hero controller has one, so its presence is how the SDK decides “this is the hero I am playing” rather than an ally or a remote player.

The asymmetry in damage accounting is netcode, not an oversight. HeroStats_OnDamageDealt fires for every hero in the session, local or remote, which is what makes a co-op damage meter possible without touching the netcode. …OnDamageTaken does not: a hit on an ally is applied on that ally’s machine, so a remote player’s “taken” is always zero locally.

You do not need Ghidra to answer “what is this thing”. From a mod, on the main thread:

local e = R.entity.hero() -- the hero controller, once captured
R.log(R.rtti.name(e)) -- class name straight from RTTI
R.log(R.hp.diagnose()) -- the whole health walk, with class names
R.log(R.hp.get(), R.hp.max(), R.hp.frac())
for _, c in ipairs(R.entity.components(e) or {}) do
R.log(("%x %s"):format(c.type_id or 0, R.rtti.name(c.ptr)))
end
R.log(R.stat.get("attack_power")) -- from the value store
R.log(R.stat.cached("attack_power")) -- what the strip prints

R.debug.dump(ptr) prints a window of an object with pointers, floats and strings identified; R.debug.find_arrays(obj) and R.debug.strings(obj) turn what used to be a multi-launch struct hunt into one launch. R.defs.classes() enumerates every loaded definition class.

For “who wrote this byte”, R.watch.on(va, {len=4}) arms a hardware watchpoint and R.watch.report() names the writer — four slots in the whole CPU, per thread, and the address must be aligned to its length. It needs RSMM_ENABLE_WATCH=1 and is a debug tool only.

  • heroController+0x15c8 is dream shards, not HP. It was documented as health until 2026-09-18 and the misreading reached a shipped symbol name. Real health is the HitPoint chain above.
  • The stat strip reads a cache. A store write that the game honours can still print as 0. Check with R.stat.cached.
  • A store poke is transient. Durable changes must be modifiers.
  • +0x190 is the spawner-go component array, not the entity’s.
  • Armour is unaddressable (key 0). Use the armour effects instead.
  • The definition, not the instance, is what a mod should be editing in almost every case.
  • Entity values — the keyed store’s read path and layout.
  • Stats & XP — the 221-key catalog, delivery routes, the strip cache.
  • Combat & damage — the hit pipeline in full.
  • Enemies — definitions, tribes and camp spawning.
  • Heroes — why a hero definition is the hardest kind.
  • Spawn system — creating an entity at runtime.
  • Event systems — the bus that component changes fire on.