Skip to content

Instantly share code, notes, and snippets.

@bohman
Last active July 5, 2026 21:06
Show Gist options
  • Select an option

  • Save bohman/25caacf81c5753a716e7057a75750389 to your computer and use it in GitHub Desktop.

Select an option

Save bohman/25caacf81c5753a716e7057a75750389 to your computer and use it in GitHub Desktop.
Godot Responsive UI

Godot Responsive UI

When porting my Godot game You're a dad to mobile I had to make UI adjustments. I didn't find any good strategies for this online, so I had to put together my own solution. I initially aimed to do something inspired by web media queries. I don't feel like I got close to that, but at least it's a workable solution.

The setup

  • Responsive.gd should be autoloaded. It's the engine behind most of this.
  • responsive_scene.gd will be inherited by individual scene implementations. It keeps track of original values and when things should be applied.
  • main_menu_layout.gd is an example of an inherited node.

This setup captures initial values and lets you override them. You can set up any number of LayoutModes, and as long as you make sure _compute_mode() responds with one of them, you can make different adjustments per mode.

I've got a sneaking suspicion you can make all of this a whole lot easier by setting up different themes and just change theme depending on mode. If you can make one theme inherit the other (this only keeping track of overrides), even better. It didn't make sense for me to test that in my current project, but it's something to experiment with for the next game.

Some notes to make your life easier:

  • Try to do as few manual overrides as possible on each node. The majority of your styling and overrides should happen in your .tres files.
  • Use as many auto flowing elements as possible. Eg, use VBox/HBox and expand/expand ratio to control layouts if you can.
  • Review your project settings canvas carefully. I used stretch=canvas_items / aspect=expand. Different settings might require different setups.

You're a dad

This was conceived when I built You're a dad.

It's a sleep-deprived text adventure about new parenthood, daydreaming, and the things left unsaid. Be a responsible adult, make dinner and take care of your child. Replay the same day and get different outcomes - for each of the game's 28 endings you'll unravel more about yourself, your child, and your family.

extends ResponsiveScene
class_name MainMenuLayout
# Wire this scene's nodes/properties to keys in Responsive.OVERRIDES. The base
# class captures the .tscn-authored desktop default and applies the override per
# mode.
func bindings() -> Dictionary:
return {
%NewGameButton: {
"theme_override_font_sizes/font_size": "mainmenu_button_large_fontsize",
},
%PrimaryActions: {
"size_flags_horizontal": "mainmenu_primaryactions_sizeflagshorizontal",
},
}
extends Node
###
#
# Responsive
#
# The "media query" engine. This is the single place where layout breakpoints
# live. It watches the window size and derives a LayoutMode. Whenever the mode
# changes it emits SignalBus.layout_changed;
# UI scripts react by reading Responsive.current_mode and reconfiguring
# themselves (column counts, margins, font sizes, etc.).
#
# Register this script as an autoloader. Preferred name: Responsive
#
###
enum LayoutMode {
PHONE,
DESKTOP,
}
const PHONE_MAX_WIDTH := 900.0
var current_mode: int = LayoutMode.DESKTOP:
set(value):
if value == current_mode:
return
current_mode = value
SignalBus.layout_changed.emit()
apply_theme_changes()
###
#
# Override values ("the media-query rules")
#
# OVERRIDES is the single source of truth for every override VALUE. There is no
# desktop row: the desktop value is whatever the .tscn authored, captured from
# the live node on first apply (see ResponsiveScene).
#
# Two kinds of keys live here together:
#
# • node-bound keys — referenced by a scene coordinator's bindings() that wires
# them to a concrete node + property (see main_menu_layout).
# • global keys — read directly here, e.g. style_button_standard_fontsize
# in apply_theme_changes().
#
###
const regular_fontsize : int = 28
const OVERRIDES := {
LayoutMode.PHONE: {
"style_label_normal_fontsize": 24,
"style_button_standard_fontsize": regular_fontsize,
"style_richtext_normal_fontsize": regular_fontsize,
"style_richtext_bold_fontsize": regular_fontsize,
"style_richtext_italics_fontsize": regular_fontsize,
"style_richtext_bold_italics_fontsize": regular_fontsize,
"style_richtext_mono_fontsize": regular_fontsize,
"button_content_margin_h": 24,
"button_content_margin_v": 20,
"mainmenu_button_large_fontsize": 34,
"mainmenu_primaryactions_sizeflagshorizontal": Control.SizeFlags.SIZE_EXPAND_FILL,
},
}
func has_override(key) -> bool:
return OVERRIDES.get(current_mode, {}).has(key)
func get_override(key):
return OVERRIDES[current_mode][key]
# Theme-level bindings — the global analog of a scene coordinator's bindings().
# Maps each theme-targeting OVERRIDES key to the project-theme item it drives
# (data type + theme type + item name). Desktop defaults are captured generically
# at startup, so adding a theme override = one row here + its phone value in
# OVERRIDES, with no per-value capture/apply code.
const THEME_BINDINGS := {
"style_button_standard_fontsize": {
"data_type": Theme.DATA_TYPE_FONT_SIZE, "theme_type": "Button", "name": "font_size",
},
"style_richtext_normal_fontsize" : {
"data_type": Theme.DATA_TYPE_FONT_SIZE, "theme_type": "RichTextLabel", "name": "normal_font_size",
},
"style_richtext_mono_fontsize" : {
"data_type": Theme.DATA_TYPE_FONT_SIZE, "theme_type": "RichTextLabel", "name": "mono_font_size",
},
"style_richtext_bold_fontsize" : {
"data_type": Theme.DATA_TYPE_FONT_SIZE, "theme_type": "RichTextLabel", "name": "bold_font_size",
},
"style_richtext_italics_fontsize" : {
"data_type": Theme.DATA_TYPE_FONT_SIZE, "theme_type": "RichTextLabel", "name": "italics_font_size",
},
"style_richtext_bold_italics_fontsize" : {
"data_type": Theme.DATA_TYPE_FONT_SIZE, "theme_type": "RichTextLabel", "name": "bold_italics_font_size",
},
"style_label_normal_fontsize" : {
"data_type": Theme.DATA_TYPE_FONT_SIZE, "theme_type": "Label", "name": "font_size",
}
}
var _theme_defaults := {}
# Stylebox bindings — the StyleBox analog of THEME_BINDINGS. A StyleBox is a
# Resource, not a scalar theme item, so its properties can't go through
# set_theme_item(); they're set on the stylebox itself. Each row maps an OVERRIDES
# key to a set of styleboxes + the properties it drives, applying one value to every
# (stylebox, property) pair via the generic Object get/set. A scalar property is just
# a single-element "properties" list; sided properties (margins, corner radii) list
# the sides that should move together.
#
# Caveat: properties like content_margin_*, corner_radius_*, bg_color only exist on
# StyleBoxFlat — don't point a binding at a box that lacks the property (set() no-ops
# and get() returns null, poisoning the captured default). has_stylebox guards a
# missing box, not a missing property.
const STYLEBOX_BINDINGS := {
"button_content_margin_h": {
"theme_type": "Button",
"styleboxes": ["normal", "hover", "pressed", "hover_pressed", "focus"],
"properties": ["content_margin_left", "content_margin_right"],
},
"button_content_margin_v": {
"theme_type": "Button",
"styleboxes": ["normal", "hover", "pressed", "hover_pressed", "focus"],
"properties": ["content_margin_top", "content_margin_bottom"],
},
}
# Nested snapshot of authored values: key -> stylebox_name -> property -> value.
var _stylebox_defaults := {}
func _ready() -> void:
_capture_theme_defaults()
_capture_stylebox_defaults()
get_tree().root.size_changed.connect(_on_size_changed)
current_mode = _compute_mode()
# Snapshot each bound theme item's authored value before any override is applied,
# so widening back restores the .tres default (mirrors ResponsiveScene capture).
func _capture_theme_defaults() -> void:
var theme := ThemeDB.get_project_theme()
if theme == null:
return
for key in THEME_BINDINGS:
var b = THEME_BINDINGS[key]
_theme_defaults[key] = theme.get_theme_item(b.data_type, b.name, b.theme_type)
# Snapshot each bound stylebox's authored property values. We must NOT store the
# StyleBox reference: it's a shared resource we mutate in place, so a captured
# reference would track the live value and couldn't restore the .tres default.
func _capture_stylebox_defaults() -> void:
var theme := ThemeDB.get_project_theme()
if theme == null:
return
for key in STYLEBOX_BINDINGS:
var b = STYLEBOX_BINDINGS[key]
var per_box := {}
for box_name in b.styleboxes:
if not theme.has_stylebox(box_name, b.theme_type):
continue
var sb := theme.get_stylebox(box_name, b.theme_type)
var per_prop := {}
for prop in b.properties:
per_prop[prop] = sb.get(prop)
per_box[box_name] = per_prop
_stylebox_defaults[key] = per_box
func _on_size_changed() -> void:
var new_mode := _compute_mode()
if new_mode == current_mode:
return
current_mode = new_mode
func _compute_mode() -> int:
var window = get_window()
var size = window.size
var width = size.x
if width < PHONE_MAX_WIDTH:
return LayoutMode.PHONE
if is_phone():
return LayoutMode.PHONE
return LayoutMode.DESKTOP
func is_phone() -> bool:
# First gate, use Godots built in reporting - but that includes tablets.
if not OS.has_feature("mobile"):
return false
# Second gate, check the screens short side by looking at resolution and DPI.
# This should remove (most) tablets.
return get_short_side_inches() < 3.5
func get_short_side_inches() -> float:
var size_px = DisplayServer.screen_get_size()
var dpi = DisplayServer.screen_get_dpi()
return minf(size_px.x, size_px.y) / float(dpi)
###
#
# Theme changes
#
###
func apply_theme_changes() -> void:
var theme := ThemeDB.get_project_theme()
if theme == null:
return
for key in THEME_BINDINGS:
var b = THEME_BINDINGS[key]
var value = get_override(key) if has_override(key) else _theme_defaults[key]
theme.set_theme_item(b.data_type, b.name, b.theme_type, value)
_apply_stylebox_properties(theme)
# StyleBox properties can't go through set_theme_item (they live on the resource, not
# the theme dict). For each binding, push the mode override onto every
# (stylebox, property), or restore the captured .tres default. Mutating the stylebox
# in place is enough — it's the same resource every Button renders from.
func _apply_stylebox_properties(theme: Theme) -> void:
for key in STYLEBOX_BINDINGS:
var b = STYLEBOX_BINDINGS[key]
var overriding := has_override(key)
var override_value = get_override(key) if overriding else null
for box_name in b.styleboxes:
if not theme.has_stylebox(box_name, b.theme_type):
continue
var sb := theme.get_stylebox(box_name, b.theme_type)
for prop in b.properties:
var value = override_value if overriding else _stylebox_defaults[key][box_name][prop]
sb.set(prop, value)
extends Node
class_name ResponsiveScene
###
#
# ResponsiveScene
#
# Base class for a scene's "layout coordinator" — the single node responsible
# for adapting ONE main scene (eg game, main_menu, game_over) to the
# current window size. It removes the boilerplate so a coordinator only has to
# describe WHAT to change, never WHEN.
#
# This base does three things for you:
# 1. Calls apply_layout() once at startup.
# 2. Re-calls apply_layout() every time the layout mode changes.
# 3. Guards apply_layout() against running on a freed node.
#
#
# How to make a new scene responsive
#
# 1. Create scripts/responsive/<scene>_layout.gd:
#
# extends ResponsiveScene
# class_name MySceneLayout
#
# func bindings() -> Dictionary:
# return {
# %SomeContainer: { "columns": "some_columns_key" },
# }
#
# 2. In the scene, add a child Node named "ResponsiveLayout" and attach the
# script.
#
# 3. Done — apply_layout() now runs at startup and on every breakpoint change.
#
# See scripts/responsive/main_menu_layout.gd for an example.
#
#
# How values flow: capture the editor default, override only the deltas
#
# A subclass overrides bindings() to wire its nodes/properties to keys in
# Responsive.OVERRIDES:
#
# func bindings() -> Dictionary:
# return {
# %NewGameButton: {
# "theme_override_font_sizes/font_size": "mainmenu_button_large_fontsize",
# },
# }
#
# On the first apply this base snapshots each node's current value before
# writing anything. From then on, each apply sets get_override(key) when
# the current mode has one, else the captured default — so widening back
# restores the editor value with no desktop value ever duplicated in code.
#
###
var _bindings: Dictionary = {}
var _defaults: Dictionary = {}
var _captured := false
func _ready() -> void:
SignalBus.layout_changed.connect(_apply)
_bindings = bindings()
_apply.call_deferred()
# Override in the per-scene subclass: map this scene's nodes/properties to keys
# in Responsive.OVERRIDES. Resolved once, inside the deferred _ready, so %Name
# lookups work. Return {} if the scene only needs apply_layout().
func bindings() -> Dictionary:
return {}
# Internal: guarded dispatcher. Don't override this — override bindings()/apply_layout().
func _apply() -> void:
# A mode change can arrive in the same frame the scene is being freed (e.g.
# resizing during a scene swap); bail if we're no longer in the tree.
if !is_inside_tree():
return
if !_captured:
for node in _bindings:
_defaults[node] = {}
for prop in _bindings[node]:
_defaults[node][prop] = node.get(prop) # snapshot editor default
_captured = true
for node in _bindings:
for prop in _bindings[node]:
var key = _bindings[node][prop]
var value = _defaults[node][prop]
if Responsive.has_override(key):
value = Responsive.get_override(key)
node.set(prop, value)
apply_layout()
# Optional escape hatch for adaptation that isn't a straight property set (e.g.
# a computed margin pair). Read from the Responsive autoload and apply to this
# scene's nodes. Called once at startup and again on every layout-mode change.
# Keep it idempotent: set every value on every mode so switching back restores.
func apply_layout() -> void:
pass
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment