godot-adapt-desktop-to-mobile

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.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "godot-adapt-desktop-to-mobile" with this command: npx skills add thedivergentai/gd-agentic-skills/thedivergentai-gd-agentic-skills-godot-adapt-desktop-to-mobile

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

GenreRecommended ControlExample
PlatformerVirtual joystick (left) + jump button (right)Super Mario Run
Top-down shooterDual-stick (move left, aim right)Brawl Stars
Turn-basedDirect tap on units/tilesInto the Breach
PuzzleTap, swipe, pinch gesturesCandy Crush
Card gameDrag-and-dropHearthstone
RacingTilt steering or tap left/rightAsphalt 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

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Automation

godot-master

No summary provided by upstream source.

Repository SourceNeeds Review
Automation

godot-shaders-basics

No summary provided by upstream source.

Repository SourceNeeds Review
Automation

godot-ui-theming

No summary provided by upstream source.

Repository SourceNeeds Review