skills/thedivergentai/gd-agentic-skills/godot-adapt-desktop-to-mobile

godot-adapt-desktop-to-mobile

SKILL.md

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.

mobile_ui_adapter.gd

Automatic mobile overrides: scales buttons for touch, applies safe area margins, disables heavy effects (SSAO, SDFGI) for battery/performance.

virtual_joystick.gd

Production-ready virtual joystick with multi-touch support, deadzone handling, and visual feedback. Outputs normalized Vector2 direction.


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

  • Touch controls work with fat fingers (test on real device)
  • UI doesn't block gameplay-critical elements
  • Game pauses when app goes to background
  • Performance is 60 FPS on target device (iPhone 12, Galaxy S21)
  • Battery drain is < 10% per hour
  • Safe area respected (notch, status bar)
  • Works in both portrait and landscape
  • Text is readable on smallest target device (iPhone SE)

Reference

Weekly Installs
55
GitHub Stars
35
First Seen
Feb 10, 2026
Installed on
codex54
gemini-cli54
opencode54
kimi-cli51
github-copilot51
amp51