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.

Terminal states report themselves. A client that ends badly used to leave no trace we could read: the updater’s fatal failures call Application::ShowErrorMessage directly, which never reaches the exception callback the crash reporter chains, and a shutdown that hangs is past the point where anything can be sent. Two things close that.

ShowUpdaterFailure (Source/Client/Updater.cpp) reports every terminal UpdaterResult before it shows the dialog, carrying the result name, the binary update target, the platform and the build. ServerMissingNativeUpdate in particular means the server offered no native modules for this client’s target — a distribution problem no player can fix by reinstalling, and one we would otherwise hear about only through a screenshot.

ClientSessionMarker (Source/Client/ClientSessionMarker.{h,cpp}) records how far shutdown got. The runtime writes the marker once the application is initialized — with the build, the start time and the process identity (Pid plus ProcessStart, see platform::process_identity in Essentials.md) — and updates it at each stage in the order they are reached: MainLoopExited, ClientStopped, ApplicationReset, ShutdownHookDone, then GlobalDataTeardown while DestroyGlobalDataRecordingTeardown tears the runtime’s global data down, with a Teardown: line naming the set being deleted at that moment (global_data::destroy reports each set to an observer before its delete callback runs; the line is cleared once the last set is gone). The host records RuntimeReturned after the runtime returns (the library is never unloaded, so nothing follows it) and ExitRequested immediately before exit_app. The numbers on disk are not in that order: GlobalDataTeardown and ExitRequested were appended after RuntimeReturned, so a marker an older build left keeps its meaning.

The file is not removed on the way out, because the process teardown after exit_app — DLL detach under the loader lock, the statically linked runtimes’ thread and FLS callbacks — can hang as well, and a removed marker would hide exactly that. The next launch reads and consumes it: a marker at ExitRequested whose process is gone was a clean exit and is dropped silently; any other marker is reported with the stage and the teardown set once the crash reporter is alive. The process identity splits that report in two. A previous process that is still running is ClientSessionException: Previous client process is still running — the process is still alive and may hold client files the updater needs to replace; one that is gone is Previous client session did not exit cleanly — it crashed or was ended from outside, for example from the Task Manager. The id alone never decides it: ids are reused, so the start time must match too, and the marker the current process wrote is never taken for a previous one. The file sits in the client’s writable root, as does the log: the host resolves the root as its first act and opens <root>/<exe>.log for the whole launch, and the runtime appends to the same file. Both halves call the same ResolveWritableRoot(args), which reads no settings at all, so nothing crosses the host/runtime boundary and the two cannot disagree (see ClientUpdater.md). The marker path is made absolute even in portable mode, so runtime teardown cannot retarget it if a loaded dependency changes the process working directory. That root is the same one the cache, the resource overlay and the log use, so an installed client whose own directory is read-only still records its shutdown. Source/Tests/Test_ClientRuntimeApi.cpp pins the round trip.

Marker fields are evidence, not defaults: an absent, malformed or unknown stage is Unknown, never clamped to ExitRequested. A marker with no usable PID/start-time pair cannot prove a clean exit and remains reportable even at ExitRequested. Shutdown updates check the marker’s process identity before writing, including each global-data teardown record, so an older client finishing after another instance has replaced the shared marker does not mark that newer session as finished. The shared file remains a best-effort diagnostic for the latest session; it is not a process registry or a synchronization mechanism between clients.

On Windows the process check polls the process handle with a zero timeout. It does not interpret the exit code as liveness: an already terminated process can return 259 (STILL_ACTIVE) and remain queryable while a launcher keeps a handle open. BuildTools/tests/test_process_identity.py compiles the canonical WinAPI query and checks live processes and terminated processes with retained handles.

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 view 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.

GetHexOffset(from, to) is GetHexPos(to) - GetHexPos(from), so scrolling the view origin (RebuildMapOffset) moves every light vertex by the same pixel delta. MapView translates cached _lightPoints by that delta instead of calling LightFanToPrimitves on every hex-scroll. A light that leaves the view (last visible hex hidden in HideHex) still forces a primitive rebuild so leftover triangles are not drawn. New lights entering the view reapply their fans and rebuild as before. The uniform-delta identity is pinned by Test_Geometry (GetHexOffset view-origin shift is a uniform pixel translation).

Map item hit testing walks the active item-owned MapSprite values in the ordinary and indoor-mask lists. DrawHexItem binds each primary and multihex sprite to its item, and invalidation clears that borrow before the pooled sprite can be reused. Empty screen points therefore cost one pass over visible sprites rather than one pass over every hex in the padded view field, while draw-order, transparent-egg, and alpha hit tests keep using the same sprite records as rendering.

Which layers a view draws is the view’s own state too. MapView::SetVisibleLayers(MapLayers) takes the set, GetVisibleLayers() reads it back, and IsLayerVisible(MapLayers::Roof) asks about one; DrawHexItem and DrawHexCritter consult it. A changed set schedules a sprite-list rebuild for the next draw, so scripts need no separate redraw call and a stationary camera sees both hidden and restored layers. The seven Hex.Show* settings this replaced were mapper state that only mapper-mode drawing read, so the client could not act on them at all — a spectator asking for the roofs to come off wrote a setting nothing consulted. The set is exported as map.GetVisibleLayers() / map.SetVisibleLayers(...), and the Mapper keeps the editor’s own set in MapperEngine::VisibleLayers, pushing it into whichever map becomes current so a map opened later inherits what the author is working under (Game.GetVisibleMapLayers() / Game.SetVisibleMapLayers(...) reach it from mapper scripts).

