Performance
Board hardware (MediaTek Genio 700, 4 GB RAM, low-end mobile-class GPU) is a fraction as fast as a desktop GPU. Games that run smoothly in the Godot editor can hit 25 fps on the device unless you pay attention to a few specific things. This guide is the checklist.
Frame Rate
Board’s display runs at 60 Hz, and Godot defaults to matching display refresh — you do not need to set Engine.max_fps = 60 explicitly. If you want physics to run in lockstep with rendering, keep the default physics_ticks_per_second = 60 in project.godot.
[physics]
common/physics_ticks_per_second=60
If you’ve been tempted to uncap the frame rate (“just run as fast as possible”), don’t — the Genio 700 isn’t going to render faster than 60 fps for any non-trivial scene, and uncapping wastes power and heats the SoC.
Renderer Choice
Use the GL Compatibility renderer on Board, not Vulkan / Forward+. Vulkan and Forward+ aren’t tuned for the Genio 700 SoC and will compile shaders slowly at startup.
In project.godot:
[rendering]
renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility"
This is what every working Board Godot game ships with today. Don’t override it without measurement.
Cold-Boot Performance: noCompress on PCK
This is a one-time setup step. After installing the Custom Android Build Template (Project → Install Android Build Template), add noCompress "pck", "sparsepck" to the aaptOptions block in android/build/build.gradle, right after the ignoreAssetsPattern line:
android {
aaptOptions {
ignoreAssetsPattern "..."
noCompress "pck", "sparsepck"
}
}
Godot’s stock template doesn’t set this. Without it, the PCK is deflated inside the APK and the CPU has to inflate it on first launch — cold boot takes ~2 minutes instead of a few seconds. With it set, the PCK can be memory-mapped at boot.
Parallel Asset Prewarming
Large textures and audio streams block the first frame they’re used unless they’ve already been touched. Spin up a boot scene that uses WorkerThreadPool to prewarm them while a splash screen renders.
const ASSETS_TO_PREWARM := [
"res://art/title.png",
"res://audio/theme.ogg",
"res://audio/sfx/place.wav",
]
func _ready() -> void:
var group_id := WorkerThreadPool.add_group_task(
_prewarm_one, ASSETS_TO_PREWARM.size(), 4, false, "prewarm")
while not WorkerThreadPool.is_group_task_completed(group_id):
await get_tree().process_frame
get_tree().change_scene_to_file("res://scenes/main.tscn")
func _prewarm_one(index: int) -> void:
ResourceLoader.load(ASSETS_TO_PREWARM[index])
Note: Player avatars decode to
ImageTextureand are cached after first load. Prewarm the ones you’ll display by callingawait Board.avatar.await_load_avatar(int(player.avatar_id))for each player fromBoard.session.get_players()in the boot scene — the loader keeps the decoded textures in its own cache, so later lookups are instant.
Threaded Scene Loads
For scenes too large to load synchronously, use ResourceLoader.load_threaded_request:
func change_scene_async(path: String) -> void:
ResourceLoader.load_threaded_request(path)
while true:
var progress: Array = []
match ResourceLoader.load_threaded_get_status(path, progress):
ResourceLoader.THREAD_LOAD_LOADED:
get_tree().change_scene_to_packed(
ResourceLoader.load_threaded_get(path))
return
ResourceLoader.THREAD_LOAD_FAILED, \
ResourceLoader.THREAD_LOAD_INVALID_RESOURCE:
return
_:
await get_tree().process_frame
This keeps your loading screen animating while Godot pulls the scene off disk on a background thread.
Hot-Path Allocations
contacts_received fires up to 60 times per second. Anything you allocate in that handler runs through the garbage collector, and GC pauses are visible on a constrained device.
Reuse buffers rather than allocating per frame:
var _by_glyph: Dictionary = {}
func _on_contacts(contacts: Array) -> void:
_by_glyph.clear() # reuse, don't reallocate
for c in contacts:
_by_glyph[c.glyph_id] = c
_process_by_glyph(_by_glyph)
The entries in contacts are raw Dictionary values — that’s deliberate, since wrapping each of the up-to-60-per-frame contacts in an object would allocate on the hot path. If you need typed access for a non-hot-path read, wrap a single contact with BoardContact.from_dict(c) (which exposes the BoardContact.Type / BoardContact.Phase enums plus the facing() -> Vector2 and is_piece() helpers) — but don’t convert the per-frame loop itself.
For numeric data, prefer PackedFloat32Array / PackedByteArray over generic Array — typed arrays avoid Variant boxing. Don’t print() in hot paths; the Android log pipe takes a mutex and drops frames.
Texture Settings
Most 2D assets render fine on Board with default import settings. Two cases to override:
- Large textures: cap above ~1024×1024 against the Genio 700’s texture budget. Lower the maximum size in the import inspector for non-critical assets.
- UI atlases: pack small sprites into a single atlas. The GL Compatibility renderer batches draws sharing a texture, so atlasing turns 30 draw calls into 1.
Audio
For short SFX (tap, place, lift), preload as AudioStreamWAV so it caches as raw PCM:
var _sfx_place: AudioStreamWAV = preload("res://audio/sfx/place.wav")
For background music and voice lines, AudioStreamOggVorbis is fine — decode runs on a separate thread.
Profiling on Device
Godot’s built-in profiler runs over the editor’s remote debugger. Export the APK (Project → Export Project… → Android, or godot --headless --export-debug "Android" game.apk) and install it to the Board — drag the .apk onto the Board Connect web UI in your browser (the Board shows its address under Settings → System), or have an agent install it with the board-connect CLI (board-connect install game.apk). Then open Debug → Open Profiler in the editor and point remote debug at the device’s IP. Watch:
- Frame Time — should be ≤16.67 ms for 60 fps.
- Process Frame Time — your
_process/_physics_processcost. - Render Time — GPU work. High here means draw-call or texture-size pressure.
Always profile on real hardware. A scene at 90 fps in the editor can hit 25 fps on Board because the desktop GPU is 5–50× faster.
Optimization Checklist
| Concern | Action |
|---|---|
| Renderer | gl_compatibility set in project.godot |
| PCK compression | noCompress "pck", "sparsepck" added to build.gradle aaptOptions (one-time setup) |
| Boot | Boot scene with WorkerThreadPool prewarms big assets |
| Scene transitions | ResourceLoader.load_threaded_request for any scene >2 MB |
| Hot paths | No Dictionary allocation or print() in _on_contacts |
| Audio | SFX as preloaded AudioStreamWAV |
| Textures | No texture >1024 unless necessary; UI packed into atlases |
| Profiling | Profiled on real hardware — not the editor |
See Also
- Touch Input — the API that delivers the per-frame load
- Off-Device Development — what won’t transfer between desktop and device
- Save Games — the other big I/O cost in a Board game