FOnline Engine — AI Maintainer Guide
This is the AI entry point for the reusable FOnline engine repository. For the human entry point, start with README.md. For the documentation map, start with Docs/README.md.
Scope
- This repository is the reusable engine submodule used by games such as Last Frontier.
- Engine-owned code lives under
Source/,BuildTools/,Resources/, and engine tests underSource/Tests/. - Game-specific content, scripts, native extensions, CI glue, and launch presets live in the embedding project and should not be moved into the engine unless the behavior is genuinely reusable.
Before Changing Anything
- Check whether the change belongs to the engine or to the embedding game project.
- Read the nearest existing code and follow its style; do not introduce parallel conventions.
- Verify before editing: docs may drift, and when a doc and the live source disagree, the source wins — fix the doc in the same change.
- If behavior changes, update the owning engine doc in
Docs/in the same worktree change. - Do not commit or push unless explicitly asked by the repository owner.
- Treat published branch history as immutable. Once the branch has a remote tip, do not rebase, reset, amend, or force-push it; merge upstream and the published tip so every publication is a fast-forward. Verify with
git merge-base --is-ancestor <remote-tip> HEADbefore pushing, and stop instead of rewriting history when that check fails.
Documentation Map
The full maintained index is Docs/README.md; use it when a topic is not listed here. Convention-critical docs for maintainers:
- Docs/Architecture.md - engine layer map and where a behavior belongs.
- Docs/SourceTree.md - source-tree navigation.
- Docs/Essentials.md - low-level platform, logging, memory, filesystem, serialization, sockets, and utilities.
- Docs/ConfigurationAndDataSources.md - config parsing, settings, data sources, file lookup, and caches.
- Docs/Testing.md - test-suite inventory, generated test targets, coverage, and validation routing.
- Docs/DocumentationMaintenance.md - source-grounded docs maintenance workflow.
- Docs/ThirdPartyMaintenance.md - vendored dependency update, pruning, version pin, and
(FOnline Patch)workflow. - Docs/ClientUpdater.md - client host/runtime split, ABI, updater protocol, and
UpdaterBackend. - Docs/Debugging.md - stack traces, debugger helpers, native debugging, and validation notes.
- Docs/LocalVariables.md - local type spelling, redundant top-level local
const, and use-after-move rules. - Docs/Nullability.md -
T?script /ptr<T>·nptr<T>native boundary contract. - Docs/SmartPointers.md - native smart-pointer vocabulary (
ptr<T>/nptr<T>borrows,unique_*/refcount_*owners, engine-ownshared_ptr/weak_ptr), raw-pointer allowlist, and audit expectations. - Docs/ExceptionSafety.md - engine-invariant stability under exceptions: terminate-on-OOM allocation model, entity-lifecycle throw-as-signal contract, and the
FO_STRONG_ASSERTdisposition rules. - Docs/ThreadSafetyAnalysis.md -
FO_TSA_*Clang Thread Safety Analysis annotations, locking primitives, and-Werror=thread-safetyenforcement. - Docs/SyncCoverAnalysis.md -
[RequiresCover]/[ProvidesCover]managed-script cover contract,CoverReach, theFO_REQUIRES_COVER/FO_PROVIDES_COVERexport markers, and the FOSYNC001-009 analyzer. - Docs/MapperTools.md - mapper automation and native mapper helper integration points.
- Docs/WebDebugging.md - web target build/debug workflow.
- Docs/AndroidDebugging.md - Android target build/debug workflow.
- PUBLIC_API.md - public API notes.
- TUTORIAL.md - engine tutorial.
- Source/README.md - source-tree overview.
- Source/Tests/README.md - engine unit-test suites.
- BuildTools/README.md - build-tooling notes.
Validation Routing
- Zero tolerance for warnings. Engine C++ builds and codegen must finish clean; there is no acceptable warning backlog (Clang thread-safety analysis already runs as
-Werror=thread-safety). Never introduce a new warning; if a change surfaces one, resolve it at the root in the same change rather than suppressing it. - Engine C++ changes: build and run the engine unit-test target used by the embedding project (
LF_UnitTests/RunUnitTestsin Last Frontier). - Build-system changes: validate the affected CMake preset or BuildTools command in the embedding project that exercises it.
- Platform-packaging changes: validate the relevant package path (
Raw,Raw+WebServer, Android package, etc.) and update the platform doc. - Script/native API boundary changes: update nullability/API docs and run the smallest test target that covers the changed binding.
- When a serialized contract changes (entity properties, network messages, save data), update the relevant
///@ MigrationRulemetadata. -
Engine changes that affect network interaction or are otherwise substantial enough to matter for client/server runtime compatibility must force a compatibility-version change by bumping the central marker in
Source/Common/Common.h:// Force change of compatability version ///@ MigrationRule Version 0 0 5
Behavioral Rules
The whole contract is opt-out: with the Server.SingleThreadedLogic setting the engine runs its logic on one worker, the cover model is bypassed, and scripts need no synchronization at all (see Docs/ServerRuntime.md).
- Fix the source, never mask. When a lower layer returns a wrong result, swallows an error, or silently coerces bad input into “looks fine”, fix it at the source (throw / validate / propagate) instead of papering over it from the caller with pre-checks, try/catch that fabricates a “good” value, default substitutes, or other workarounds. If you find yourself adding a guard that compensates for a bug elsewhere, stop and fix the bug instead.
- No legacy compatibility in the reusable engine. When an engine API, configuration key, data format, or internal contract is replaced, remove the old surface completely. Do not retain deprecated aliases, fallback parsing, detection of removed keys, migration shims, compatibility branches, tests, or documentation for the removed contract. Compatibility-version markers may reject incompatible builds or data, but must not implement the old behavior. Migration of database-persisted game data belongs to the embedding project and must not leak into Engine.
- Keep engine invariants stable under exceptions. An exception thrown partway through a multi-step engine mutation (entity create/register/destroy/invalidate, cross-entity links) must never leave a half-mutated, restart-only state. Engine allocation terminates on OOM (
safe_alloc/safe_allocator), sobad_allocis not a recoverable error and never a reason to build a rollback. A post-mutation invariant that “can only be false if the world is already corrupt” is aFO_STRONG_ASSERT(always-on, deterministic exit), not a swallowableFO_VERIFY_AND_THROW. Entity create/destroy is throw-as-signal, not transactional rollback (events may legitimately destroy or relocate the entity; the function throws but the entity is left in a valid state) — pinned bySource/Tests/Test_EntityLifecycle.cppandTest_ServerMapOperations.cpp, so do not add a blanket create-time rollback. Full rules: Docs/ExceptionSafety.md. - No singletons. No hidden global / static state for engine-semantic data. State with per-engine semantics (lock sets, entity registries, ticket allocators tied to one engine’s wait queues, mutable runtime caches that could be mixed across engines) lives on an owning instance reachable through the engine object graph — typically
ServerEngine(managers),ServerEntity(per-entity locks, properties, parent), orBaseEngine(ScriptSystem, settings). Do not introduce file-scope mutable statics,staticdata members on engine classes, function-local statics that cache cross-call state, or unnamed-namespace mutable variables. Multiple engine instances may coexist in one process (embedding projects run parallel test suites this way), so hidden globals silently share state across engines, hide cross-test pollution, and turn lifetime-ordering bugs into timing windows. static thread_localis OK only when threads are partitioned by engine ownership and the slot can never observe values from a foreign engine on the same thread. The current example isSyncContext* CurrentContextinSource/Server/EntitySync.cpp: every thread that touches it belongs to exactly one engine, so the thread-local slot is implicitly per-engine. The same logic permits astatic std::atomicwhose value has no per-engine semantics (e.g. a monotonic ticket counter only ever compared inside one lock’s wait queue). When in doubt, prefer instance ownership — but do not pay a mutex-plus-map lookup just to satisfy the rule when athread_local T*is already correctly isolated.staticis otherwise acceptable only for compile-time constants (static constexpr), file-local pure helper functions (static void Helper(...)), andstatic_assert. An existing static that holds per-engine state is a bug to migrate to instance ownership, not a precedent to copy.
Style Notes
- Prefer existing engine idioms over new local abstractions.
- Use
structonly for passive data aggregates: no user-defined constructors, methods, or hidden invariants. When behavior or construction logic belongs on the type, make it aclassand apply full encapsulation with private state and a deliberate public interface. Do not mark such structsfinal— a plain data aggregate needs no inheritance guard, the keyword only complicates it. - The code is the documentation; a comment carries only what the code cannot. Names, types, and structure already state what happens, so a comment that re-tells them is deleted, not shortened — and the default state of a line of engine code is no comment. Write one only for what the reader cannot see: why the code sits here rather than elsewhere, why this approach instead of the obvious alternative, the gist of a genuinely dense block, or a contract that is not derivable from the code (sentinel meanings and units, threshold calibration, concurrency/ownership/lifetime requirements, edge-case exemptions, why a plausible alternative is unsafe). One line is the default and two is the ceiling — no multi-line essays. When the explanation genuinely needs more room, it belongs in the owning
Docs/page with a short link from the code; never link a temporary plan. Do not narrate the next statement, restate a signature or parameter list, enumerate steps the code already spells out, or record history, fixed-bug stories, or benchmark numbers (those belong in commit messages and docs). A comment points at code, never at bookkeeping: no task, ticket, issue, or PR numbers, no dates or “as of” wording, no version or release markers, no author names — that metadata lives in the commit and the tracker, and in code it only rots. Refer to a symbol, a function, a file, or the test that pins the behavior; the sole non-code target is the owningDocs/page named above. Short structural headings are allowed when they materially improve navigation in long flat code. Only the concluding sentence of a comment drops its period (the single line of a one-line comment, the final line of a two-line block, or a trailing comment); every preceding sentence keeps its period. Capitalize prose, not symbols (a leading lowercase code identifier stays as written). Fixed-text banners such as the license header, and commented-out code, are exempt. - A type whose whole content is static members is a namespace, not a class. A deleted constructor plus nothing but statics is a namespace written in class syntax: it can never be instantiated, it has no state and no invariant, and
X::f()reads the same either way — so writenamespace X { ... }and drop thestatic, which at namespace scope would mean internal linkage instead. Two things change with the spelling and are worth knowing: a namespace resolves names in declaration order (a class body does not, so a helper called from an inline function above it must be declared first), and a namespace cannot be a friend. That second point is the exception:safe_allockeeps its class shape precisely because a dozen types declarefriend class safe_allocso its factories can reach their private constructors. There, the class-ness is load-bearing rather than accidental. - Do not write
externon a function. At namespace scope a function already has external linkage, so the keyword states the default and says nothing. Keep it only where it still decides something: a variable declaration, whereexternis what makes the line a declaration rather than a definition, and anextern "C"language-linkage boundary. - A free function that has to carry its module’s name gets that module’s namespace instead.
write_base_log,fs::exists,mem_copy,get_stack_traceandreport_exception_and_continuewere all spelling their module into the identifier because nothing else could — so the module becomes a namespace and the word comes out of the name:logging::write_base,fs::exists,memory::copy,stack_trace::get,exceptions::report_and_continue, alongside thewinapi::/posix::/platform::/utf8::modules that already read that way. Once a module takes a namespace its whole free surface moves in, types included, so it is never split across two scopes. The test is the name, not the module: a function that needed no prefix is already the layer’s vocabulary and stays bare —numeric_cast,safe_call,copy_hold_ref,coarse_sleep,run_async,exit_app, thevec_*helpers. One name is not ours to pick:logcollides with::logfrom<cmath>, which vendored headers call unqualified, so the logging module islogging::. - Naming follows the layer:
Source/Essentials/is snake_case, everything above it is PascalCase. The foundation layer is the engine’s standard-library-shaped vocabulary, so its types, free functions, methods, public fields and private members are spelled the waystd::spells its own —data_writer,safe_alloc::make_unique,logging::write,_read_pos. Every layer above it (Source/Common/,Client/,Server/,Tools/, and an embedding project’s own native sources) stays PascalCase, so the spelling of a name tells a reader which side of the boundary it lives on. Four things keep PascalCase inside Essentials because they are not the layer’s to name: template parameters (CharT,Traits,Alloc,InlineCapacity— the standard library’s own convention), exception type names (XExceptionis an engine-wide convention that Essentials only seeds), the ref-count protocol as AngelScript spells it —AddRef/TryAddRef/ReleaseonasIScriptFunctionandasITypeInfo(refcountableaccepts either spelling andrefcount_ptrdispatches on whichever the pointee declares, sorefcountedusesaddref/release/get_refcountand only the library types keep their own), and foreign API names — the OS calls re-spelled insideWinApi.cpp/Posix.cpp, and the global crash hooks inExceptionHandling.cppthat the vendoredbackward.hppdeclares by name. Module file names stay PascalCase as well (MemorySystem.h), because theEssentials.hinclude-order block andBuildTools/tests/test_essentials_layering.pyare keyed on them. See Docs/Essentials.md. - Reuse the existing MIT source-file header and
#pragma once; match the surrounding module-level layout. - Module layout: put class definitions and static-function forward declarations near the top of the translation unit, then implementations ordered from high-level entry points down to low-level helpers, so a reader meets the public/orchestrating code first.
- Inside a class, what reports on the object comes before what changes it. A class body opens with construction and destruction, then the informational block — the
[[nodiscard]]accessors (Get*,Has*,Is*,Check*) a reader consults to learn what the object holds — and only below them the methods that mutate it, ordered by lifecycle: the initializing ones (Set*,Add*,Attach*,Load*) before the finalizing ones (Remove*,Detach*,Clear*, the teardown helpers). SoCritter::ClearAllAssociationssits at the bottom of the mutating run rather than beside the destructor it serves, andMapManager::ClearStaticMapsbelowViewMaprather than inside the accessor block. Blank lines separate the groups, and a family that already owns a block of its own — theSend_*/Broadcast_*runs, the///@ ExportEventdeclarations — keeps it where the file puts it. - Every standalone native module must be created as a complete
.h+.cpppair and both files must be registered in the owning build source list. A named subsystem/module must never be introduced as a lone header; keep the translation unit as the module anchor even when all current declarations are passive data and no out-of-line implementation is needed yet. Pure template/constexpr/umbrella headers are not standalone modules. - Order code top-down by importance and abstraction level: high-level entry points and orchestration first, secondary helpers and low-level details below, unless a nearby file has a stronger established ordering.
- First statement of every
.cppmethod/function body:FO_STACK_TRACE_ENTRY()orFO_NO_STACK_TRACE_ENTRY(). If the body continues after the marker, keep one blank line after it. Do not put either macro in headers, inline header-defined bodies, or lambda bodies. Exception:FO_SCRIPT_APIscript-export functions (///@ ExportMethod,///@ EngineHook, and the other script-bound exports) take no stack-trace marker at all — the body starts directly after the opening brace. - Separate semantically distinct code blocks with a blank line. In particular, surround every control-flow statement (
if/else,for,while/do,switch,try/catch, and similar constructs) with a blank line before and after the complete statement. Blank lines may be omitted only inside a consecutive run of the same construct (if+if,for+for, and so on). - Treat preprocessor directives as transparent to the blank-line rule: do not add or remove blank lines merely because code is enclosed by
#if/#else/#endif; apply spacing to the surrounding C++ blocks as if the directives were not there. - Keep a comment attached to the code block it describes below: put a blank line before the comment, but no blank line between the comment and that block. For control-flow spacing, the comment is part of the following block.
- Prefer
FO_VERIFY_AND_THROWover silently masking unexpected states. - Throw engine exceptions with a fixed message plus context arguments, never a pre-formatted string.
FO_DECLARE_EXCEPTIONexception types andFO_VERIFY_AND_THROWtake a constantstring_viewmessage followed by variadic context values; the base exception preserves the bare message and appends each value as its own- valueline. Writethrow SomeException("Fixed human-readable message", id, path, value);andFO_VERIFY_AND_THROW(cond, "Fixed message", ctx...);, notthrow SomeException(strex("... {} ...", value));. A constant message keeps every occurrence of the same failure identical, so failures group and sort by message uniqueness while the variable parts travel in the arguments — the same rule the script-sideverifymacro follows. Do not build the message withstrex/std::format/+from runtime values. - Never throw anything that is not derived from
std::exception, and treatcatch (...)as a defect handler, not an error handler. Every exception the engine raises comes from theFO_DECLARE_EXCEPTIONhierarchy or the standard library, so a non-std::exceptiontype walks past everycatch (const std::exception&)recovery path and arrives with nothing to report. Wherever acatch (...)sits beside acatch (const std::exception& ex), its entire body isFO_UNKNOWN_EXCEPTION();— which raisesStrongAssertationException, reports it, and terminates the process. Do not log it and continue, fabricate an"Unknown exception"message, count it as an ordinary failure, or rethrow it as a domain exception. The only places that may swallow instead are no-throw teardown/unwind paths (noexceptbodies,scope_exit/scope_failbodies,safe_calltargets, destructors, C-ABI callbacks) and the reporting/logging machinery itself, which must not re-enter while reporting. Full rule: Docs/ExceptionSafety.md §2.1. - Write for exception safety. A throw must never escape a function leaving a broken invariant. As you write or change engine code, derive the exception-safety level the body provides (
NoThrow/Strong/Basic/None) per Docs/ExceptionSafety.md (§5 disposition ladder, §8 levels): do fallible/validating work (throwingnumeric_cast, duplicate/collision guards) before the first observable mutation, insert into the authoritative store before any derived cache, and undo an early flag/counter/lock/handle mutation on unwind withscope_fail(noexcept body) or RAII on raw OS/third-party resources. Never aim forNone— it is a defect tier to fix, not to record. Allocation terminates on OOM (§1) so do not write allocation-only rollbacks, and entity create/destroy is throw-as-signal (Basicby design). Embedding projects track the per-function level in an audit baseline gated by CI (Last Frontier:Tools/ExceptionSafetyAudit/); update it in the same change. - Use
numeric_castfor numeric conversions; do not usestatic_castfor numeric narrowing/widening unless the surrounding code has a specific established reason. - Use fixed-width types (
int8_t,uint8_t,int16_t,uint16_t,int32_t,uint32_t,int64_t,uint64_t,float32_t,float64_t,size_t) instead of bareint/floatin new engine code. - Do not use
autofor primitive values or simple obvious types such asstring,hstring,size_t, and the fixed-width aliases. - Do not put top-level
conston an automatic local when removing it leaves the type contract unchanged — writeint32_t count = GetCount();, notconst int32_t count = GetCount();. There is no engine-wide immutability-by-default rule, and parameters are out of scope. Constness that belongs to the value itself stays: aconstpointee (const Item* item), aconstreferent (const Item& item),constarray elements, andconstexpr. Top-levelconston a pointer (int32_t* const pointer) is removable and is diagnosed. A deliberate qualifier is kept with// FO_REDUNDANT_CONST_SUPPRESS: <reason>on the declaration or the line above it, and the reason is mandatory. See Docs/LocalVariables.md; embedding projects gate this together with explicit simple local types and use-after-move (Last Frontier:Tools/LocalVariableValidator/,Tools/ExplicitLocalTypes/). - Use the engine smart-pointer vocabulary from
Source/Essentials/SmartPointers.h—ptr<T>/nptr<T>for borrows,unique_*/refcount_*for owners, engine-ownshared_ptr/weak_ptrviasafe_alloc::make_shared()— instead ofstd::smart pointers or bare rawT*. Raw pointers remain only at the documented ABI/low-level allowlist boundaries; inside a function, bind them to wrappers before ordinary engine work and unwrap with.get()only at the final handoff. A checkednptr<T>and every owner convert toptr<T>implicitly, so do not write.as_ptr()where that implicit conversion applies — aptr<T>parameter, member, typed local or return takes the value directly; spell.as_ptr()only where no conversion can happen (a deducedautolocal, overload or template deduction, a lambda capture, a value needed after its owner moves). See Docs/SmartPointers.md; embedding projects may gate this with an audit tool (Last Frontier:Tools/SmartPointerAudit/). - Use the engine container aliases from
Source/Essentials/Containers.h, never theirstd::originals —string,wstring,vector,map,unordered_map,set,list,deque,stringstream,small_vector. Each follows the terminate-on-OOM contract instead of throwingstd::bad_alloc:stringandwstringare the enginebasic_stringfromSource/Essentials/StringObject.h, whose small-string buffer is theFO_STRING_INLINE_CAPACITYbuild option,dequeis the enginebasic_dequefromSource/Essentials/DequeObject.h, whose block size is a template parameter, and the rest are the standard containers instantiated onsafe_allocator. For allocation that cannot be expressed as a C++ container, usesafe_alloc: theMake*family for typed objects, and the raw tier (malloc_raw/calloc_raw/realloc_raw/free_raw,malloc_aligned_raw/free_aligned_raw) for third-party C-ABI hooks. Do not reach for baremalloc/free, and do not usestd::format/std::vformat/std::to_string, which materialise astd::allocatorstring — format into an existing buffer withstd::format_to/std::vformat_toinstead. Exceptions are limited to foreign ABI boundaries, the Essentials modules aboveMemorySystemin the include order, and standard types with no allocator parameter; each one is recorded with a written reason. See Docs/Essentials.md; embedding projects may gate this with an audit tool (Last Frontier:Tools/AllocatorAudit/). - Use the engine callable wrappers from
Source/Essentials/FunctionObjects.h, neverstd::function—function<Sig>(an alias ofmove_only_function<Sig>) is the default, andcopyable_function<Sig>is for a stored callable that is genuinely copied, such as one snapshotted during dispatch or handed to several owners. Both keep a small nothrow-movable target inside the wrapper, so an ordinary closure allocates nothing. When afunctionmember no longer compiles because something copies it, first check whether the copy should be astd::move; reach forcopyable_functiononly when the copy is the actual contract. The sole remainingstd::functionis theStackTrace.hscript-provider hook, which sits above the callable module in the Essentials order. See Docs/Essentials.md. - The engine
stringbehaves asstd::basic_string, so write it as you would the standard string. The one difference is the tunable inline buffer, which is a build option rather than a source-level choice. Three interop rules do not follow from the standard: text handed to a standard string stream is copied throughmake_stream_string, astd::filesystem::pathis built from an engine string withfs::make_path, andgetlinemust be called unqualified so ADL finds the engine overload. See Docs/Essentials.md. - Use
random_generatorfromSource/Essentials/RandomGenerator.h, neverstd::mt19937orstd::uniform_int_distribution. The standard engine costs 5000 bytes of state and 3.6 us to construct, but the deciding reason is that the standard distributions are implementation-defined: the same seed maps to different values on a Windows client and a Linux server.next()draws raw bits,next_below/next_between/next_normalizedare the engine’s own bounded draws and produce one sequence everywhere. See Docs/Essentials.md. - Sleep with
coarse_sleeporprecise_sleepfromSource/Essentials/Threading.h, neverstd::this_thread::sleep_for. The standard call rounds up to the OS timer tick, so a 50 us request parks for 15 ms on a default Windows configuration.coarse_sleepparks and lands within half a millisecond at no CPU cost;precise_sleephits the deadline to within microseconds by spinning the last millisecond, and is for waits whose duration was chosen on purpose. See Docs/Essentials.md. - Call the operating system through
winapi::orposix::, never directly.Source/Essentials/WinApi.*andPosix.*are the only places<Windows.h>and the POSIX headers are included, and their boundary carries engine types rather than OS ones.Platformdispatches between them; add a wrapper there instead of reaching for the OS from a consumer. Three implementations below the modules in the Essentials order keep their own calls because the layering leaves no alternative (BasicCore.cpp,BaseLogging.cpp,StringUtils.cpp), and two more are OS wrappers in their own right rather than consumers (NetSockets.*,ServerServiceApp.cpp). See Docs/Essentials.md. - A setting is read, never written.
Source/Common/Settings.incdeclares every entry with one macro,SETTING(<type>, <Group>, <Name>, <default>), which makes the memberconst;SetRuntimeSetting()rejects a write to any of them, andManagedScriptBakeremits the script-sideSettings.Group.Nameas a get-only property. A value that changes while the game runs therefore cannot live here — it belongs to the system that owns it, reached through that system’s API (MapView::SetVisibleLayers,AudioManager::SetMusicVolume,Application::ScreenState, …).BakerTests::OverrideSettingis the one deliberate exception and is confined to test code. See Docs/ConfigurationAndDataSources.md. - A setting is addressed through its group.
Source/Common/Settings.incdeclares a group asSETTING_GROUP(<Group>, <virtual bases>)and holds its settings in a nested aggregate named after the group, so engine code readssettings.Network.ServerPortand a config key, a command-line override andGetRuntimeSetting()/SetRuntimeSetting()all spell itNetwork.ServerPort. A bareNamenames no engine setting anywhere — it lands in custom settings, which the config baker reports asUnknown setting— and that is what leaves two groups free to declare the same short name. A///@ Settingdeclaration carries the wholeGroup.Nametoo. See Docs/ConfigurationAndDataSources.md. - Task-language definition: in requirements, reviews, and conversation about engine code, informal wording such as “pointer”, “raw pointer”, “borrowed pointer”, “указатель”, or “сырой указатель” means a non-owning engine borrow, not permission to write a bare C++
T*. Useptr<T>when presence is guaranteed andnptr<T>when absence is valid. A bareT*is authorized only when the request explicitly requires the literal C++T*type and identifies a documented ABI/low-level boundary; if either condition is missing, the wrapper rule wins. - For
Entityand derived types use pointers, not references. - Prefer
staticfree functions for file-local helpers instead of unnamed namespaces. - Do not add
staticvariables for hidden state or caches; see the Behavioral Rules above for the narrow allowed uses ofstatic. - Add
constandnoexceptwhere they express the semantic contract; do not add them mechanically everywhere possible. Fornoexceptspecifically: aNoThrowexception-safety classification alone never justifies the keyword — the ES baseline records the current fact, whilenoexceptis a contract that blocks future legitimate validating throws. Reserve it for move operations/swap, teardown/unwind-path callables (scope_exit/scope_failbodies,safe_calltargets), C-ABI callbacks, and documented no-throw primitives; see Docs/ExceptionSafety.md §8. - Use
ignore_unused(...)only for variables/objects; for an intentionally ignored function-call result, write(void)FunctionCall(...). - For C++ string/text construction and parsing, prefer existing engine helpers such as
strexandstrvexwhen they make formatting or token handling clearer. If the helper surface is missing a repeated string-formatting operation, add a reusable helper in the appropriate engine utility layer instead of open-coding ad hoc parsing/formatting at call sites. - Gate conditional compilation on our own (
FO_*/ project) macros with#if FOO/#if !FOO, never#ifdef/#ifndef: every such macro is mandatorily#defined to0or1, so its definedness must never carry meaning — only its value does. - Keep engine-owned source inventories unconditional. Add every engine-owned
.h/.cpppair to its normal CMake source list even when the feature is optional; the files themselves own the#if FO_*guard. Do not mirror a feature toggle aroundAppendList(...), and do not create a second feature-onlyAppendListfor the guarded module. Likewise, do not wrap#includedirectives for self-guarded engine headers in a standalone feature conditional: include them normally and keep the feature guard around declarations/definitions that need it. Conditional third-party targets, link dependencies, and third-party headers whose include paths only exist with the feature may remain gated. - Include layering: non-Essentials engine code must consume Essentials modules through
Common.h, not by including individualSource/Essentials/*.hheaders directly. STL headers are centralized throughBasicCore.h; add or adjust standard-library includes there instead of including<...>headers from higher layers. - Essentials layering is strict. The
Source/Essentials/Essentials.haggregate lists Essentials headers in include order — each Essentials header (and its.cpp) may only depend on headers listed above it in that block. This is a compile- and link-time rule: declaring a function in an early header but defining it in a later module is still a forbidden reverse dependency, even when no downward#includeappears. Public declarations are implemented in their owning module (or an earlier one), andBuildTools/tests/test_essentials_layering.pyenforces both direct-include direction and definition ownership for the namespace-level APIs a header declares. If a low-layer module needs information that physically lives in a higher layer, expose only what the lower layer can produce on its own or take the higher-layer value as a parameter — do not back-channel the include and do not reorder the block to work around it. - Never
#includean STL header outside the engine’sSource/Essentials/BasicCore.h. The standard library is not included per-file; the whole STL surface in use is included centrally throughBasicCore.h. If a standard-library facility is missing, add its include toBasicCore.h, never to the consuming file. - Essentials layering is strict. The
Source/Essentials/Essentials.haggregate lists Essentials headers in include order — each Essentials header (and its.cpp) may only depend on headers listed above it in that block. If a low-layer module needs information that physically lives in a higher layer, expose only what the lower layer can produce on its own or take the higher-layer value as a parameter — do not back-channel the include and do not reorder the block to work around it. - Iterating server-side entities while events may fire: any script-visible event (e.g.
OnItemOnMapAppeared.Fire(...),OnCritterDisappeared.Fire(...)) can re-enter scripts and mutate world state — including destroying the very entity being iterated. The convention is: (1) snapshot the iteration set withcopy_hold_ref(...)so each element is held by ref-count for the duration of the loop; (2) re-validate inside the loop withif (cr->IsDestroyed()) continue;(and after each event for any other entity you keep working with); (3) fire scripted events at the end of the unit of work, after non-revocable state changes are committed. - Blank line before and after every control-flow block. Put one blank line before and after every
if/for/while/switchblock so it is separated from surrounding non-control-flow statements (declarations, assignments, calls). Only exception: consecutive control-flow blocks that form a group may be stacked directly with no blank line between them. - Managed C# sources (
Source/Scripting/Managed/) open with the file-scoped namespace declaration, then the using directives, then the code — nonamespace X { ... }block, and no per-file#nullabledirective, since every project that compiles them sets<Nullable>enable</Nullable>. The exception is code the baker generates: Roslyn withholds the project nullable context from a file under an// <auto-generated />banner (CS8669), soManagedScriptBakerstates it in the emitted file itself, below the usings —annotationsonly, because the marshalling it emits is not written to satisfy flow analysis and its warnings would land on generated lines nobody edits. Embedding projects may gate the shape with an audit tool (Last Frontier:Tools/CsStyleAudit/) and withIDE0161/IDE0065in.editorconfig, which the generated script project turns into build errors throughEnforceCodeStyleInBuild+TreatWarningsAsErrors. - Keep edited source files ending with exactly one trailing blank line.
- A method the native backend resolves through Mono (
[CallableByEngine]) isinternal. Native lookup (mono_class_get_method_from_name) finds non-public members;privatelooks unused toIDE0051because nothing in C# calls it. Helpers that only their own type uses stayprivate. In CoreScripts an embedding project’s analyzer may gate this (Last Frontier:LF0015);ManagedHostis compiled into its own assembly, which those analyzers never see, so there the rule is held by review alone. - Managed C# member names are PascalCase, whatever their access — private fields included (
Continuations, not_continuations); underscores may separate segments that each start with a capital letter or a digit, never lead or trail a name. A field that must stay a field beside a same-named property gets a name of its own (RecordedGloballybehindGlobalCount). Two surfaces keep a different spelling on purpose: the script-visible value types mirror the native API in lower case (ipos.x,hstr(),timespan.milliseconds), and codeManagedScriptBakergenerates keeps_-prefixed internals (_entityPtr,__event_OnStart) so no property an embedding project declares can collide with them. - Keep docs reusable: describe engine behavior first; mention Last Frontier only as an embedding-project example, never as an engine-doc dependency or validation owner.
- Keep
README.mdhuman-oriented andAGENTS.mdAI-oriented.CLAUDE.mdis intentionally only a pointer to@AGENTS.md.