The same reading moved three more values out of the settings table. The mixer volumes are AudioManager::GetMusicVolume() / SetMusicVolume() and their sound pair, exported as Game.GetMusicVolume() / Game.SetMusicVolume(...); Audio.MusicVolume / SoundVolume now only say what to start at. The always-on-top window flag lives where it is applied, SpriteManager::IsAlwaysOnTop() / SetAlwaysOnTop(), exported as Game.IsAlwaysOnTop() / Game.SetAlwaysOnTop(...) — the old Game.RefreshAlwaysOnTop(), which re-read the setting the script had just written, is gone. The language in effect is BaseEngine::GetCurLangName(), set by ClientEngine::ChangeLanguage() and read by scripts as Game.CurrentLanguage; Client.Language names the language to load at startup.

Manual scrolling is the view’s own state in the same way. MapView::SetManualScroll(ScrollDirection) takes the direction set the input layer decided this frame, GetManualScroll() reads it back, and IsManualScrolling() is simply that value being non-empty; ProcessScroll consumes it. Both are exported to client scripts as map.SetManualScroll(...) / map.GetManualScroll(), and the Mapper drives its own view through the same call. The eight Hex.ScrollKeyb* / Hex.ScrollMouse* settings this replaced were never configuration: no config authored them, and input wrote them every frame for MapView to read. Where the input layer needs to remember which half of it is scrolling — a key held down versus the cursor sitting at a screen edge — that belongs to the input layer, which is why the Mapper keeps its two halves as its own members and the embedding project keeps its own in a script class.

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:

Positional audio

The engine mixes, it does not decide. How far a sound carries, how its volume falls with distance and how hard it leans across the stereo image are game rules, so the caller computes both numbers and the engine applies them. An embedding project owns the curve, its radii and the listener it measures from.

Naming is the caller’s too. PlaySound takes a resource path the caller has already resolved and reads it; it does not lower-case, strip an extension or expand a convention such as a run of numbered variants. The engine reports what it indexed through GetSoundNames() and a project maps its own names onto that list, so two games can spell the same library differently without touching the mixer.

Neither is fixed once the sound starts. A sound outlives the frame that raised it — a burst, a line of speech, a machine — and the listener keeps moving meanwhile, so a placement decided once is heard from where the listener used to be. PlaySound answers with a uint32_t handle naming the sound while it plays, and UpdateSound(handle, attenuation, pan) places it again; that call answers false once the sound has finished, which is how a caller following it learns to stop, with no duration to track and no end-of-sound event to subscribe to. A handle is never reused, so a stale one is simply unknown rather than someone else’s sound. It is the same shape as the interface sprite handles (AnimLoad / Game.LoadSprite), zero included.

The handle is zero whenever nothing is playing to follow, and the mixer does not distinguish the reasons: a silent device, a sound out of earshot, and a resource that could not be played all answer zero. The first two are ordinary states rather than errors, and a caller can do nothing about the third, so an error channel beside the handle would be one nobody services. A resource that exists and still fails to decode is a defect in what was baked, so that path logs and calls break_into_debugger() — exactly what AnimLoad does for a sprite. Which name maps to which resource stays the caller’s question, as above, so a path that resolves to no file only answers zero.

Pan is therefore applied on the way to the mixer rather than into the decoded buffer. A sound short enough to decode in one pass is converted exactly once, so a pan baked in could never change afterwards, and a second pan laid over the first would multiply the two. Attenuation was already read per mixing callback, so it needed no such move.

The engine is deliberately stereo. Surround would answer “in front of or behind me” for a listener standing inside the scene; a project whose camera looks down from above and never rotates makes the player an observer instead, so a sound lower on the screen is not behind them, it is somewhere they are looking straight at.

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.

Model attachments do not own a ParticleSprite, so their simulation is advanced explicitly by ModelInstance::ProcessAnimation after the current bone transform is applied. The particle receives the model’s logical frame delta; zero-delta sizing re-poses update placement without advancing emission a second time. Prewarming defers a model-clock reset until the next real animation advance, preventing time spent waiting off-screen from becoming one large first update that destroys the warmed particle-age distribution.

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.

Typed text is filtered here, not in the frontend layer. ProcessInputEvent() strips C0 control characters and DEL from KeyDown.Text before raising OnKeyDown, and drops a KeyCode::Text event whose payload was nothing but control characters. Windows delivers Alt+numpad as an ordinary OS text-input event, so codes below 0x20 arrive as bare control characters; a bitmap font then draws them as CP437 pseudographics and they surface as junk glyphs in chat and text fields. Filtering by character rather than by the Alt modifier keeps AltGr, ordinary typing, and printable Alt codes (Alt+0169) working. Byte-wise filtering is safe for UTF-8 because every byte of a multi-byte sequence is >= 0x80.

ProcessInputEvent() is the correct place for this because it is the single point every input source funnels through — SDL events polled by ProcessInputEvents(), scripted Game.Simulate* calls, and an embedding project’s automation bridge alike. A filter placed in the frontend/SDL layer would cover only the OS path and would be invisible to simulated-input tests.

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. Two network notifications are synthesizable the same way: Game.SimulateDisconnect() delivers the OnDisconnected notification a real disconnect ends with, and Game.SimulateInfoMessage(infoMessage, extraText) delivers OnInfoMessage. Both leave the connection itself untouched — a probe testing the reaction to a dropped session must not end the session it reports through.

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: