[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M7E6M2KMATVT2PXC9RWBYGEC":3,"skill-same:01M7E6M2KMATVT2PXC9RWBYGEC":47},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":16,"categories":17,"keywords":18,"publishedAt":19,"linkOnly":20,"markdownContent":21,"markdownBody":22,"frontMatter":23,"refName":24,"commitSha":25,"skillDirectory":26,"forksCount":27,"repositoryHtmlUrl":28,"repositoryHomepageUrl":29,"ownerHtmlUrl":30,"ownerBlogUrl":31,"ownerDescription":6,"updatedAt":32,"install":33,"resources":46},"01M7E6M2KMATVT2PXC9RWBYGEC","mobile-web",null,"Use when a Godot game targets phones, tablets or browsers: touch controls, notches and safe areas, screen sizes and orientation, battery and pausing, Android or iOS exports, web builds, hosting on itch.io or GitHub Pages, or testing in Chrome, Firefox and Edge.","nick-vas\u002Fhyperdot","nick-vas","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F7181961?v=4","01M7E6F6F0BQ49V0JZHX9BXCBD","Nikolaos Vasilikiotis","01M7E6F6FZW8SW1AJ1S7ARFE89",1,"Apache-2.0","https:\u002F\u002Fgithub.com\u002Fnick-vas\u002Fhyperdot\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fmobile-web\u002FSKILL.md",[],[],"2026-11-15 14:19:17",false,"---\nname: mobile-web\ndescription: \"Use when a Godot game targets phones, tablets or browsers: touch controls, notches and safe areas, screen sizes and orientation, battery and pausing, Android or iOS exports, web builds, hosting on itch.io or GitHub Pages, or testing in Chrome, Firefox and Edge.\"\n---\n\n# Phones, tablets and browsers\n\nMeasured on Godot 4.7.2 (Android and web exports built and run here; no iOS, no Android device); sources and the\nmeasurements: `${CLAUDE_PLUGIN_ROOT}\u002Fdocs\u002Fmobile-web.md`.\n\n## 1. What C# can target in Godot 4.7\n\n| Platform | C# | Notes |\n|---|---|---|\n| Web | **no** | the export stops: \"Exporting to Web is currently not supported in Godot 4 when using C#\u002F.NET\". A web build needs a GDScript game (or a GDScript demo). `doctor` fails a C# project with a Web preset |\n| Android | experimental | use a **Gradle** build (AAB for Google Play): 39 MB here on net8.0. The prebuilt template needs `net9.0` and made a 102 MB APK full of static `.a` files. `doctor` fails the prebuilt route on net8.0 |\n| iOS | experimental | export only on macOS with Xcode; Apple wants the iOS 26 SDK (Xcode 26) for uploads since April 2026 |\n\n## 2. Every screen: `layout`\n\n`layout.json` in the project turns on the `layout` runner. It lays out each scene, headless, on a portrait phone\n(1080x2400), a landscape phone, a tablet and a 3440x1440 ultrawide, and fails on controls that are:\n- **off-screen**;\n- **unsafe-area**: under the notch, the status bar or the home indicator;\n- **small-target**: under 48 dp on touch (Android 48 dp, Apple 44 pt), under 24 CSS px with a mouse (WCAG 2.2);\n- **small-control**, with `\"minControlDp\": 75`: on touch profiles, a game control under 75 dp. Game controls are every\n  `TouchScreenButton` (measured from its shape, or its texture) and every Control in the group `touch_control`.\n\n```json\n{ \"scenes\": [\"res:\u002F\u002Fui\u002Fmenu.tscn\", \"res:\u002F\u002Fui\u002Fhud.tscn\"], \"minControlDp\": 75,\n  \"profiles\": [\"phone_portrait\", \"phone_landscape\", \"tablet\", \"ultrawide\", \"foldable:1812x2176@374:touch:insets=0,96,0,48\"] }\n```\n\n```powershell\npwsh ${CLAUDE_PLUGIN_ROOT}\u002Fci\u002Fgodot\u002Fhyperdot.ps1 test -Runners layout -Json -Quiet\n```\n\nA UI designed at 1280x720 and shown on a portrait phone shrinks: a 64 px button became 21 dp. Design phone UI in\nportrait (or choose the base size for the orientation the game ships in).\n\n**Safe areas.** `DisplayServer.GetDisplaySafeArea()` works on Android and iOS only; it returns the whole window\nelsewhere, in **screen** pixels. `GetDisplayCutouts()` is Android only, and browsers report no notch at all. Keep the UI in\na `SafeArea` autoload's rectangle. The runner calls its `SetInsets` with each profile's notch, so a game that uses one\npasses, and one that ignores the notch is caught. `--safe-area=0,128,0,66` after `--` fakes a notch on desktop.\n\n```csharp\npublic partial class SafeArea : Node   \u002F\u002F autoload named SafeArea\n{\n    [Signal] public delegate void ChangedEventHandler();\n    private Rect2I _override;\n\n    public void SetInsets(int left, int top, int right, int bottom)   \u002F\u002F window pixels; zeros remove the fake notch\n    {\n        Vector2I window = GetTree().Root.Size;\n        _override = left + top + right + bottom > 0 ? new Rect2I(left, top, window.X - left - right, window.Y - top - bottom) : new Rect2I();\n        EmitSignal(SignalName.Changed);\n    }\n\n    public Rect2I WindowRect()\n    {\n        var window = new Rect2I(Vector2I.Zero, GetTree().Root.Size);\n        if (_override.HasArea()) return _override;\n        if (!OS.HasFeature(\"mobile\")) return window;\n        Rect2I safe = DisplayServer.GetDisplaySafeArea();   \u002F\u002F screen pixels\n        safe.Position -= DisplayServer.WindowGetPosition();\n        return safe.Intersection(window);\n    }\n\n    \u002F\u002F What Controls use: the safe rectangle after stretching.\n    public Rect2 CanvasRect() => GetTree().Root.GetFinalTransform().AffineInverse() * new Rect2(WindowRect().Position, WindowRect().Size);\n}\n```\n\nA full-rect `MarginContainer` sets its four `margin_*` theme constants from `CanvasRect()` on `Changed` (and on\n`Root.SizeChanged`). The GDScript version is in the kit's `tests\u002Flayout-fixture`; the C# `SafeArea` and `SafeMargins` are in `docs\u002Fmobile-web.md`.\n\n**Orientation.** The default is landscape (`display\u002Fwindow\u002Fhandheld\u002Forientation`; an exported APK said\n`screenOrientation=0`). A portrait game must set it, and also its viewport size: Godot does not swap them.\nA game that supports both (sensor) re-lays out on `Root.SizeChanged` at any moment, never only in `_Ready`; in\nlandscape both thumbs rest low at the sides, so critical buttons go in reach there, with a left-handed mirror. Test by\nrotating mid-fight, during loading and between a press and its release (a UI flow's `hold`, `resize` and `release`\nsteps, `ui-flows`), and check text at the smallest aspect ratio in each orientation (Elder Scrolls: Blades, GDC: dual orientation added 20 to 60% to UI design time; plan it from the start).\n\n## 3. Touch\n\n- `input_devices\u002Fpointing\u002Femulate_mouse_from_touch` is on by default: the **first** finger also arrives as a mouse,\n  so mouse-only code works with one finger and never with two.\n- Track fingers by `InputEventScreenTouch.Index` and `InputEventScreenDrag.Index`.\n- Godot 4.7's buttons track a touch index, so two `Button`s can be held at once, for example move and jump\n  (changelog GH-110893; not tried on a device).\n- `TouchScreenButton` (a Node2D with an `Action`, `PassbyPress`, and shown on touchscreens only) still suits on-screen\n  game controls.\n- Virtual stick, if the game needs one: write a Control (in the group `touch_control`) that takes one touch index inside its\n  rectangle and maps the offset to `Input.ActionPress(action, strength)`. Or use MarcoFazioRandom's MIT \"Virtual Joystick\"\n  add-on (record it in `assets.json`).\n- Android gestures (`InputEventMagnifyGesture`, `InputEventPanGesture`) need `enable_pan_and_scale_gestures`. Turn on\n  `emulate_touch_from_mouse` to try touch code with a mouse.\n- Test touch like any input (`input`): `Input.ParseInputEvent(new InputEventScreenTouch { Index = 1, Position = p,\n  Pressed = true })`, then wait a frame. Use two indices for two fingers. `p` is in **window** pixels, and a headless\n  window is 64x64: with a stretch mode, (100, 420) reached `_Input` as (1000, 4120). Send\n  `GetTree().Root.GetFinalTransform() * viewportPoint`.\n- **Game controls as hit pads** (a GDC talk on Warhammer 40,000: Carnage): a line splits the screen, controls below and\n  the game above. Draw small buttons, but give each a much larger invisible pad that reaches past where the largest thumb\n  lands, so a thumb below the line never lands on nothing. In an overlap the touch goes to the pad it is nearest relative\n  to the pad's size; the action that hurts most to miss (jump) gets the largest pad.\n- A finger that slides into another pad switches to it without lifting; one that drifts off every pad keeps its pad. An\n  action stays held while any finger is on its pad. Release all on `NotificationApplicationPaused` and focus loss.\n- Fade the drawn buttons after a few seconds of play (never to zero); offer a pad-size setting; give screens under\n  7 inches (where the thumbs meet) a tighter layout. Tested: `${CLAUDE_PLUGIN_ROOT}\u002Fexamples\u002Fcsharp\u002Fsrc\u002FExample.Core\u002FControls\u002FTouchPads.cs`\n  (`DeadSpots` lists where a thumb's reach finds no pad) and the C# example's `Controls\u002FTouchControls.cs`.\n- Touch targets: 48 dp minimum (above). Xbox's accessibility guidelines ask for 15 mm on phones (`ui-accessibility`).\n  `ScreenGetDpi()` on Android is a bucket (160, 240, 320, 480...), so dp is exact and millimetres are approximate.\n\n**Controls the thumb finds without looking** (a GDC talk, `${CLAUDE_PLUGIN_ROOT}\u002Fdocs\u002Fgdc\u002Fcontrols-you-can-feel.md`):\n- A practised player knows what a press did before the screen shows it. Glass gives no such feel, so a control that is\n  only nearly certain fails; on-screen feedback cannot make up for it. A click sound on each press helps.\n- 48 dp is for buttons the player looks at. Buttons pressed blind (move, jump, fire): about 75 dp each, few of them,\n  each on or off. Set `minControlDp` to 75. In-game things the player taps: never under about 40 dp (the runner cannot\n  measure moving objects; test them in a flow).\n- A thumb lands within about 44 points, so a virtual stick, fixed or floating, gives many possible directions: the weak\n  option. Prefer two or three big buttons, a tap on one of a few large choices, or gestures that hint rather than steer.\n- Let the controls change the design: EA's Tetris replaced dragging with a tap on one of four drop spots, and made the\n  thinking happen under time pressure while the input did not.\n- Swipe up, not down (swiping down is uncomfortable); avoid moves of a few points (10-point Tetris columns).\n\n## 4. Battery, heat and the app lifecycle\n\n- Cap the frame rate (`application\u002Frun\u002Fmax_fps`, 30 for slow games) rather than relying on `low_processor_mode`, which\n  only helps apps that rarely redraw.\n- `display\u002Fwindow\u002Fenergy_saving\u002Fkeep_screen_on` is on by default; turn it off for idle games and menus.\n- Android frame pacing (Swappy) is on by default.\n- Godot 4.7 has no thermal API (Android's thermal headroom needs a plugin).\n- **Save on `NotificationApplicationPaused`** (Android and iOS only). iOS gives about 5 seconds and then kills the app\n  (`saves`). Desktops, Steam Deck and consoles send nothing before a suspend: save often there instead\n  (`console-cert`). Pause the game there and resume on `NotificationApplicationResumed`.\n- `OS.GetMemoryInfo()` gives physical, free and available memory for telemetry. Godot has no memory budget API.\n- **Lights per mesh.** Mobile lights each mesh instance with at most 8 OmniLights and 8 SpotLights, a limit fixed in its\n  shader, not a setting; Compatibility's are `rendering\u002Flimits\u002Fopengl\u002Fmax_lights_per_object` (8) and\n  `max_renderable_lights` (32). Lights past the limit silently skip that mesh: split large floors so nearby lights stay\n  under it, and bake the rest (`LightmapGI`).\n\n## 5. Android export\n\n- **Tools:** JDK 17 or newer (21 worked), and the SDK's `platform-tools`, `build-tools;35.0.1` and `platforms;android-35`.\n  The NDK and CMake were not needed for APK or Gradle builds.\n- **Editor settings:** `ANDROID_HOME` and `JAVA_HOME` only seed **new** editor settings. An existing\n  `editor_settings-4.7.tres` wins (\"Invalid Android SDK path\"). In CI, point `XDG_CONFIG_HOME` at an empty folder (Linux),\n  or write `export\u002Fandroid\u002Fandroid_sdk_path` and `java_sdk_path` into it. Set `DOTNET_NOLOGO=1`: .NET's first-run banner\n  was read as the target framework.\n- **Required:** `rendering\u002Ftextures\u002Fvram_compression\u002Fimport_etc2_astc=true` (without it the export refuses), and a\n  project icon (without one, the build uses Godot's icon). `doctor` checks both.\n- **Signing:** `GODOT_ANDROID_KEYSTORE_RELEASE_PATH`, `_USER` and `_PASSWORD` (the keystore and key passwords must\n  match). Google Play wants an AAB, which needs a Gradle build: `--install-android-build-template`, then\n  `gradle_build\u002Fuse_gradle_build=true` and `gradle_build\u002Fexport_format=1`. It uses about 1.2 GB of Gradle cache, and\n  Maven Central can answer 429 on shared runners, so retry once. No keystore yet: `export -Preset Android -DebugBuild`\n  makes a debug APK for a first run on a device (without one, `export` fails with `export-no-keystore`).\n- **Google Play:** new apps and updates must target API 36 since 31 August 2026; 4.7's default target is 36, minimum 24.\n  From 1 February 2027, updates must support 16 KB memory pages. 4.7.2's libraries passed `zipalign -c -P 16`.\n- **Launch tests** need a device or an emulator with KVM (GitHub's Linux runners have it); none ran here.\n\n## 6. iOS, in concept\n\nmacOS and Xcode only. `application\u002Fmin_ios_version` defaults to 15.0. The preset's `privacy\u002F*` options fill\n`PrivacyInfo.xcprivacy`, so declare what the game collects (`legal`). The home indicator and status bar are hidden\nby default. `ScreenSetOrientation` works only when the orientation setting is sensor.\n\n## 7. Web builds (GDScript)\n\n- **Renderer:** Compatibility only (WebGL 2); Forward+ and Mobile do not run, and there is no WebGPU. Safari has WebGL 2\n  issues.\n- **Threads:** keep Thread Support **off** (the default since 4.3). A threads build shows \"missing features: Cross-Origin\n  Isolation, SharedArrayBuffer\" unless the server sends COOP and COEP headers, and itch.io and GitHub Pages send none.\n  The PWA option \"Ensure Cross Origin Isolation Headers\" works around it, but needed a second reload here. `doctor`\n  warns about threads builds.\n- **Size:** the engine alone is 38 MB, 9.9 MB gzipped (GitHub Pages compresses; itch.io does not). `export` reports\n  `exports[].web` (`rawMb`, `gzipMb`), and `exports[].size.pack` what the game adds, by folder and kind of file\n  (`byFolder`, `byType`). Web portals cap downloads; CrazyGames, for example, allows 50 MB, or 20 MB for\n  its mobile homepage.\n- **Export outside the project.** An export into `res:\u002F\u002Fbuild\u002F...` gets imported, and the next export packs it (the\n  `.pck` grew tenfold here). `export` adds a `.gdignore` to an output folder inside the project; `doctor` warns about\n  presets that export into it.\n- **Saves:** `user:\u002F\u002F` is IndexedDB, and it survived a reload. The write to IndexedDB happens a frame after the file\n  closes, so call `JavaScriptBridge.force_fs_sync()` after a save that must survive closing\n  the tab. Private windows do not keep it.\n- **Audio** needs a click or key press first in most browsers (not reproducible in automated Chromium). Bus effects do\n  not run on the web (`game-audio`).\n- **Input:** fullscreen and mouse capture work only from inside an input event. Gamepads appear after their first\n  button press. A background tab pauses the game.\n- **Test in browsers:** `export -Preset Web -Run -Browser chromium,firefox,msedge` exports a **debug** build too and\n  opens it in each browser. Release builds hide script errors: a call on null does nothing and the game carries on.\n  The run fails (`web-run-failed`) when the engine does not start, or on any page error, failed request or `ERROR` \u002F\n  `SCRIPT ERROR` line. Without the browser it fails with `no-browser`; `npx playwright install --with-deps firefox`\n  installs one. `chrome` and `msedge` use the browsers installed on the machine. On a machine without a GPU (GitHub's\n  runners) headless Firefox has no WebGL 2 and fails at start: give it a window, `HYPERDOT_WEB_HEADED=1` with Xvfb running\n  (the kit's self-test does this).\n\n## 8. Done means\n\n- [ ] `layout` passes on every profile the game ships for, with the real notch insets\n- [ ] two-finger input tested (`ParseInputEvent` with two indices); every on-screen control is at least 48 dp, and\n  game controls pressed without looking at least 75 dp (`minControlDp`)\n- [ ] on-screen game controls: no dead spot under a thumb, sliding between pads, everything released on pause\n- [ ] the game saves on pause and resumes cleanly; the frame rate is capped where it can be\n- [ ] Android: `doctor` clean; an AAB signed; target API 36\n- [ ] Web: `export -Run -Browser chromium,firefox,msedge` passes; the gzipped size fits the portal; a single-threaded build for itch.io\n","# Phones, tablets and browsers\n\nMeasured on Godot 4.7.2 (Android and web exports built and run here; no iOS, no Android device); sources and the\nmeasurements: `${CLAUDE_PLUGIN_ROOT}\u002Fdocs\u002Fmobile-web.md`.\n\n## 1. What C# can target in Godot 4.7\n\n| Platform | C# | Notes |\n|---|---|---|\n| Web | **no** | the export stops: \"Exporting to Web is currently not supported in Godot 4 when using C#\u002F.NET\". A web build needs a GDScript game (or a GDScript demo). `doctor` fails a C# project with a Web preset |\n| Android | experimental | use a **Gradle** build (AAB for Google Play): 39 MB here on net8.0. The prebuilt template needs `net9.0` and made a 102 MB APK full of static `.a` files. `doctor` fails the prebuilt route on net8.0 |\n| iOS | experimental | export only on macOS with Xcode; Apple wants the iOS 26 SDK (Xcode 26) for uploads since April 2026 |\n\n## 2. Every screen: `layout`\n\n`layout.json` in the project turns on the `layout` runner. It lays out each scene, headless, on a portrait phone\n(1080x2400), a landscape phone, a tablet and a 3440x1440 ultrawide, and fails on controls that are:\n- **off-screen**;\n- **unsafe-area**: under the notch, the status bar or the home indicator;\n- **small-target**: under 48 dp on touch (Android 48 dp, Apple 44 pt), under 24 CSS px with a mouse (WCAG 2.2);\n- **small-control**, with `\"minControlDp\": 75`: on touch profiles, a game control under 75 dp. Game controls are every\n  `TouchScreenButton` (measured from its shape, or its texture) and every Control in the group `touch_control`.\n\n```json\n{ \"scenes\": [\"res:\u002F\u002Fui\u002Fmenu.tscn\", \"res:\u002F\u002Fui\u002Fhud.tscn\"], \"minControlDp\": 75,\n  \"profiles\": [\"phone_portrait\", \"phone_landscape\", \"tablet\", \"ultrawide\", \"foldable:1812x2176@374:touch:insets=0,96,0,48\"] }\n```\n\n```powershell\npwsh ${CLAUDE_PLUGIN_ROOT}\u002Fci\u002Fgodot\u002Fhyperdot.ps1 test -Runners layout -Json -Quiet\n```\n\nA UI designed at 1280x720 and shown on a portrait phone shrinks: a 64 px button became 21 dp. Design phone UI in\nportrait (or choose the base size for the orientation the game ships in).\n\n**Safe areas.** `DisplayServer.GetDisplaySafeArea()` works on Android and iOS only; it returns the whole window\nelsewhere, in **screen** pixels. `GetDisplayCutouts()` is Android only, and browsers report no notch at all. Keep the UI in\na `SafeArea` autoload's rectangle. The runner calls its `SetInsets` with each profile's notch, so a game that uses one\npasses, and one that ignores the notch is caught. `--safe-area=0,128,0,66` after `--` fakes a notch on desktop.\n\n```csharp\npublic partial class SafeArea : Node   \u002F\u002F autoload named SafeArea\n{\n    [Signal] public delegate void ChangedEventHandler();\n    private Rect2I _override;\n\n    public void SetInsets(int left, int top, int right, int bottom)   \u002F\u002F window pixels; zeros remove the fake notch\n    {\n        Vector2I window = GetTree().Root.Size;\n        _override = left + top + right + bottom > 0 ? new Rect2I(left, top, window.X - left - right, window.Y - top - bottom) : new Rect2I();\n        EmitSignal(SignalName.Changed);\n    }\n\n    public Rect2I WindowRect()\n    {\n        var window = new Rect2I(Vector2I.Zero, GetTree().Root.Size);\n        if (_override.HasArea()) return _override;\n        if (!OS.HasFeature(\"mobile\")) return window;\n        Rect2I safe = DisplayServer.GetDisplaySafeArea();   \u002F\u002F screen pixels\n        safe.Position -= DisplayServer.WindowGetPosition();\n        return safe.Intersection(window);\n    }\n\n    \u002F\u002F What Controls use: the safe rectangle after stretching.\n    public Rect2 CanvasRect() => GetTree().Root.GetFinalTransform().AffineInverse() * new Rect2(WindowRect().Position, WindowRect().Size);\n}\n```\n\nA full-rect `MarginContainer` sets its four `margin_*` theme constants from `CanvasRect()` on `Changed` (and on\n`Root.SizeChanged`). The GDScript version is in the kit's `tests\u002Flayout-fixture`; the C# `SafeArea` and `SafeMargins` are in `docs\u002Fmobile-web.md`.\n\n**Orientation.** The default is landscape (`display\u002Fwindow\u002Fhandheld\u002Forientation`; an exported APK said\n`screenOrientation=0`). A portrait game must set it, and also its viewport size: Godot does not swap them.\nA game that supports both (sensor) re-lays out on `Root.SizeChanged` at any moment, never only in `_Ready`; in\nlandscape both thumbs rest low at the sides, so critical buttons go in reach there, with a left-handed mirror. Test by\nrotating mid-fight, during loading and between a press and its release (a UI flow's `hold`, `resize` and `release`\nsteps, `ui-flows`), and check text at the smallest aspect ratio in each orientation (Elder Scrolls: Blades, GDC: dual orientation added 20 to 60% to UI design time; plan it from the start).\n\n## 3. Touch\n\n- `input_devices\u002Fpointing\u002Femulate_mouse_from_touch` is on by default: the **first** finger also arrives as a mouse,\n  so mouse-only code works with one finger and never with two.\n- Track fingers by `InputEventScreenTouch.Index` and `InputEventScreenDrag.Index`.\n- Godot 4.7's buttons track a touch index, so two `Button`s can be held at once, for example move and jump\n  (changelog GH-110893; not tried on a device).\n- `TouchScreenButton` (a Node2D with an `Action`, `PassbyPress`, and shown on touchscreens only) still suits on-screen\n  game controls.\n- Virtual stick, if the game needs one: write a Control (in the group `touch_control`) that takes one touch index inside its\n  rectangle and maps the offset to `Input.ActionPress(action, strength)`. Or use MarcoFazioRandom's MIT \"Virtual Joystick\"\n  add-on (record it in `assets.json`).\n- Android gestures (`InputEventMagnifyGesture`, `InputEventPanGesture`) need `enable_pan_and_scale_gestures`. Turn on\n  `emulate_touch_from_mouse` to try touch code with a mouse.\n- Test touch like any input (`input`): `Input.ParseInputEvent(new InputEventScreenTouch { Index = 1, Position = p,\n  Pressed = true })`, then wait a frame. Use two indices for two fingers. `p` is in **window** pixels, and a headless\n  window is 64x64: with a stretch mode, (100, 420) reached `_Input` as (1000, 4120). Send\n  `GetTree().Root.GetFinalTransform() * viewportPoint`.\n- **Game controls as hit pads** (a GDC talk on Warhammer 40,000: Carnage): a line splits the screen, controls below and\n  the game above. Draw small buttons, but give each a much larger invisible pad that reaches past where the largest thumb\n  lands, so a thumb below the line never lands on nothing. In an overlap the touch goes to the pad it is nearest relative\n  to the pad's size; the action that hurts most to miss (jump) gets the largest pad.\n- A finger that slides into another pad switches to it without lifting; one that drifts off every pad keeps its pad. An\n  action stays held while any finger is on its pad. Release all on `NotificationApplicationPaused` and focus loss.\n- Fade the drawn buttons after a few seconds of play (never to zero); offer a pad-size setting; give screens under\n  7 inches (where the thumbs meet) a tighter layout. Tested: `${CLAUDE_PLUGIN_ROOT}\u002Fexamples\u002Fcsharp\u002Fsrc\u002FExample.Core\u002FControls\u002FTouchPads.cs`\n  (`DeadSpots` lists where a thumb's reach finds no pad) and the C# example's `Controls\u002FTouchControls.cs`.\n- Touch targets: 48 dp minimum (above). Xbox's accessibility guidelines ask for 15 mm on phones (`ui-accessibility`).\n  `ScreenGetDpi()` on Android is a bucket (160, 240, 320, 480...), so dp is exact and millimetres are approximate.\n\n**Controls the thumb finds without looking** (a GDC talk, `${CLAUDE_PLUGIN_ROOT}\u002Fdocs\u002Fgdc\u002Fcontrols-you-can-feel.md`):\n- A practised player knows what a press did before the screen shows it. Glass gives no such feel, so a control that is\n  only nearly certain fails; on-screen feedback cannot make up for it. A click sound on each press helps.\n- 48 dp is for buttons the player looks at. Buttons pressed blind (move, jump, fire): about 75 dp each, few of them,\n  each on or off. Set `minControlDp` to 75. In-game things the player taps: never under about 40 dp (the runner cannot\n  measure moving objects; test them in a flow).\n- A thumb lands within about 44 points, so a virtual stick, fixed or floating, gives many possible directions: the weak\n  option. Prefer two or three big buttons, a tap on one of a few large choices, or gestures that hint rather than steer.\n- Let the controls change the design: EA's Tetris replaced dragging with a tap on one of four drop spots, and made the\n  thinking happen under time pressure while the input did not.\n- Swipe up, not down (swiping down is uncomfortable); avoid moves of a few points (10-point Tetris columns).\n\n## 4. Battery, heat and the app lifecycle\n\n- Cap the frame rate (`application\u002Frun\u002Fmax_fps`, 30 for slow games) rather than relying on `low_processor_mode`, which\n  only helps apps that rarely redraw.\n- `display\u002Fwindow\u002Fenergy_saving\u002Fkeep_screen_on` is on by default; turn it off for idle games and menus.\n- Android frame pacing (Swappy) is on by default.\n- Godot 4.7 has no thermal API (Android's thermal headroom needs a plugin).\n- **Save on `NotificationApplicationPaused`** (Android and iOS only). iOS gives about 5 seconds and then kills the app\n  (`saves`). Desktops, Steam Deck and consoles send nothing before a suspend: save often there instead\n  (`console-cert`). Pause the game there and resume on `NotificationApplicationResumed`.\n- `OS.GetMemoryInfo()` gives physical, free and available memory for telemetry. Godot has no memory budget API.\n- **Lights per mesh.** Mobile lights each mesh instance with at most 8 OmniLights and 8 SpotLights, a limit fixed in its\n  shader, not a setting; Compatibility's are `rendering\u002Flimits\u002Fopengl\u002Fmax_lights_per_object` (8) and\n  `max_renderable_lights` (32). Lights past the limit silently skip that mesh: split large floors so nearby lights stay\n  under it, and bake the rest (`LightmapGI`).\n\n## 5. Android export\n\n- **Tools:** JDK 17 or newer (21 worked), and the SDK's `platform-tools`, `build-tools;35.0.1` and `platforms;android-35`.\n  The NDK and CMake were not needed for APK or Gradle builds.\n- **Editor settings:** `ANDROID_HOME` and `JAVA_HOME` only seed **new** editor settings. An existing\n  `editor_settings-4.7.tres` wins (\"Invalid Android SDK path\"). In CI, point `XDG_CONFIG_HOME` at an empty folder (Linux),\n  or write `export\u002Fandroid\u002Fandroid_sdk_path` and `java_sdk_path` into it. Set `DOTNET_NOLOGO=1`: .NET's first-run banner\n  was read as the target framework.\n- **Required:** `rendering\u002Ftextures\u002Fvram_compression\u002Fimport_etc2_astc=true` (without it the export refuses), and a\n  project icon (without one, the build uses Godot's icon). `doctor` checks both.\n- **Signing:** `GODOT_ANDROID_KEYSTORE_RELEASE_PATH`, `_USER` and `_PASSWORD` (the keystore and key passwords must\n  match). Google Play wants an AAB, which needs a Gradle build: `--install-android-build-template`, then\n  `gradle_build\u002Fuse_gradle_build=true` and `gradle_build\u002Fexport_format=1`. It uses about 1.2 GB of Gradle cache, and\n  Maven Central can answer 429 on shared runners, so retry once. No keystore yet: `export -Preset Android -DebugBuild`\n  makes a debug APK for a first run on a device (without one, `export` fails with `export-no-keystore`).\n- **Google Play:** new apps and updates must target API 36 since 31 August 2026; 4.7's default target is 36, minimum 24.\n  From 1 February 2027, updates must support 16 KB memory pages. 4.7.2's libraries passed `zipalign -c -P 16`.\n- **Launch tests** need a device or an emulator with KVM (GitHub's Linux runners have it); none ran here.\n\n## 6. iOS, in concept\n\nmacOS and Xcode only. `application\u002Fmin_ios_version` defaults to 15.0. The preset's `privacy\u002F*` options fill\n`PrivacyInfo.xcprivacy`, so declare what the game collects (`legal`). The home indicator and status bar are hidden\nby default. `ScreenSetOrientation` works only when the orientation setting is sensor.\n\n## 7. Web builds (GDScript)\n\n- **Renderer:** Compatibility only (WebGL 2); Forward+ and Mobile do not run, and there is no WebGPU. Safari has WebGL 2\n  issues.\n- **Threads:** keep Thread Support **off** (the default since 4.3). A threads build shows \"missing features: Cross-Origin\n  Isolation, SharedArrayBuffer\" unless the server sends COOP and COEP headers, and itch.io and GitHub Pages send none.\n  The PWA option \"Ensure Cross Origin Isolation Headers\" works around it, but needed a second reload here. `doctor`\n  warns about threads builds.\n- **Size:** the engine alone is 38 MB, 9.9 MB gzipped (GitHub Pages compresses; itch.io does not). `export` reports\n  `exports[].web` (`rawMb`, `gzipMb`), and `exports[].size.pack` what the game adds, by folder and kind of file\n  (`byFolder`, `byType`). Web portals cap downloads; CrazyGames, for example, allows 50 MB, or 20 MB for\n  its mobile homepage.\n- **Export outside the project.** An export into `res:\u002F\u002Fbuild\u002F...` gets imported, and the next export packs it (the\n  `.pck` grew tenfold here). `export` adds a `.gdignore` to an output folder inside the project; `doctor` warns about\n  presets that export into it.\n- **Saves:** `user:\u002F\u002F` is IndexedDB, and it survived a reload. The write to IndexedDB happens a frame after the file\n  closes, so call `JavaScriptBridge.force_fs_sync()` after a save that must survive closing\n  the tab. Private windows do not keep it.\n- **Audio** needs a click or key press first in most browsers (not reproducible in automated Chromium). Bus effects do\n  not run on the web (`game-audio`).\n- **Input:** fullscreen and mouse capture work only from inside an input event. Gamepads appear after their first\n  button press. A background tab pauses the game.\n- **Test in browsers:** `export -Preset Web -Run -Browser chromium,firefox,msedge` exports a **debug** build too and\n  opens it in each browser. Release builds hide script errors: a call on null does nothing and the game carries on.\n  The run fails (`web-run-failed`) when the engine does not start, or on any page error, failed request or `ERROR` \u002F\n  `SCRIPT ERROR` line. Without the browser it fails with `no-browser`; `npx playwright install --with-deps firefox`\n  installs one. `chrome` and `msedge` use the browsers installed on the machine. On a machine without a GPU (GitHub's\n  runners) headless Firefox has no WebGL 2 and fails at start: give it a window, `HYPERDOT_WEB_HEADED=1` with Xvfb running\n  (the kit's self-test does this).\n\n## 8. Done means\n\n- [ ] `layout` passes on every profile the game ships for, with the real notch insets\n- [ ] two-finger input tested (`ParseInputEvent` with two indices); every on-screen control is at least 48 dp, and\n  game controls pressed without looking at least 75 dp (`minControlDp`)\n- [ ] on-screen game controls: no dead spot under a thumb, sliding between pads, everything released on pause\n- [ ] the game saves on pause and resumes cleanly; the frame rate is capped where it can be\n- [ ] Android: `doctor` clean; an AAB signed; target API 36\n- [ ] Web: `export -Run -Browser chromium,firefox,msedge` passes; the gzipped size fits the portal; a single-threaded build for itch.io\n",{"name":5,"description":7},"main","5fa4c2262d814d50f72198a95b8c483ff195f90f",".agents\u002Fskills\u002Fmobile-web",0,"https:\u002F\u002Fgithub.com\u002Fnick-vas\u002Fhyperdot","https:\u002F\u002Fnick-vas.github.io\u002Fhyperdot\u002F","https:\u002F\u002Fgithub.com\u002Fnick-vas","https:\u002F\u002Fcommon-beef.itch.io\u002F","2026-11-13 18:55:29",{"source":8,"skillName":5,"commands":34,"prompt":44,"zipAvailable":45},[35,38,41],{"tool":36,"command":37},"npx","npx skills add nick-vas\u002Fhyperdot --skill mobile-web",{"tool":39,"command":40},"bunx","bunx skills add nick-vas\u002Fhyperdot --skill mobile-web",{"tool":42,"command":43},"pnpm","pnpm dlx skills add nick-vas\u002Fhyperdot --skill mobile-web","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002Fnick-vas\u002Fhyperdot\u002Fblob\u002Fmain\u002F.agents\u002Fskills\u002Fmobile-web\u002FSKILL.md",true,[],[48,55,61,68,75,82],{"skillNo":49,"name":50,"nameZh":6,"description":51,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":52,"categories":53,"keywords":54,"publishedAt":19},"01M7E6RF2S03S92PB9RZB4S4W0","over-budget","Use when a fixture exceeds the size budget.","https:\u002F\u002Fgithub.com\u002Fnick-vas\u002Fhyperdot\u002Fblob\u002Fmain\u002Ftests\u002Fskill-fixtures\u002Fover-budget\u002FSKILL.md",[],[],{"skillNo":56,"name":57,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":58,"categories":59,"keywords":60,"publishedAt":19},"01M7E6RBY8SDNMA3ZARE8MAH0G","bad-yaml","https:\u002F\u002Fgithub.com\u002Fnick-vas\u002Fhyperdot\u002Fblob\u002Fmain\u002Ftests\u002Fskill-fixtures\u002Fbad-yaml\u002FSKILL.md",[],[],{"skillNo":62,"name":63,"nameZh":6,"description":64,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":65,"categories":66,"keywords":67,"publishedAt":19},"01M7E6R9S9YHTC552SR41RQAJ1","bad-workflow-description","Use when testing; runs the kit and then reads the JSON.","https:\u002F\u002Fgithub.com\u002Fnick-vas\u002Fhyperdot\u002Fblob\u002Fmain\u002Ftests\u002Fskill-fixtures\u002Fbad-workflow-description\u002FSKILL.md",[],[],{"skillNo":69,"name":70,"nameZh":6,"description":71,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":72,"categories":73,"keywords":74,"publishedAt":19},"01M7E6R6CWSAN48C80130CM1AK","bad-version","Use when a fixture quotes a stale package version.","https:\u002F\u002Fgithub.com\u002Fnick-vas\u002Fhyperdot\u002Fblob\u002Fmain\u002Ftests\u002Fskill-fixtures\u002Fbad-version\u002FSKILL.md",[],[],{"skillNo":76,"name":77,"nameZh":6,"description":78,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":79,"categories":80,"keywords":81,"publishedAt":19},"01M7E6R2C0E24TC133ESHKZT9R","bad-value","Use when a fixture passes a value outside a parameter's set.","https:\u002F\u002Fgithub.com\u002Fnick-vas\u002Fhyperdot\u002Fblob\u002Fmain\u002Ftests\u002Fskill-fixtures\u002Fbad-value\u002FSKILL.md",[],[],{"skillNo":83,"name":84,"nameZh":6,"description":85,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":86,"categories":87,"keywords":88,"publishedAt":19},"01M7E6QYM81V061X7DSQ94CZRP","bad-trigger","Helps with Godot tests.","https:\u002F\u002Fgithub.com\u002Fnick-vas\u002Fhyperdot\u002Fblob\u002Fmain\u002Ftests\u002Fskill-fixtures\u002Fbad-trigger\u002FSKILL.md",[],[]]