View on GitHub

FOnline Engine

Flexible cross-platform isometric game engine

Client Runtime

Engine-owned documentation. This page describes reusable client runtime behavior in Source/Client/; game UI policy, gameplay rules, and concrete content remain in the embedding project.

Purpose

The client runtime turns baked resources, server state, local input, and scripts into the player-facing game view. It does not own game design decisions. Instead, it provides the reusable engine pieces that a game project drives through scripts, configuration, resources, and server messages.

Read this page together with:

Source paths inspected

Runtime owner: ClientEngine

ClientEngine in Source/Client/Client.h is the central client-side engine object. It derives from BaseEngine and AnimationResolver, owns the high-level client lifecycle, and exposes event hooks used by client scripts.

Major responsibilities:

ClientEngine is intentionally broad: it is the composition root where Common-layer data (Entity, properties, prototypes, networking buffers) meets Frontend-layer services (Application, render, input, audio) and game scripts.

Client lifecycle

A typical client lifetime has these phases:

  1. Application initialization happens in the frontend layer. Application owns the main window, renderer, input, and audio. See FrontendAndRendering.md.
  2. Resource filesystem selection starts through GetClientResources(GlobalSettings&) in Source/Client/Client.cpp, which builds the client-side FileSystem view used by runtime managers. Installed clients may add a higher-priority writable resource overlay above the read-only base resources; see ConfigurationAndDataSources.md and ClientUpdater.md.
  3. Engine construction wires settings, resources, the main application window, generated metadata, script modules, and client managers.
  4. OnStart/script initialization gives scripts their first client-side entry point. Source/Tests/Test_ClientEngine.cpp validates that script module init and loop calls are callable on a self-contained client runtime.
  5. The main loop processes frontend input, network packets, scripted loop callbacks, visual effects, video playback, map processing, and rendering-facing updates.
  6. Network connection starts with Connect(), which delegates transport setup and handshake work to ClientConnection.
  7. Map and entity state arrive through network messages, are represented as client view entities, and are updated through property sync and movement/action packets.
  8. Shutdown disconnects networking, destroys inner entities, clears caches and render targets, and releases frontend resources.

When changing startup or shutdown behavior, keep script events, manager lifetime, entity registration, and network callbacks in sync; these paths are tightly coupled.

Server connection and message dispatch

ClientConnection (Source/Client/ClientConnection.h, Source/Client/ClientConnection.cpp) owns the client-side transport state. It hides whether the current connection is interthread, TCP sockets, or UDP-capable sockets.

Important responsibilities:

ClientEngine owns the semantic handlers. Examples include Net_OnInitData, Net_OnAddCritter, Net_OnRemoveCritter, Net_OnProperty, Net_OnLoadMap, Net_OnSomeItems, Net_OnRemoteCall, Net_OnAddCustomEntity, and Net_OnRemoveCustomEntity.

For protocol format details, use Networking.md. For client/server handshake validation, see Source/Tests/Test_ClientServerIntegration.cpp, especially ClientAndServerHandshakeOverInterthreadTransport.

Client-side script continuations scheduled through ScheduleDelayedCallback() are processed once per main-loop pass from a snapshot of callbacks already due at the start of that pass. A callback that schedules another zero-delay callback, including Yield(0), resumes on the next pass instead of re-entering immediately. This prevents script wait loops from starving the next network/input tick.

Entity and view model

Client-side game objects are not raw server entities. They are view entities that combine Common-layer entity data with client-only rendering, input, and presentation state.

Primary view types:

ClientEngine::RegisterEntity() and ClientEngine::UnregisterEntity() maintain the id-to-entity lookup used by network handlers and scripts. Source/Tests/Test_ClientEngine.cpp validates that client entities can be registered and removed from the lookup.

Script GUI ItemView widgets cache the item handles bound to their cells. Resort() keeps a cell only when the source returns the same handle instance; a replacement clone with the same entity id is rebound so item draw callbacks observe its current count and other projected properties.

3D model runtime architecture

The former 3dAnimation and 3dStuff umbrella modules have been removed. The client 3D path now uses same-named Model*.h / Model*.cpp module pairs with one ownership boundary per module. There is no client-side ModelMesh utility module: mesh representation types live with ModelHierarchy. There is also no separate ModelPoseRuntime module: renderer-independent pose/link operations are part of ModelAnimation.

Module Responsibility
ModelManager Runtime entry point, model-description and hierarchy caches, baked mesh loading, and construction of model instances.
ModelHierarchy Shared loaded ModelBone topology, mesh/bind data, model textures/effects, and the mesh representation types used by hierarchy and instances. It does not own mutable animation pose output.
ModelInformation One resolved baked .fo3d description: hierarchy reference, canonical/runtime joint identities, animation lookup tables, cuts/links, draw metadata, and one immutable ModelAnimationRuntimeRig.
ModelInstance All mutable per-instance state: controller timelines, runtime pose, world-matrix snapshots, mesh state and batching, cuts, attachments, particles, procedural transforms, projection, and draw submission.
ModelAnimation Engine animation-controller timeline, transition, callback, reverse/freeze/play-once, and binding semantics together with the engine-owned clip/rig/pose contract, Ozz-backed sampling and blending behind PImpl, canonical-joint mapping, linked-pose resolution, and validated rest-pose matrix construction for direct raw models.
ModelBakedData Small defensive reader helpers shared by client model loaders, especially count-versus-unread-data preflight before allocation.
ModelSprites Adapter from ModelManager / ModelInstance to atlas-backed and direct-scene sprite rendering.

The ownership direction is deliberate:

This split removes the old include-everything dependency. A caller includes the manager, information, hierarchy, instance, animation, or baked-data contract it actually consumes. Small passive types stay with their owning module instead of forming empty translation units.

ModelAnimationRuntimeClip, ModelAnimationRuntimeRig, and ModelAnimationRuntimePose keep their backend state behind PImpl. The public header exposes only engine-owned runtime values and spans; Ozz headers, archive objects, sampling contexts, and matrix buffers stay in ModelAnimation.cpp. ModelAnimationData defines the native versioned wire contract shared with the baker, while ModelAnimationConverter owns offline conversion. The engine API does not name or model interchangeable animation backends: Ozz is an implementation detail of the engine’s native model-animation runtime and baked format.

Critter model animation

3D critter models use separate body/action and movement animation controllers. ModelInstance::PlayAnim() applies animation-specific speed (AnimSpeed) to the body/action controller, while RefreshMoveAnimation() assigns gait and movement-speed scaling to the movement controller’s track. When both are active, the movement controller advances with the model base/link/global speed only; it must not inherit the current body action’s AnimSpeed, otherwise fast actions such as use/pick-up make the leg cycle run too quickly while the critter is moving.

An animation name prefixed with ~ plays the source clip in reverse: playback time t samples the clip at duration - fmod(t, duration), so an exact loop boundary restarts at the clip end. If interpolation is disabled, the same nearest-key rule is applied at that reversed source time.

Every baked .fo3d has the required versioned LFMODINF schema-1 header and a required LFOZZRIG payload generated for pinned ozz-animation 0.16.0. ModelInformation strictly loads and owns one immutable ModelAnimationRuntimeRig. Its PImpl contains the canonical Ozz skeleton, unique clips, base/clip remaps, presence masks, nearest timelines, and resolved state/action binding table. The loader rejects old unversioned files and any partial or inconsistent rig; it never falls back to another payload after an Ozz load error. The final mesh-only wire transition uses compatibility marker 0.0.30 and requires a full resource rebake.

Ozz is the production animated-pose path. The body and movement controllers advance timeline/event state only. Each registered animation stores direct Ozz clip index/duration/reverse metadata and an immutable bound-joint set derived from the clip presence mask. A joint is bound only when its canonical and exact runtime names match, so this gate deliberately keeps a resource-renamed model root out of a source-root animation. Per-track allowed-joint and transition- suppression masks further filter those bindings before the track state feeds the per-instance Ozz pose. Ozz performs clip sampling, body blending, movement-only joint replacement, procedural body/head pre-rotation, and local-to-model evaluation. Each ModelInstance snapshots the resulting world matrices, and skin palettes, linked children, particles, and bone queries read that owning instance snapshot. Link-all attachments override only the matched joint after evaluation and deliberately do not recompute its descendants, preserving the established attachment order.

Canonical joint names, exact runtime lookup names, and name-to-index bindings are independent of the physical ModelBone hierarchy. Base joints retain a read-only physical bone for meshes and cuts; animation-contributed joints have no ModelBone and are never materialized into the shared cached hierarchy. The base root deliberately keeps its resource-path runtime alias, while contributed joints use their canonical names, preserving exact legacy lookup and root-animation suppression semantics. Runtime particles, attachment resolution, link-all matching, and bone-position queries operate on canonical indices; link-all can therefore bind contributed joints without physical bones. Authored one-bone link validation remains base-hierarchy-only in the current baking schema.

Static .fo3d instances evaluate an empty Ozz track set so canonical rest and procedural body/head transforms follow the same runtime as animated instances. Only direct raw-model instances remain outside Ozz and build parent-ordered world matrices through the validation helpers owned by ModelAnimation.

