View on GitHub

FOnline Engine

Flexible cross-platform isometric game engine

Entity Model

This document explains the reusable runtime entity model: entity type descriptors, generated property accessors, prototype entities, inner-entity ownership, entity events, and the property storage model that other runtime systems build on.

Use it when changing Source/Common/Entity.*, EntityProperties.*, EntityProtos.*, Properties.*, PropertiesSerializator.*, ProtoManager.*, metadata annotations, or code that persists/synchronizes entity state.

For how entity create/destroy/register stays consistent when an exception is thrown mid-operation — the terminate-on-OOM allocation model, the lifecycle throw-as-signal contract, and the post-mutation FO_STRONG_ASSERT policy — see ExceptionSafety.md.

Ownership model

The engine owns the entity runtime and metadata/property mechanics. An embedding game project owns concrete prototype files, content IDs, scripts, and gameplay rules that use those mechanics.

Keep this document focused on reusable engine behavior. Put project-specific item/critter/map/location definitions and balancing notes in the embedding project’s docs.

Runtime entity types

Source/Common/Entity.h declares the core entity taxonomy through ///@ ExportEntity annotations:

EntityTypeDesc stores metadata discovered/generated for each entity type:

For generated metadata and registration flow, see GeneratedApiAndMetadata.md.

Entity base class

Entity is the shared base for all runtime/prototype entities. It owns:

Important accessors and mutation paths include:

Do not bypass Properties when changing entity state. Property callbacks, overlay data, sync flags, persistence flags, and script-visible accessors depend on the property layer seeing the mutation.

Generated property wrappers

FO_ENTITY_PROPERTY(type, Name) expands into a small typed accessor surface:

Source/Common/EntityProperties.h defines the generated property wrapper classes:

EntityProperties itself contributes common persistent fields:

The generated wrapper classes are thin over Properties; the real storage, type information, sync/persistence flags, callbacks, and serialization decisions live in Property, Properties, and PropertyRegistrator.

Server-side AngelScript property getters copy non-virtual raw property data through Properties::CopyRawData() before converting it to script values. Properties serializes only the raw buffer copy/write window; property setter and post-setter callbacks run outside that storage lock so event dispatch, reparenting, and destruction do not inherit a property-buffer lock.

Typed and script-facing property assignment rejects non-finite floating-point leaves before storage, including values nested in arrays, structs, and dictionary keys or values. The same validation runs again after setter callbacks mutate raw data, and document/text serialization rejects non-finite values if trusted binary restore or native code supplied a corrupted payload.

Property raw data storage is naturally aligned: the storage blob and PropertyRawData buffers start max-aligned, struct layout registration enforces field-offset alignment, and overlay/pod offsets follow each property’s data alignment. Property readers therefore use plain typed loads with no unaligned-access shims or runtime alignment checks — sanitizer builds are the guard that flags any path violating the alignment contract. Raw payload equality is bytewise (MemCompare): the total byte length of a payload does not raise its alignment requirement.

Property runtime

Source/Common/Properties.h defines four central pieces:

Property flags are load-bearing:

When changing property metadata, update runtime docs and script/nullability docs together if the change affects script-visible signatures. See Nullability.md.

Base properties and overlays

A Properties instance can have base properties. This is used heavily by prototype-derived runtime entities:

This means a runtime entity is not simply a flat map from property name to value. When debugging, inspect whether the value is coming from base properties, own POD/complex storage, or overlay data.

Proto-derived overlays keep the dense property index lazy: small overlays use a linear scan over their sorted entries, and _overlayEntryIndex is built only after the overlay entry count reaches the engine threshold. This avoids allocating an index sized to every registered property for the common low-entry overlay case while keeping dense lookup for larger overlays.

Overlay data offsets are naturally aligned. Each property gets an interior data alignment at registration (Property::GetDataAlignment()): plain values and POD arrays use the largest power of two dividing the base size (capped at MAX_SERIALIZED_ALIGNMENT), string arrays align to their u32 prefixes, ref-type payloads to MAX_SERIALIZED_ALIGNMENT, dicts to the strictest of their key/value/prefix alignments, and single strings stay byte-aligned. AllocOverlayData first best-fit searches freed holes and alignment paddings between existing entries and only extends the overlay tail (aligned) when no hole fits; _overlayGarbageSize tracks exactly the bytes inside the used range not owned by any entry, which lets the allocator skip the hole search when no hole can possibly fit. Capacity growth is re-evaluated after repacking because moving variable-size entries can shift the final aligned tail across the initially selected capacity boundary. Repack and rebuild-from-full lay entry data out in stable alignment-descending order, which minimizes padding (plain entries pack back-to-back; variable-size complex payloads may leave small aligned gaps accounted as garbage). The registrator’s main POD block is aligned by construction (offsets are multiples of the property base size, section bases are multiples of 8), so GetRawData() always returns data aligned for its interior layout regardless of which storage backs it.

Complex property raw data also keeps its interior aligned. The layout contract lives in Properties.h and uses alignment_for_size(): inside a blob, every fixed-size item is placed at its natural alignment — u32 length/count prefixes at 4, POD keys/values/element runs at the largest power of two dividing their size (capped at MAX_SERIALIZED_ALIGNMENT), nested ref-type payloads at MAX_SERIALIZED_ALIGNMENT, ref-blob field payloads at the field’s own data alignment; string bytes are unaligned, padding bytes are zero, and no padding follows the last item, so dict parsers still terminate on exact buffer exhaustion. All blob codecs mirror the same align_up steps: PropertiesSerializator (value/text), the AngelScript marshaling in AngelScriptHelpers.cpp, DynamicRefTypeInstance in ScriptSystem.cpp, the string-array codec in Properties.h, and the inbound validator in ClientDataValidation.cpp (which additionally rejects non-zero padding in untrusted client payloads). Since the blob start is aligned by storage and interiors follow the contract, fixed-size items can be read and written with direct typed access. Changing this layout is a client/server compatibility break: bump the compatibility version marker in Common.h and rebake resources.

MAX_SERIALIZED_ALIGNMENT (defined in BasicCore.h, currently 8) is the cap for this whole contract, chosen as a single compile-time constant rather than the platform-dependent alignof(std::max_align_t) (16 on x64 via long double, 8 on wasm) precisely so the serialized byte layout is identical on every target regardless of the platform’s max_align_t or default new alignment. The contract stays sound only while every serialized scalar leaf fits within MAX_SERIALIZED_ALIGNMENT; because the layout later reads fixed-size items with direct typed access (reinterpret_as<T>()), an over-aligned leaf would produce a misaligned load (UB / sanitizer trap / hard fault on strict-alignment targets). This is pinned at compile time: the fundamental integer/float leaves are asserted in BasicCore.h, the hashed-string hash leaf in Properties.h, and every typed property accessor (GetValue/GetValueFast/SetValue) statically asserts alignof(T) <= MAX_SERIALIZED_ALIGNMENT at instantiation, so adding an over-aligned type (SIMD, __int128, long double, alignas(16)) anywhere on the serialized path fails the build instead of silently under-aligning. The current type grammar cannot express such a leaf — primitives are ≤ 8 bytes, hstring serializes as a 64-bit hash, and structs are compositions of those, so alignof(struct) never exceeds 8.

Prototypes

Source/Common/EntityProtos.h defines prototype entities:

Source/Common/ProtoManager.* owns prototype lookup and loading:

Prototype loading is adjacent to resource baking. For baker-side proto handling, see BakingPipeline.md.

Inner entities and holders

Entities can hold other entities under named entries. Holder metadata lives in EntityTypeDesc::HolderEntryDesc:

The common persistent fields CustomHolderId and CustomHolderEntry let custom entities record holder relationships. EntityManagerApi provides custom-entity creation, lookup, and destruction hooks:

When changing holder behavior, inspect server/client entity managers and persistence paths in addition to Entity.*.

Events and time events

FO_ENTITY_EVENT(Name, Args...) creates an EntityEventWrapper member. Event callbacks are priority-ordered and return Entity::EventResult:

EntityEventWrapper::Fire() builds native call data differently for global and non-global entities: non-global events inject the entity as the first argument.

Entity::TimeEventData stores scheduled script callbacks, fire time, repeat duration, and script data. Entities that support time events are declared with the HasTimeEvents metadata flag in the ExportEntity annotations.

Serialization relationships

Entity state is serialized through property data, not by hand-copying entity fields:

When text/document loading converts numeric property values, the serializer rejects values that do not fit the target primitive width instead of wrapping or producing infinity.

The binary restore paths (RestoreData, RestoreAllData) likewise validate the snapshot against the registrator layout before copying: property indices outside the registrator table, oversized blocks, and out-of-bounds POD (start_pos, len) sections are rejected with a throw rather than written, so a corrupted or hostile snapshot fails closed instead of overflowing the property storage.

Persistence backends store AnyData::Document records. For database commit/recovery details, see Persistence.md.

Tests to inspect

Relevant tests include:

Change routing

Validation checklist

  1. Build the smallest target that compiles generated entity/property code.
  2. Run entity lifecycle/prototype tests relevant to the changed type.
  3. Run property/metadata tests when property flags, registration, or serialization changes.
  4. If a property is synced, validate client/server replication paths and update Networking.md if behavior changes.
  5. If a property is persistent, validate database save/load paths and update Persistence.md if behavior changes.
  6. If a property or method is script-visible, validate generated script API and update Nullability.md where applicable.