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>):
| Method | When |
|---|---|
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:
| Field | Type | Null when |
|---|---|---|
world | core::World* | never |
renderer | rhi::Renderer* | --headless / --server (no GPU) |
physicsWorld | physics::PhysicsWorld* | physics init failed |
clothSimulator, ropeSimulator | --headless / --server | |
particleManager, particleForceManager, vfxManager, decalManager | --headless / --server | |
weatherSystem | rhi::WeatherSystem* | never |
audioEngine, audioGraph, musicSystem | never (null audio device in headless) | |
scriptRuntime | editor::VisualScriptRuntime* | never |
scriptEngine | scripting::ScriptEngine* | Lua failed to initialize (runs ScriptComponent scripts; see lua-api.md) |
editor | editor::EditorApp* | always in SDK builds (the editor is not included); game mode, headless |
screenManager | screens::ScreenManager* | --headless / --render-test |
cameraManager | gameplay::CameraManager* | never |
window | pal::Window* | never a null pointer, but never created in headless |
networkManager | net::NetworkManager* | process has no network role (see below) |
mobileRenderTier | i32 | never null; -1 on desktop (or --mobile-tier=off), else 0 Low / 1 Medium / 2 High. Scale your own content (crowds, draw distance) with it |
savedDirectory | std::string | tests (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() |
requestQuit | std::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
| Flag | Meaning |
|---|---|
--project=<path.motaproj> | Open a project (loads its gameModule). |
--game | Standalone play mode: fullscreen, HUD. |
--pak=<file.pak> | Mount a pak before the project opens. |
--headless | No window/GPU/screens; fixed-timestep simulation. |
--server | Implies --headless; listens on --port (default 27015). |
--port=N | Server listen port. |
--connect=<host[:port]> | Join a server as a client. |
--tickrate=N | Headless fixed-step override, 1–240 Hz. |
--max-connections=N | Server player cap, 1–1024 (default 32); a joiner beyond it is denied. |
--snapshot-rate=N | Server replication snapshots per second, 1–240 (default 30). |
--replication=delta|full | Per-connection reliable deltas (default) or the legacy unreliable full-state broadcast. |
--sim-loss=F / --sim-latency=MS / --sim-jitter=MS | Testing: impair this process's outbound datagrams (drop fraction 0-1, one-way hold, uniform jitter). Run on both ends for a symmetric link. |
--net-profile | Accumulate CPU timers around the UDP path (serialize, frame, build, seal, sendto, receive); table at shutdown and on net.profile. |
--health-port=N | Serve GET /health (200/503), /status (JSON) and /metrics (Prometheus) over HTTP for orchestrators and dashboards; off by default. |
--health-bind=ADDR | Interface for --health-port (default 0.0.0.0). |
--frames=N | Auto-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-controls | On-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|off | Mobile 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|high | Mobile 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:
| Route | Answer |
|---|---|
GET /health | 200 {"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 /status | JSON: 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 /metrics | The 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:
| kind | direction | body |
|---|---|---|
| Hello | client -> server | 64 bytes padding |
| HelloReply | server -> client | server public key (32) + cookie (16) |
| KeyExchange | client -> server | cookie (16) + key-exchange packet (48) |
| Data | both | counter (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:
- Virtual gamepad (
input::TouchControls), active while the HUD is the top screen: a left thumbstick that re-centres under the thumb and drivesGamepadAxis::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 yourInputContextand the same code path serves controllers:PadAxis(GamepadAxis::LeftY, -1.0f)for "forward",Pad(GamepadButton::A)for jump, and so on (LibertySlice'sSetupInputis the reference). - Pointer emulation (
InputSystem::InjectTouch): every other touch. The primary finger moves the mouse; a lift that qualifies as a tap (short, little movement) pressesMouseButton::Leftfor 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 andDrainTouchGestures()hands out theTouchTrackergestures (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):
| Sibling | Encoder | Sampled by |
|---|---|---|
<tex>.dds (BC7, 4x4) | DirectXTex | desktop GPUs (textureCompressionBC) |
<tex>.astc.ktx2 (ASTC 6x6 colour, 4x4 normal maps) | libktx + ARM astcenc | Mali, Adreno, Apple, every Vulkan-era phone (textureCompressionASTC_LDR) |
<tex>.etc2.ktx2 (ETC2+EAC RGBA8, 4x4) | libktx UASTC encode + Basis transcode | legacy 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.
