| name | godot-adapt-desktop-to-mobile |
| description | Expert patterns for porting desktop games to mobile including touch control schemes (virtual joystick, gesture detection), UI scaling for small screens, performance optimization for mobile GPUs, battery life management, and platform-specific features. Use when creating mobile ports or cross-platform mobile builds. Trigger keywords: TouchScreenButton, virtual_joystick, gesture_detector, InputEventScreenTouch, InputEventScreenDrag, mobile_optimization, battery_saving, adaptive_performance, MOBILE_ENABLED. |
Adapt: Desktop to Mobile
Expert guidance for porting desktop games to mobile platforms.
NEVER Do
- NEVER use mouse position directly — Touch has no "hover" state. Replace mouse_motion with screen_drag and check InputEventScreenTouch.pressed.
- NEVER keep small UI elements — Apple HIG requires 44pt minimum touch targets. Android Material: 48dp. Scale up buttons 2-3x.
- NEVER forget finger occlusion — User's finger blocks 50-100px radius. Position critical info ABOVE touch controls, not below.
- NEVER run at full performance when backgrounded — Mobile OSs kill apps that drain battery in background. Pause physics, reduce FPS to 1-5 when app loses focus.
- NEVER use desktop-only features — Mouse hover, right-click, keyboard shortcuts, scroll wheel don't exist on mobile. Provide touch alternatives.
Available Scripts
MANDATORY: Read the appropriate script before implementing the corresponding pattern.
Expert Dynamic Virtual Joystick that appears exactly where the user touches the left half of the screen instead of relying on fixed UI positions.
Adaptive Viewport scaler that dynamically drops the scaling_3d_scale to maintain 60FPS on weak GPUs while keeping the 2D UI perfectly sharp.
Advanced touch gesture recognizer tracking duration, distance, and multi-touch ratios to output precise swipe and pinch-to-zoom signals.
Crucial lifecycle manager that hooks NOTIFICATION_APPLICATION_PAUSED to instantly lock Engine.max_fps = 1 and pause physics to prevent the OS from killing the app due to background battery drain.
Dynamic MarginContainer script querying DisplayServer.get_display_safe_area() to automatically pad UI elements around iPhone notches and Android hole-punch cameras.
Smooth Camera2D controller combining 1-finger relative panning and 2-finger distance-ratio pinch zooming simultaneously.
Centralized singleton triggering Input.vibrate_handheld for Android, and demonstrating the hook pattern for iOS native haptic plugins.
SceneTree crawler that strips expensive sub-surface scattering, clearcoats, and dynamic shading from StandardMaterial3D on weak mobile renderers.
Listens to DisplayServer.virtual_keyboard_update to tween the entire UI upward, preventing the OS keyboard from occluding LineEdits.
Memory-based save dictionary that bypasses NOTIFICATION_WM_CLOSE_REQUEST (which fails on mobile kill) and guarantees encrypted disk writes during the App Pause lifecycle.
Touch Control Schemes
Decision Matrix
| Genre | Recommended Control | Example |
|---|
| Platformer | Virtual joystick (left) + jump button (right) | Super Mario Run |
| Top-down shooter | Dual-stick (move left, aim right) | Brawl Stars |
| Turn-based | Direct tap on units/tiles | Into the Breach |
| Puzzle | Tap, swipe, pinch gestures | Candy Crush |
| Card game | Drag-and-drop | Hearthstone |
| Racing | Tilt steering or tap left/right | Asphalt 9 |
Virtual Joystick
# virtual_joystick.gd
extends Control
signal direction_changed(direction: Vector2)
@export var dead_zone: float = 0.2
@export var max_distance: float = 100.0
var stick_center: Vector2
var is_pressed: bool = false
var touch_index: int = -1
@onready var base: Sprite2D = $Base
@onready var knob: Sprite2D = $Knob
func _ready() -> void:
stick_center = base.position
func _input(event: InputEvent) -> void:
if event is InputEventScreenTouch:
if event.pressed and is_point_inside(event.position):
is_pressed = true
touch_index = event.index
elif not event.pressed and event.index == touch_index:
is_pressed = false
reset_knob()
elif event is InputEventScreenDrag and event.index == touch_index:
update_knob(event.position)
func is_point_inside(point: Vector2) -> bool:
return base.get_rect().has_point(base.to_local(point))
func update_knob(touch_pos: Vector2) -> void:
var local_pos := to_local(touch_pos)
var offset := local_pos - stick_center
# Clamp to max distance
if offset.length() > max_distance:
offset = offset.normalized() * max_distance
knob.position = stick_center + offset
# Calculate direction (-1 to 1)
var direction := offset / max_distance
if direction.length() < dead_zone:
direction = Vector2.ZERO
direction_changed.emit(direction)
func reset_knob() -> void:
knob.position = stick_center
direction_changed.emit(Vector2.ZERO)
Gesture Detection
# gesture_detector.gd
extends Node
signal swipe_detected(direction: Vector2) # Normalized
signal pinch_detected(scale: float) # > 1.0 = zoom in
signal tap_detected(position: Vector2)
const SWIPE_THRESHOLD := 100.0 # Pixels
const TAP_MAX_DISTANCE := 20.0
const TAP_MAX_DURATION := 0.3 # Seconds
var touch_start: Dictionary = {} # index → {position: Vector2, time: float}
var pinch_start_distance: float = 0.0
func _input(event: InputEvent) -> void:
if event is InputEventScreenTouch:
if event.pressed:
touch_start[event.index] = {
"position": event.position,
"time": Time.get_ticks_msec() * 0.001
}
else:
_handle_release(event)
elif event is InputEventScreenDrag:
_handle_drag(event)
func _handle_release(event: InputEventScreenTouch) -> void:
if event.index not in touch_start:
return
var start_data = touch_start[event.index]
var distance := event.position.distance_to(start_data.position)
var duration := (Time.get_ticks_msec() * 0.001) - start_data.time
# Tap detection
if distance < TAP_MAX_DISTANCE and duration < TAP_MAX_DURATION:
tap_detected.emit(event.position)
# Swipe detection
elif distance > SWIPE_THRESHOLD:
var direction := (event.position - start_data.position).normalized()
swipe_detected.emit(direction)
touch_start.erase(event.index)
func _handle_drag(event: InputEventScreenDrag) -> void:
# Pinch detection (requires 2 touches)
if touch_start.size() == 2:
var positions := []
for idx in touch_start.keys():
if idx == event.index:
positions.append(event.position)
else:
positions.append(touch_start[idx].position)
var current_distance := positions[0].distance_to(positions[1])
if pinch_start_distance == 0.0:
pinch_start_distance = current_distance
else:
var scale := current_distance / pinch_start_distance
pinch_detected.emit(scale)
pinch_start_distance = current_distance
UI Scaling
Responsive Layout
# Adjust UI for different screen sizes
extends Control
func _ready() -> void:
get_viewport().size_changed.connect(_on_viewport_resized)
_on_viewport_resized()
func _on_viewport_resized() -> void:
var viewport_size := get_viewport_rect().size
var aspect_ratio := viewport_size.x / viewport_size.y
# Adjust for different aspect ratios
if aspect_ratio > 2.0: # Ultra-wide (tablets in landscape)
scale_ui_for_tablet()
elif aspect_ratio < 0.6: # Tall (phones in portrait)
scale_ui_for_phone()
# Adjust touch button sizes
for button in get_tree().get_nodes_in_group("touch_buttons"):
var min_size := 88 # 44pt * 2 for Retina
button.custom_minimum_size = Vector2(min_size, min_size)
func scale_ui_for_tablet() -> void:
# Spread UI to edges, use horizontal space
$LeftControls.position.x = 100
$RightControls.position.x = get_viewport_rect().size.x - 100
func scale_ui_for_phone() -> void:
# Keep UI at bottom, vertically compact
$LeftControls.position.y = get_viewport_rect().size.y - 200
$RightControls.position.y = get_viewport_rect().size.y - 200
Performance Optimization
Mobile-Specific Settings
# project.godot or autoload
extends Node
func _ready() -> void:
if OS.get_name() in ["Android", "iOS"]:
apply_mobile_optimizations()
func apply_mobile_optimizations() -> void:
# Reduce rendering quality
get_viewport().msaa_2d = Viewport.MSAA_DISABLED
get_viewport().msaa_3d = Viewport.MSAA_DISABLED
get_viewport().screen_space_aa = Viewport.SCREEN_SPACE_AA_DISABLED
# Lower shadow quality
RenderingServer.directional_shadow_atlas_set_size(2048, false) # Down from 4096
# Reduce particle counts
for particle in get_tree().get_nodes_in_group("godot-particles"):
if particle is GPUParticles2D:
particle.amount = max(10, particle.amount / 2)
# Lower physics tick rate
Engine.physics_ticks_per_second = 30 # Down from 60
# Disable expensive effects
var env := get_viewport().world_3d.environment
if env:
env.glow_enabled = false
env.ssao_enabled = false
env.ssr_enabled = false
Adaptive Performance
# Dynamically adjust quality based on FPS
extends Node
@export var target_fps: int = 60
@export var check_interval: float = 2.0
var timer: float = 0.0
var quality_level: int = 2 # 0=low, 1=med, 2=high
func _process(delta: float) -> void:
timer += delta
if timer >= check_interval:
var current_fps := Engine.get_frames_per_second()
if current_fps < target_fps - 10 and quality_level > 0:
quality_level -= 1
apply_quality(quality_level)
elif current_fps > target_fps + 5 and quality_level < 2:
quality_level += 1
apply_quality(quality_level)
timer = 0.0
func apply_quality(level: int) -> void:
match level:
0: # Low
get_viewport().scaling_3d_scale = 0.5
1: # Medium
get_viewport().scaling_3d_scale = 0.75
2: # High
get_viewport().scaling_3d_scale = 1.0
Battery Life Management
Background Behavior
# mobile_lifecycle.gd
extends Node
func _ready() -> void:
get_tree().on_request_permissions_result.connect(_on_permissions_result)
func _notification(what: int) -> void:
match what:
NOTIFICATION_APPLICATION_PAUSED:
_on_app_backgrounded()
NOTIFICATION_APPLICATION_RESUMED:
_on_app_foregrounded()
func _on_app_backgrounded() -> void:
# Reduce FPS drastically
Engine.max_fps = 5
# Pause physics
get_tree().paused = true
# Stop audio
AudioServer.set_bus_mute(AudioServer.get_bus_index("Master"), true)
func _on_app_foregrounded() -> void:
# Restore FPS
Engine.max_fps = 60
# Resume
get_tree().paused = false
AudioServer.set_bus_mute(AudioServer.get_bus_index("Master"), false)
Platform-Specific Features
Safe Area Insets (iPhone Notch)
# Handle notch/status bar
func _ready() -> void:
if OS.get_name() == "iOS":
var safe_area := DisplayServer.get_display_safe_area()
var viewport_size := get_viewport_rect().size
# Adjust UI margins
$TopBar.position.y = safe_area.position.y
$BottomControls.position.y = viewport_size.y - safe_area.end.y - 100
Vibration Feedback
func trigger_haptic(intensity: float) -> void:
if OS.has_feature("mobile"):
# Android
if OS.get_name() == "Android":
var duration_ms := int(intensity * 100)
OS.vibrate_handheld(duration_ms)
# iOS (requires plugin)
# Use third-party plugin for iOS haptics
Input Remapping
Mouse → Touch Conversion
# Desktop mouse input
func _input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed:
_on_click(event.position)
# ⬇️ Convert to touch:
func _input(event: InputEvent) -> void:
# Support both mouse (desktop testing) and touch
if event is InputEventMouseButton and event.pressed:
_on_click(event.position)
elif event is InputEventScreenTouch and event.pressed:
_on_click(event.position)
func _on_click(position: Vector2) -> void:
# Handle click/tap
pass
Edge Cases
Keyboard Popup Blocking UI
# Problem: Virtual keyboard covers text input
# Solution: Detect keyboard, scroll UI up
func _on_text_edit_focus_entered() -> void:
if OS.has_feature("mobile"):
# Keyboard height varies; estimate 300px
var keyboard_offset := 300
$UI.position.y -= keyboard_offset
func _on_text_edit_focus_exited() -> void:
$UI.position.y = 0
Accidental Touch Inputs
# Problem: Palm resting on screen triggers inputs
# Solution: Ignore touches near screen edges
func is_valid_touch(position: Vector2) -> bool:
var viewport_size := get_viewport_rect().size
var edge_margin := 50.0
return (position.x > edge_margin and
position.x < viewport_size.x - edge_margin and
position.y > edge_margin and
position.y < viewport_size.y - edge_margin)
Testing Checklist
Expert Techniques & Optimizations
1. Unified IAP Manager (iOS & Android)
Mobile In-App Purchases (IAP) rely on platform-specific singletons (GodotGooglePlayBilling for Android and InAppStore for iOS). Use feature tags to abstract these behind a unified interface.
class_name IAPManager extends Node
var android_billing: Object
var ios_store: Object
func _ready() -> void:
if OS.has_feature("android") and Engine.has_singleton("GodotGooglePlayBilling"):
android_billing = Engine.get_singleton("GodotGooglePlayBilling")
android_billing.start_connection()
elif OS.has_feature("ios") and Engine.has_singleton("InAppStore"):
ios_store = Engine.get_singleton("InAppStore")
ios_store.set_auto_finish_transaction(true)
func purchase_product(id: String) -> void:
if android_billing:
android_billing.purchase(id)
elif ios_store:
ios_store.purchase({ "product_id": id })
2. App Store Asset Pipeline (Screenshot Automation)
Generating store screenshots across multiple resolutions is tedious. Automate this by capturing the viewport texture after the frame is drawn.
func capture_screenshot(filename: String) -> void:
# Wait for the frame to finish rendering
await RenderingServer.frame_post_draw
var img: Image = get_viewport().get_texture().get_image()
var path := "user://screenshots/%s.png" % filename
img.save_png(path)
print("Screenshot saved to: ", path)
3. Mobile Debug Overlay (Memory Monitoring)
Mobile devices have strict memory limits. Use OS.get_memory_info() to monitor physical and available memory in real-time.
func _process(_delta: float) -> void:
var mem: Dictionary = OS.get_memory_info()
var physical_mb := float(mem.get("physical", 0)) / 1048576.0
var available_mb := float(mem.get("available", 0)) / 1048576.0
$DebugLabel.text = "Mem: %.2f / %.2f MB" % [available_mb, physical_mb]
Reference