The client script pair Game.DrawCritter3d(...) and Game.GetDrawCritter3dBounds(...) supports reusable GUI layout around a model sprite. After drawing an instance, the bounds query returns two rectangles relative to the draw anchor, or false when that instance has not produced a valid model sprite. drawRect covers the selected animation’s complete cycle and continuous facing range, including its projected shadow. viewRect is the stable logical model-and-layers rectangle used by names, coarse picking, and similar presentation. GUI preview code fits and centres the draw rectangle; world-space overlays use the stable view rectangle as their logical anchor, without duplicating 3D projection rules or depending on the current atlas crop. The former custom pose evaluator and shared mutable matrix-output table have been removed. Baked model meshes now begin with the mandatory LFMODMSH schema-1 header and contain only the recursive hierarchy/bind/drawable mesh payload. The client consumes it exactly and rejects headerless, mismatched, truncated, or trailing data; no serialized TRS tail and no legacy mesh fallback remain. Runtime clip identity, duration, joint presence, and sampling come exclusively from the baked Ozz rig.

The nested LF archive hash detects accidental corruption but is not an authentication mechanism. Ozz deserialization assumes the baked resource pack is trusted; deployments that permit attacker-rewritable packs must authenticate the pack before this loader runs.

MapView: map presentation and local spatial state

MapView is the largest client view class because it bridges several subsystems:

MapView is still a client-side view over the Common map model. Reusable coordinate/pathfinding rules belong in MapsMovementGeometry.md; presentation details such as render targets, light textures, transparent eggs, map scrolling, and hit testing belong here and in FrontendAndRendering.md.

Map light source intensity is authored as a percentage magnitude (0..100, with negative values keeping the same magnitude but opting into constant/personal capacity semantics). MapView clamps the current animated percentage, converts it to an internal raw falloff scale (0..10000), and then scales light-map RGB to the engine light range (0..200) and primitive alpha to 0..255 through the source’s day-light capacity percentage. SetDayColors() must invalidate applied light fans when either the day color or the light-capacity percentage changes, because both feed cached per-hex lighting.

The reusable map presentation API includes SetExtraScrollOffset() for script-owned transient camera offsets. The engine applies the offset to the map view, but game-specific screen effects such as quake/shake timing and fade overlays are owned by embedding-project scripts.

Resources, sprites, effects, and render targets

The client resource path starts with a FileSystem from GetClientResources() and is organized by runtime managers:

For 3D critter views, idle refresh plays alive-state animations from the beginning. Dead condition idles freeze on their final frame. Other non-alive condition idles freeze on their first frame, so embedding projects should author that first frame as the intended resting pose for the condition.

These managers are renderer-facing but not renderer-specific. They talk through IAppRender / Renderer abstractions, so the same client logic can run against OpenGL, Direct3D, or the null renderer depending on platform/build configuration.

ParticleManager and ParticleSystem are backend-neutral dispatch facades used by sprites and model attachments. ParticleRuntime.cpp is the composition point: it creates the enabled ParticleRuntimeBackend implementations, and the manager selects one by resource extension. Every live particle owns exactly one ParticleRuntimeSystem; common timing, scale, and render scheduling stay in ParticleSystem, while simulation and backend-specific rendering are virtual runtime operations.

Resource invalidation follows the same neutral boundary: SpriteManager -> ParticleSpriteFactory -> ParticleManager notifies every backend through ParticleRuntimeBackend::InvalidateResource(), because a changed file may be a dependency rather than a backend-owned root asset. SPARK drops the matching parsed graph for a changed .spk and clears its graph cache when a texture or render-effect dependency changes, so the next particle creation reloads both graph and dependency. Failed loads are not cached. Backends without a parsed-asset cache keep the invalidation operation as a no-op.

VisualParticles.h / .cpp contain no particle-backend names or feature guards. Concrete types and capabilities live in their extension files. The SPARK editor performs its checked typed access only after crossing the neutral GetRuntimeSystem() boundary, so adding another runtime does not add another branch, enum value, or vendor type to the common facade.

Particle backends are independent build features: FO_SPARK_PARTICLES and FO_EFFEKSEER_PARTICLES both default to OFF. The embedding project may enable one or both; the particle sprite factory advertises only the extensions owned by enabled backends, and a disabled backend does not compile or link its upstream runtime.

SPARK .spark sources are baked to .spk binaries. SparkParticleRuntimeBackend accepts only .spk and loads it through SPARK’s binary loadFromBuffer path; XML never reaches runtime. The binary path must remain behaviorally equivalent to its stream loader; truncated/oversized payloads, unknown object types, descriptor-signature mismatches, zero/out-of-range object references, and references to an incompatible object class invalidate the graph. Custom FOnline SPARK object registration is shared by the baker, editor, and client through the thread-safe EnsureSparkParticleObjectsRegistered() path. SparkQuadRenderer::Setup() resolves the effect and texture before a particle is returned; missing render dependencies produce a normal load failure instead of a later exception on the first draw. A renderer newly added in the SPARK editor is bound to the owning runtime before the preview graph is initialized; while its effect or texture is still unassigned it draws nothing, allowing the author to complete the renderer without dereferencing an incomplete backend state.

Baked raw .efk resources select EffekseerParticleRuntimeBackend behind the same facade. The client never loads .efkproj XML or invokes the build-time compiler. This includes Web: the host pipeline must bake .efk before packaging. Each ClientEngine or Mapper instance owns an Effekseer core manager; each Create parses a new effect, while the shared particle sprite factory separately caches successfully loaded atlas textures. Effekseer advances hierarchy, emission, and lifetime state; custom renderer callbacks copy the evaluated render snapshot into FOnline-owned packets. No Effekseer graphics backend participates. The initial capability gate is CPU Sprite/Ring-only and rejects unsupported renderer or material families before returning a particle system. Dynamic callback checks still fail closed on non-finite evaluated data, invalid UV ranges, or an atlas filter mismatch and retire that already-created handle. Simulation update is separate from draw, so effect lifetime does not pause when a direct-scene sprite is outside the rendered viewport; stopped handles are advanced through Effekseer’s deferred removal queues before their wrapper is released.

The exception is an explicit direct-scene prewarm request: it remains pending until the first DrawInScene can provide the current transform, and scheduled updates pause meanwhile. Effekseer prewarm advances exactly one second, then resynchronizes the update clock before ordinary simulation resumes so the offscreen wait is not double-counted. Sprite mesh geometry is independent of the pixel-exact hit mask. FillAtlas still derives hit testing directly from source alpha and Render.SpriteHitValue; contour simplification/dilation only changes which triangles are submitted for drawing. Cropped regions, repeated patterns, fonts, render-target blits, and padded custom-effect/contour draws intentionally construct quads because their sampling rectangle is not the source sprite silhouette.

Fonts and Inline Color Tags

FontManager::FormatText() strips @color:0xBBGGRR@ / @color:0xAABBGGRR@ tags and records the parsed ucolor value in the formatted text’s per-glyph color buffer during draw formatting. The reset tag is @color@; it restores the previous inline color, or the base draw color when no inline color is active. FontFlag::NoColorize still strips these tags, but keeps rendering with the caller-provided base color.

Game.BindFont(font, path, defaultScale = 1.0) can downscale the bound font slot. The scale is applied once at bind time: glyph bitmaps are re-rasterized in place inside the font’s atlas region with an area-average filter, and every metric (advances, offsets, line height, space width) is rounded to integers at the target size. The runtime text pipeline (Game.GetTextInfo(...), Game.GetTextLines(...), Game.DrawText(...)) therefore always works in plain integer pixel coordinates — a scaled font behaves exactly like a font authored at the smaller size, with no fractional glyph positions. The scale must be in (0..1]; upscaling a bitmap font is rejected — author a bigger font asset for larger text.

Input and script-facing hooks

ClientEngine::ProcessInputEvent() receives frontend InputEvent values and raises higher-level script events such as:

Input semantics originate in Source/Frontend/Application.h; game-specific UI behavior should stay in scripts and GUI resources owned by the embedding project.

Client scripts can synthesize local input through the same runtime path for automation and embedded-client probes. Game.SimulateMouseMove(pos), Game.SimulateMouseDown(pos, button), and Game.SimulateMouseUp(pos, button) preserve held-button state across a raw mouse gesture, including positions outside the render window; Game.SimulateMouseClick(pos, button) sends a complete mouse click or wheel event. Game.SimulateTouchDown(fingerId, pos), Game.SimulateTouchMove(fingerId, pos, offsetPos), and Game.SimulateTouchUp(fingerId, pos) send raw touch streams, Game.SimulateTouchTap(pos) sends a completed tap event, Game.SimulateKeyPress(key, text) sends one key down/up pair, and Game.SimulateKeyboardPress(key1, key2, key1Text, key2Text) remains available for two-key sequences.

For local critter movement prediction, ClientEngine::CritterMoveTo() synchronizes any active MovingContext to the current client frame before starting a new movement or sending a stop request. It then normalizes the local hex/offset pair before the next request is sent, so rapid start/stop input does not report one-frame-stale or overlarge offsets to the server.

Client-side validation tests

Use the smallest relevant test scope when changing client runtime behavior:

Exact test target names are generated by the embedding project’s CMake/BuildTools configuration; do not hard-code one project’s target names in engine docs.

Change checklist

When changing client runtime code, verify: