MotaEngine

MotaEngine SDK Reference

The reference for building a game against MotaEngine, from an engine source checkout or a prebuilt SDK. Companion documents: docs/lua-api.md (scripting surface), docs/user-manual.md (engine systems), per-project README.md files scaffolded by tools/mota new.

Project anatomy

A game is its own directory (own repo if you like) with:

MyGame/
├── CMakeLists.txt        add_subdirectory(<engine root>) + your executable
├── CMakePresets.json     pins MOTA_ENGINE_ROOT + vcpkg manifest redirection
├── MyGame.motaproj       project settings; "gameModule" names your module
├── src/
│   ├── main.cpp          registers the module, calls RunEngineMain
│   └── game_module.{h,cpp}
└── assets/

tools\mota new MyGame scaffolds all of it. mota build / run / package drive it. Keep project paths short (vcpkg + MAX_PATH). The scaffolded CMakePresets.json also carries a linux-server configure preset (+ linux-server-debug|shipping build presets) that reads the engine checkout from $MOTA_ENGINE_ROOT, so the same project builds its dedicated server on a Linux box: copy the project and an engine checkout over, export MOTA_ENGINE_ROOT=... VCPKG_ROOT=..., cmake --preset linux-server, cmake --build --preset linux-server-shipping, then run ~/mota-build-MyGame/Shipping/MyGame --server --project=MyGame.motaproj from the project directory (the scaffolded README spells it out).

mota package builds the Windows package (dist/<Game>/: exe + signed pak + run.bat + THIRD_PARTY_NOTICES.md + MOTAENGINE_LICENSE.md, the attributions a shipped game must carry, written from copies embedded in the engine binary at build time); mota package -Platform android produces the APK/AAB (dist/<Game>/android/, see platform/android/README.md) and mota package -Platform ios the .app on a Mac over ssh (platform/ios/README.md). Both mobile drivers take -BuildType debug|release (release = the Shipping config, ThinLTO, stripped) and read settings.displayName, settings.bundleId and settings.version from the .motaproj for the store metadata.

Prebuilt SDK (Windows x64 + Linux x64 server)

Alongside the full source (available to every licensee), the engine ships as prebuilt SDKs for teams that would rather not build it - headers + static libraries + the dependency tree, no engine source. Both are built with link-time optimization OFF (-DMOTA_SHIPPING_LTO=OFF in a separate build tree): LTO objects carry compiler IR (MSVC /GL, Clang bitcode) that only the exact same compiler version can link, and a licensee's toolchain will not match the SDK build machine's.

Windows x64 (game client + dedicated server)

tools\make_sdk.ps1 builds into build-sdk\ and produces dist\sdk\MotaEngineSDK-<ver>-win64.zip: the 18 engine static libraries for Debug / Development / Shipping, the public headers, the vcpkg dependency tree they link against (deps/x64-windows, tools stripped), cmake/MotaEngineConfig.cmake (+ an SDK edition of mota_game.cmake that deploys the dependency DLLs after each link), the mota CLI with its scaffold template, the StormForge sample, docs and the legal texts. A machine with Visual Studio 2026 (version 18) or later, whose MSVC toolset is at least the SDK's (stamped as MOTAENGINE_SDK_MSVC_VERSION; find_package(MotaEngine) fails with a clear message on an older one, since an older linker cannot read newer MSVC objects), CMake 3.25+ and the Vulkan SDK builds a game against it with no vcpkg run and no engine compile (measured on the reference machine: zip ~830 MB, 4 GB extracted; a scaffolded game configures in ~11 s, links Debug in ~10 s, and mota package including the Shipping link takes ~30 s - against ~40 min of vcpkg + ~10 min of engine compile for the source SDK):

C:\MotaSDK\tools\mota new MyGame -Dir C:\      # same scaffold as the source SDK
cd C:\MyGame
C:\MotaSDK\tools\mota build                    # configure in seconds, link in a minute
C:\MotaSDK\tools\mota run --headless --frames=120
C:\MotaSDK\tools\mota package

The scaffolded CMakeLists.txt detects which kind of root MOTA_ENGINE_ROOT is (engine/CMakeLists.txt = source checkout -> add_subdirectory; cmake/MotaEngineConfig.cmake = prebuilt SDK -> find_package(MotaEngine CONFIG)) and the mota CLI picks the matching presets (windows-sdk / sdk-debug| development|shipping), so the same project builds against either. The SDK libraries carry the ABI the config file asserts: C++20, /GR- /EHsc /utf-8, MSVC dynamic runtime, editor off (the visual editor is not part of the SDK; MOTA_EDITOR stays undefined). Mobile targets still need the source SDK; mota package -Platform android|ios is refused from an SDK root.

Linux x64 (dedicated server)

tools/make_sdk_linux.sh (on a Linux host with the linux-server prerequisites + VCPKG_ROOT) builds the engine with the linux-server settings (Clang, editor OFF) into ~/mota-build-sdk and produces dist/sdk/MotaEngineSDK-<ver>-linux-x64.tar.gz (~285 MB; 18 libMota*.a per configuration, all public headers, the static x64-linux vcpkg tree, the same cmake/MotaEngineConfig.cmake, StormForge, docs, legal texts). A game's server builds against it with only clang 18+, CMake, ninja and libvulkan-dev - no vcpkg, no engine compile. Every project carries a linux-sdk configure preset (+ linux-sdk-debug|shipping):

cd MotaEngineSDK-<ver>-linux-x64/samples/StormForge   # or: export MOTA_ENGINE_ROOT=<sdk>
cmake --preset linux-sdk
cmake --build --preset linux-sdk-shipping           # ~1 min; a 28 MB server binary

The Shipping binary depends only on libc, libstdc++, libm and the Vulkan loader (no GPU needed). On Linux the engine libraries link as one $<LINK_GROUP:RESCAN,...> (GNU ld resolves archives in order and they call each other in cycles). samples/StormForge/Dockerfile.sdk builds a server image from the SDK root (docker build -f samples/StormForge/Dockerfile.sdk .); a game copies it and points the COPY lines at its own project.

Dedicated server Docker image (Linux)

The repo-root Dockerfile builds MotaServer with the linux-server preset (Clang, editor off, Shipping = -O3 + LTO) in a builder stage and ships it in a minimal Ubuntu 24.04 runtime with the DemoGame + LibertySlice projects, a non-root user, tini for signal forwarding, and a HEALTHCHECK that probes the engine's --health-port endpoint:

docker build -t motaware/server .                          # vcpkg ports are a cached layer
docker run --rm -p 27015:27015/udp -p 8080:8080 motaware/server
docker run --rm -p 27015:27015/udp -p 8080:8080 -v mota-saved:/opt/motaware/Saved \
    motaware/server --project=projects/LibertySlice/LibertySlice.motaproj --max-connections=64
curl -fsS http://localhost:8080/health                     # 200 {"status":"ok"}

Arguments after the image name are appended to the engine command line (the ENTRYPOINT fixes --port=27015 --health-port=8080 --health-bind=0.0.0.0). Mount /opt/motaware/Saved so the server identity key (Saved/Net/server_identity.key) survives restarts and clients that pinned it with --server-key keep connecting. docker stop delivers SIGTERM through tini; the health endpoint flips to 503 draining and the engine shuts down cleanly. A game built from its own repo copies this file, replaces the COPY --from=build lines with its own executable and project directory, and keeps the rest. .dockerignore keeps build output, editor state, mobile signing files and the render baselines out of the build context.

The game module

Your game implements mota::app::IGameModule (<motaware/app/game_module.h>):

MethodWhen
bool Initialize(const GameModuleContext&)Once, after every engine subsystem is up and the project is opened. Seed your world here. Return false to abort.
void Update(f64 dt)Every fixed simulation tick (60 Hz default; --tickrate on servers).
void UpdateHUD()Every render frame — sync game state into screens/HUD.
void UpdateWeather(f64 totalGameTime)Every render frame.
void CollectDebugLines(std::vector<DebugLine>&)Every render frame — debug overlay.
void Shutdown()Once, before engine teardown.

Register it in your main.cpp before RunEngineMain:

mota::app::GameModuleRegistry::Instance().Register(
    "MyGame", []() { return std::make_unique<MyGameModule>(); });

GameModuleContext

Every subsystem your module may touch, handed to Initialize. Null pointers are meaningful — check them:

FieldTypeNull when
worldcore::World*never
rendererrhi::Renderer*--headless / --server (no GPU)
physicsWorldphysics::PhysicsWorld*physics init failed
clothSimulator, ropeSimulator--headless / --server
particleManager, particleForceManager, vfxManager, decalManager--headless / --server
weatherSystemrhi::WeatherSystem*never
audioEngine, audioGraph, musicSystemnever (null audio device in headless)
scriptRuntimeeditor::VisualScriptRuntime*never
scriptEnginescripting::ScriptEngine*Lua failed to initialize (runs ScriptComponent scripts; see lua-api.md)
editoreditor::EditorApp*always in SDK builds (the editor is not included); game mode, headless
screenManagerscreens::ScreenManager*--headless / --render-test
cameraManagergameplay::CameraManager*never
windowpal::Window*never a null pointer, but never created in headless
networkManagernet::NetworkManager*process has no network role (see below)
mobileRenderTieri32never null; -1 on desktop (or --mobile-tier=off), else 0 Low / 1 Medium / 2 High. Scale your own content (crowds, draw distance) with it
savedDirectorystd::stringtests (empty). The writable per-install Saved/ dir: next to the exe on desktop, the app sandbox on Android/iOS. Put profiles and save games under it, never under GetExecutableDirectory()
requestQuitstd::function<void()>tests

Write headless-safe modules: guard every renderer/screenManager use. A module that does boots identically under a window, --headless, and the dedicated server.

ECS quick reference

core::World& world = *ctx.world;
EntityID e = world.CreateEntity();
world.AddComponent<Transform>(e, {.position = Vec3(0, 1, 0)});
world.AddComponent<rhi::MeshComponent>(e, {});          // default = unit cube
world.AddComponent<rhi::MaterialParams>(e, mat);
world.RegisterSystem({ .name = "...", .phase = SystemPhase::Update,
                       .execute = [](core::World& w, f64 dt) { ... } });
w.ForEach<Query<All<Transform, MyComp>>>([](EntityID, Transform& t, MyComp& c) { ... });

Components must be trivially copyable + default constructible. Heavy runtime state (physics bodies, animation state machines) lives in side storage keyed by u32 indices — see CLAUDE.md's Side Storage pattern.

Command line

FlagMeaning
--project=<path.motaproj>Open a project (loads its gameModule).
--gameStandalone play mode: fullscreen, HUD.
--pak=<file.pak>Mount a pak before the project opens.
--headlessNo window/GPU/screens; fixed-timestep simulation.
--serverImplies --headless; listens on --port (default 27015).
--port=NServer listen port.
--connect=<host[:port]>Join a server as a client.
--tickrate=NHeadless fixed-step override, 1–240 Hz.
--max-connections=NServer player cap, 1–1024 (default 32); a joiner beyond it is denied.
--snapshot-rate=NServer replication snapshots per second, 1–240 (default 30).
--replication=delta|fullPer-connection reliable deltas (default) or the legacy unreliable full-state broadcast.
--sim-loss=F / --sim-latency=MS / --sim-jitter=MSTesting: impair this process's outbound datagrams (drop fraction 0-1, one-way hold, uniform jitter). Run on both ends for a symmetric link.
--net-profileAccumulate CPU timers around the UDP path (serialize, frame, build, seal, sendto, receive); table at shutdown and on net.profile.
--health-port=NServe GET /health (200/503), /status (JSON) and /metrics (Prometheus) over HTTP for orchestrators and dashboards; off by default.
--health-bind=ADDRInterface for --health-port (default 0.0.0.0).
--frames=NAuto-quit after N frames (CI/smoke).
--exec="cmd1;cmd2"Run console commands before the first frame.
--cook=<dir> / --cook-out=<dir>Headless cook commandlet.
--package=<proj> / --out=<dir> / --exe-name=<n>Cook + pak + stage a shippable folder.
--touch-controls / --no-touch-controlsOn-screen virtual gamepad (stick + A/B/X/Y + pause) over the HUD. Default on for Android/iOS; on desktop --touch-controls enables it with the mouse as finger 0.
--cook-textures=<list>Texture siblings the cook emits: comma list of bc7, astc, etc2, or all / none / default (default bc7,astc).
--mobile-tier=low|medium|high|offMobile render tier: render scale, shadow-map size, the lite scene shader (Low: r.scene.lite, a PBR fragment stage without light shadow atlases / area lights / probes and a 4-tap cascade PCF) and the optional passes (Lumen, TAA, bloom, SSR, god rays, aerial perspective, composite post-fx) per core::platform::DefaultMobileProfile. Android defaults to low, iOS to medium, desktop off. Games read ctx.mobileRenderTier.
--cook-mobile-tier=low|medium|highMobile cook config for the ASTC/ETC2 siblings: size cap + ASTC block (default medium: 2048, 6x6 colour / 4x4 normal maps).
--locale=<tag> / --screenshot=<png>See the user manual, section 19.

Networking

The engine creates a net::NetworkManager over the real UDP transport when the process has a network role (--server / --connect), ticks it every frame (receive, dispatch, handshake, ping, reliable retransmission), and hands it to your module via ctx.networkManager — along with a ReplicationManager (ctx.replicationManager) and an RPCManager (ctx.rpcManager).

What a dedicated server boots

--headless creates no window, renderer, editor, screens, Gui, particle, VFX, decal, cloth or rope systems, and no shader hot-reload watcher. --server additionally skips the audio engine and music system and caps the job pool at 4 workers (--workers=N overrides, 1–64; a client defaults to cores − 1). The corresponding GameContext pointers (vfxManager, particleManager, decalManager, clothSimulator, ropeSimulator, audioEngine, musicSystem) are null in those modes — null-check them in module code that also runs on the server. weatherSystem and cameraManager are always present.

net.status reports role, peers, ping, transport byte counters, and the replication snapshot counters (ReplicationManager::GetStats()); the serializer no longer logs a line per tick.

Health / status endpoint

--health-port=N (--health-bind=ADDR, default 0.0.0.0) starts a small HTTP server on its own thread (cpp-httplib, two worker threads) for the questions an orchestrator, load balancer or dashboard asks a server:

RouteAnswer
GET /health200 {"status":"ok",...} while the main loop published a snapshot within the last 5 s and, for a server, the UDP socket is bound. 503 {"status":"unhealthy","reason":...} otherwise: no snapshot published yet, main loop stalled: last snapshot N s ago, draining: shutdown in progress, server not listening.
GET /statusJSON: engine (version, build config, platform, pid, uptime), game (project, module), loop (frames, ticks, sim Hz, frame busy/period ms over the last 0.5 s), network (role, port, connections/max, snapshot rate, replication mode, server key, byte/datagram/failure/resend counters, replication stats, per-peer ping) and process resident memory.
GET /metricsThe same numbers as Prometheus text exposition (mota_up, mota_frames_total, mota_net_connections, mota_net_bytes_total{direction=...}, mota_replication_deltas_total{direction=...}, mota_net_peer_ping_milliseconds{connection,player}, mota_process_resident_bytes, mota_info{version,build,platform,role,project,module}, ...).

The main thread publishes a HealthSnapshot (motaware/app/health_endpoint.h) every 0.5 s; handlers only copy the latest one, so a wedged main loop still answers, with 503. On shutdown the engine publishes draining first so balancers stop routing before the sockets close, and stops the endpoint last. A bind failure aborts boot, like the UDP port. The server's identity public key appears in /status because clients pin it anyway; the endpoint is unauthenticated, so bind it to a management interface or firewall it.

Connection lifetime

Connected peers ping each other once a second. A peer that stays silent for NetworkConfig::connectionTimeout seconds (default 10; 0 disables) is dropped by the manager's timeout reaper and a DisconnectionEvent with reason "Timed out" fires. This covers crashed clients, a vanished server (the client sees the same event), and half-open peers that never finished the handshake. A clean exit is faster: the engine calls NetworkManager::Disconnect() at shutdown, which sends a Disconnect packet so the other side frees the slot on its next tick (reason "Disconnected"). Subscribe to DisconnectionEvent on the EventBus to react — for example, to despawn a departed player's pawn.

The engine also routes Ctrl+C, Ctrl+Break, a closed console window, and (POSIX) SIGINT/SIGTERM through the same shutdown path, so a dedicated server stopped by a process manager says goodbye to its peers and flushes saves and telemetry instead of dying mid-tick.

A CLIENT that loses its server reconnects on its own. After the loss (a timeout, or the Disconnect a shutting-down server sends) it waits NetworkConfig::reconnectDelay seconds (default 2) and connects again, up to reconnectAttempts times (default 5; 0 disables); each attempt has connectionTimeout seconds to complete its handshake. The original loss is the only DisconnectionEvent; every attempt, the success, and giving up are ReconnectEvents (attempt, maxAttempts, succeeded, gaveUp), and a success also fires the usual ConnectionEvent. After the last failure the session shuts down (IsRunning() false). IsReconnecting() and GetReconnectAttempt() expose the state for a HUD; net.status prints it. Each accepted connection bumps GetSessionGeneration(): the replication apply system watches it and destroys the entities built from the previous session before applying the new server's snapshots (ReplicationManager::ClearNetworkEntities), since a restarted server assigns network IDs from scratch. Game code holding its own per-session state should do the same on a generation change. A client's own Disconnect() never reconnects, and a pinned server key still applies to the restarted server (its identity file makes that the same key). A ConnectionDenied from a full server is final: the client reports it once (DisconnectionEvent reason "Denied" on a first connect, or a ReconnectEvent with gaveUp during a reconnect) and shuts the session down at once rather than retrying.

Transport security

Every UDP datagram after the handshake is encrypted and authenticated (libhydrogen: Curve25519 key exchange, Gimli AEAD, 64-message anti-replay window). Nothing in the game protocol - replication, RPCs, pings - ever travels in the clear.

Server identity. A dedicated server has a persistent key pair in Saved/Net/server_identity.key (generated on first boot; back it up and keep it private - it is the seed). The public key is logged at startup and shown by net.status. Clients pin it with --server-key=<64 hex> (or NetworkConfig::serverPublicKeyHex); a pinned client refuses any other server, which defeats an active man-in-the-middle. Without pinning the session is still encrypted - a sniffer sees nothing, tampering and injection are rejected, replays are dropped - but an on-path impostor could stand in for the server, and the client logs a warning saying so.

Handshake and admission. The 5-byte protocol prefix (magic "MOTA" + UDPTransport::kProtocolVersion, now 2) is followed by a kind byte:

kinddirectionbody
Helloclient -> server64 bytes padding
HelloReplyserver -> clientserver public key (32) + cookie (16)
KeyExchangeclient -> servercookie (16) + key-exchange packet (48)
Databothcounter (8) + ciphertext + tag

The server keeps no state for a Hello: the cookie is a keyed hash of the source address, so only a peer that can actually receive at that address can echo it, and the padding keeps the reply smaller than the request (no amplification). A peer - address, reliable channel, session keys - is allocated only on a KeyExchange with a valid cookie and below UDPTransport::SetMaxPeers (the engine sets maxConnections + 8). Data from an unknown address, plaintext, a wrong key, a flipped bit, or a replay is dropped and counted (GetRejectedDatagramCount(), also in net.status). A client accepts datagrams only from its server. A client built against a different protocol version is dropped silently and sees its connection time out. Bump kProtocolVersion whenever the wire format changes incompatibly.

Datagram admission

Every UDP datagram starts with a 5-byte prefix: the protocol magic "MOTA" and UDPTransport::kProtocolVersion. Datagrams without it are dropped before any per-peer state exists. A server seats an unknown source only if its datagram carries a ConnectionRequest and the peer table is below UDPTransport::SetMaxPeers (the engine sets maxConnections + 8); a client accepts traffic only from the server it connected to. A client built against a different protocol version is dropped silently and sees its connection time out. UDPTransport::GetRejectedDatagramCount() counts refusals. Bump kProtocolVersion whenever the wire format changes incompatibly.

Message size and fragmentation

A UDP datagram is at most UDPTransport::kMaxPacketSize (1400 bytes) on the wire; anything larger is truncated by the receiving socket and then fails authentication. The per-peer ReliableChannel therefore budgets every datagram it builds (kMaxChannelPacketSize: 1400 minus the protocol prefix, kind byte, counter and AEAD tag). Messages that do not fit wait for the next datagram — SendTo keeps flushing until nothing due is left, up to kMaxDatagramsPerFlush per call — and a single message larger than one datagram carries is split into fragments (kFlagFragment + a 6-byte group/index/count header) and reassembled on the far side. Reliable fragments ride the ordered stream, so a large RPC arrives whole exactly once; an unreliable message such as a replication snapshot is delivered only when every fragment arrives and is dropped otherwise (an incomplete group is forgotten after ReliableChannel::kFragmentGroupTimeout). Game code sees none of this: SendPacket/BroadcastPacket accept any payload up to the Packet limit (64 KB). net.reptest <N> (server only) spawns N replicated Transform entities so a snapshot spans several datagrams — a quick real-socket check of the path.

Replication

The engine registers Transform for replication and wires the ECS systems: NetworkConfig::tickRate times per second (default 30; --snapshot-rate=N; 0 = every simulation step) the server captures one snapshot of the replicated state and sends every connection the delta from the snapshot that connection was last sent: only components whose serialized bytes changed, plus the network ids of entities that vanished. Deltas travel on the reliable ordered channel (each assumes the previous one was applied), a new connection's first delta is the full state, and a connection for which nothing changed receives nothing - so idle entities cost no bandwidth. NetworkConfig::deltaReplication = false (or --replication=full) restores the legacy behaviour: an unreliable broadcast of the full state every snapshot. Clients apply either form to their world automatically (ReplicationManager::ApplyDelta / DeserializeState); net.status shows deltas= / bytesOut= / deltasApplied=. Component removal is not expressed by a delta (entity removal is).

To replicate an entity, the SERVER gives it a ReplicatedComponent with an assigned network ID:

world.AddComponent<net::ReplicatedComponent>(
    e, net::ReplicatedComponent{ctx.replicationManager->AssignNetworkID()});

The entity (and its registered components) then appears and stays updated in every connected client's world. Register additional component types with ctx.replicationManager->RegisterType<MyComp>("MyComp") — in the same order on server and client: the wire identifies types by registration index, because C++ type IDs are process-local.

RPCs

// Both sides register (same order — same contract as replication):
ctx.rpcManager->RegisterRPC("explode", net::RPCType::MulticastRPC,
    [](core::BinaryReader& args) { ... });   // handler runs on the receiver

// Server fires it at all clients:
core::BinaryWriter args;  args.WriteU32(entityNetID);
core::BinaryWriter body;  ctx.rpcManager->CallRPC("explode", args, body);
net::Packet p(net::PacketType::RPC);
p.GetWriter().WriteBytes(body.GetBuffer().data(), body.GetSize());
p.Finalize();
ctx.networkManager->BroadcastPacket(p, /*reliable=*/true);
if (auto* net = m_ctx.networkManager) {
    if (net->GetRole() == net::NetworkRole::Server) {
        net::Packet p(net::PacketType::RPC);
        p.GetWriter().WriteU32(kSpawnMsg);
        p.Finalize();
        net->BroadcastPacket(p, /*reliable=*/true);
    }
    net->RegisterPacketHandler(net::PacketType::RPC,
        [](net::ConnectionID from, const net::Packet& p) { ... });
}

Snapshot interpolation, interest management (replication_graph.h), delta compression, and lag compensation live in <motaware/net/...> as libraries driven by game code. The net.status console command dumps the live session (role, peers, ping, traffic).

Mesh LODs (runtime decimation)

<motaware/rhi/mesh_simplify.h> ships a quadric-error edge-collapse simplifier and a helper that turns one runtime mesh into an rhi::LODGroup chain:

rhi::MeshData base = ...;                       // the mesh you uploaded as LOD0
const u32 lod0 = renderer.AllocateRuntimeMesh(base);
const f32 ratios[2]     = { 0.40f, 0.15f };     // LOD1 / LOD2 triangle fractions
const f32 thresholds[3] = { 0.12f, 0.05f, 0.0f };  // screen-coverage ratio per level
rhi::LODGroup group = rhi::BuildRuntimeLODGroup(renderer, lod0, base, ratios, 2, thresholds,
                                                /*boundingRadius=*/0.9f,
                                                [](rhi::MeshData& m) { /* optional per-LOD post-process */ });
world.AddComponent<rhi::LODGroup>(entity, group);   // next to the entity's MeshComponent

SimplifyMesh(mesh, ratio) is the primitive: it welds positions (so split normals, UV islands and per-face colours do not block collapses), collapses edges onto existing vertices (corners keep their own attributes; nothing is interpolated), holds open borders with a constraint plane, and rejects any collapse that would invert an adjacent triangle. Bone weights survive, so a skinned bind mesh can be decimated too. The renderer picks the level per entity from the group's boundingRadius and the camera distance (LODGroup::SelectLOD); a coverage of 0.05 is roughly "the object spans 5% of the screen height". LibertySlice uses this for its crowd (720-triangle peds at 40% / 15%): on a Galaxy A17 the 120 title-screen peds went from 6 ms to 2.4 ms of the scene pass, because sub-pixel triangles shade as full 2x2 quads regardless of render scale.

Touch input (Android / iOS)

Native touches (Android MotionEvent on the SurfaceView, iOS UITouch on the Metal view) are queued on the UI thread (pal::mobile::TouchQueue) and drained by the engine's input phase. Two consumers, in order:

  1. Virtual gamepad (input::TouchControls), active while the HUD is the top screen: a left thumbstick that re-centres under the thumb and drives GamepadAxis::LeftX/LeftY (SDL convention, up = -1), plus A / B / X / Y and a pause button (GamepadButton::A/B/X/Y/Start). A finger that begins inside a zone is owned by it until it lifts. Bind gamepad sources in your InputContext and the same code path serves controllers: PadAxis(GamepadAxis::LeftY, -1.0f) for "forward", Pad(GamepadButton::A) for jump, and so on (LibertySlice's SetupInput is the reference).
  2. Pointer emulation (InputSystem::InjectTouch): every other touch. The primary finger moves the mouse; a lift that qualifies as a tap (short, little movement) presses MouseButton::Left for one frame at the tap point, so every mouse-driven menu and HUD hit-test works without changes; a drag accumulates mouse delta (camera look) and never clicks. GetTouches() lists live fingers and DrainTouchGestures() hands out the TouchTracker gestures (tap / long-press / pan / pinch / swipe) for game-specific handling.

The overlay lays out on the runtime UI's 1920x1080 canvas and is mapped to the surface with the renderer's pillarbox (input::ComputeUICanvasTransform). --no-touch-controls hides it on a device (touches stay pointer input); --touch-controls on desktop shows it with the mouse standing in for one finger, which is how the layout is checked without a phone.

Asset cooking

mota package (or --package) cooks content through the production cookers before pak-ing: textures gain block-compressed siblings with pre-baked mip chains, models become binary MMSH files loaded without Assimp. Dev runs use loose source assets; the runtime prefers cooked artifacts automatically when they resolve through the VFS. Sources, build output, and docs never ship.

Every package also gets THIRD_PARTY_NOTICES.md (the engine's component attributions) and MOTAENGINE_LICENSE.md next to the exe. They are embedded into the engine at build time (engine/app/private/embed_legal_texts.cmake from the repo's LICENSE.md + THIRD_PARTY_NOTICES.md), so packaging needs no engine checkout, and a package without them is a failed package (non-zero exit). The pak itself never contains them. Mobile packages (-Platform android|ios) take only the pak from the staging folder; the store listing or in-app credits carry the notices there.

Texture siblings, selected with --cook-textures (default bc7,astc):

SiblingEncoderSampled by
<tex>.dds (BC7, 4x4)DirectXTexdesktop GPUs (textureCompressionBC)
<tex>.astc.ktx2 (ASTC 6x6 colour, 4x4 normal maps)libktx + ARM astcencMali, Adreno, Apple, every Vulkan-era phone (textureCompressionASTC_LDR)
<tex>.etc2.ktx2 (ETC2+EAC RGBA8, 4x4)libktx UASTC encode + Basis transcodelegacy Android without ASTC (textureCompressionETC2); opt-in

The runtime takes the first family its device samples, in that order, and falls back to the source image (which always ships, since some game code CPU-reads it). Block data is encoded UNORM; the sRGB/linear view is chosen per load call. Normal maps are detected by filename (_n, _nrm, _norm, _normal, or normal in the stem). The ASTC/ETC2 base level is capped to the mobile tier's texture size (--cook-mobile-tier; the Medium default caps at 2048) by dropping the largest mips, so a 4096 desktop texture ships as 2048 on mobile. Cooking needs a Windows host (DirectXTex + libktx are host-only deps); the runtime reads DDS and KTX2 with its own parsers on every platform.