ANDROID 14 -- SPACE PROGRAM -- LAUNCH IMMINENT

EASTEREGG.APK IS AN EXPLORATION OF NEW TECHNOLOGY.

// MISSION "UPSIDE DOWN CAKE" //
 + JETPACK COMPOSURE
 + SPACE FOLDING
 + ASTRAL PROJECTION
 + VECTORPUNK

// PILOT ADVISORIES //
 + STABLE ORBITS ARE TRICKY BUT POSSIBLE.
 + EACH DAY PRESENTS NEW DISCOVERIES.
 + ATTEMPT ALL LANDINGS.

GODSPEED AND GOOD LUCK.

A PRODUCT OF YOYODROID PROPULSION SYSTEMS
"WHERE INTEGRATION COMPLETE IS TOMORROW!"

Fixes: 267678663 // REMOVE BEFORE FLIGHT
Test: adb shell am start -a android.intent.action.MAIN -c com.android.internal.category.PLATLOGO
Change-Id: Ia06422bdc2ec22e8e64b189ca2cc02def03d84f3
diff --git a/packages/EasterEgg/Android.bp b/packages/EasterEgg/Android.bp
index e88410c..8699f59 100644
--- a/packages/EasterEgg/Android.bp
+++ b/packages/EasterEgg/Android.bp
@@ -26,7 +26,10 @@
 android_app {
     // the build system in pi-dev can't quite handle R.java in kt
     // so we will have a mix of java and kotlin files
-    srcs: ["src/**/*.java", "src/**/*.kt"],
+    srcs: [
+        "src/**/*.java",
+        "src/**/*.kt",
+    ],
 
     resource_dirs: ["res"],
 
@@ -36,17 +39,34 @@
     certificate: "platform",
 
     optimize: {
+        enabled: true,
+        optimize: true,
+        shrink: true,
+        shrink_resources: true,
+        proguard_compatibility: false,
         proguard_flags_files: ["proguard.flags"],
     },
 
-	static_libs: [
-		"androidx.core_core",
-		"androidx.recyclerview_recyclerview",
+    static_libs: [
+        "androidx.core_core",
         "androidx.annotation_annotation",
-		"kotlinx-coroutines-android",
-		"kotlinx-coroutines-core",
-		//"kotlinx-coroutines-reactive",
-	],
+        "androidx.recyclerview_recyclerview",
+        "kotlinx-coroutines-android",
+        "kotlinx-coroutines-core",
+
+        "androidx.core_core-ktx",
+        "androidx.lifecycle_lifecycle-runtime-ktx",
+        "androidx.activity_activity-compose",
+        "androidx.compose.ui_ui",
+        "androidx.compose.ui_ui-util",
+        "androidx.compose.ui_ui-tooling-preview",
+        "androidx.compose.material_material",
+        "androidx.window_window",
+
+        "androidx.compose.runtime_runtime",
+        "androidx.activity_activity-compose",
+        "androidx.compose.ui_ui",
+    ],
 
     manifest: "AndroidManifest.xml",
 
diff --git a/packages/EasterEgg/AndroidManifest.xml b/packages/EasterEgg/AndroidManifest.xml
index cc7bb4a..d1db237 100644
--- a/packages/EasterEgg/AndroidManifest.xml
+++ b/packages/EasterEgg/AndroidManifest.xml
@@ -1,4 +1,19 @@
-<?xml version="1.0" encoding="utf-8"?>
+<?xml version="1.0" encoding="utf-8"?><!--
+    Copyright (C) 2023 The Android Open Source Project
+
+    Licensed under the Apache License, Version 2.0 (the "License");
+    you may not use this file except in compliance with the License.
+    You may obtain a copy of the License at
+
+         http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing, software
+    distributed under the License is distributed on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+    See the License for the specific language governing permissions and
+    limitations under the License.
+-->
+
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
     package="com.android.egg"
     android:versionCode="12"
@@ -18,8 +33,27 @@
     <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
 
     <application
-        android:icon="@drawable/icon"
+        android:icon="@drawable/android14_patch_adaptive"
         android:label="@string/app_name">
+
+        <!-- Android U easter egg -->
+
+        <activity
+            android:name=".landroid.MainActivity"
+            android:exported="true"
+            android:label="@string/u_egg_name"
+            android:icon="@drawable/android14_patch_adaptive"
+            android:configChanges="orientation|screenLayout|screenSize|density"
+            android:theme="@android:style/Theme.DeviceDefault.NoActionBar.Fullscreen">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="com.android.internal.category.PLATLOGO" />
+            </intent-filter>
+        </activity>
+
+
+        <!-- Android Q easter egg -->
         <activity
             android:name=".quares.QuaresActivity"
             android:exported="true"
@@ -69,7 +103,7 @@
             android:exported="true"
             android:showOnLockScreen="true"
             android:theme="@android:style/Theme.Material.Light.Dialog.NoActionBar" />
-        <!-- Used to enable easter egg -->
+        <!-- Used to enable easter egg components for earlier easter eggs. -->
         <activity
             android:name=".ComponentActivationActivity"
             android:excludeFromRecents="true"
@@ -79,7 +113,6 @@
                 <action android:name="android.intent.action.MAIN" />
 
                 <category android:name="android.intent.category.DEFAULT" />
-                <category android:name="com.android.internal.category.PLATLOGO" />
             </intent-filter>
         </activity>
 
diff --git a/packages/EasterEgg/res/drawable/android14_patch_adaptive.xml b/packages/EasterEgg/res/drawable/android14_patch_adaptive.xml
new file mode 100644
index 0000000..423e351
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/android14_patch_adaptive.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+Copyright (C) 2023 The Android Open Source Project
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+    you may not use this file except in compliance with the License.
+    You may obtain a copy of the License at
+
+         http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing, software
+    distributed under the License is distributed on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+    See the License for the specific language governing permissions and
+    limitations under the License.
+-->
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@drawable/android14_patch_adaptive_background"/>
+    <foreground android:drawable="@drawable/android14_patch_adaptive_foreground"/>
+    <monochrome android:drawable="@drawable/android14_patch_monochrome"/>
+</adaptive-icon>
\ No newline at end of file
diff --git a/packages/EasterEgg/res/drawable/android14_patch_adaptive_background.xml b/packages/EasterEgg/res/drawable/android14_patch_adaptive_background.xml
new file mode 100644
index 0000000..c31aa7b
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/android14_patch_adaptive_background.xml
@@ -0,0 +1,85 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+Copyright (C) 2023 The Android Open Source Project
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+    you may not use this file except in compliance with the License.
+    You may obtain a copy of the License at
+
+         http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing, software
+    distributed under the License is distributed on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+    See the License for the specific language governing permissions and
+    limitations under the License.
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportWidth="108"
+    android:viewportHeight="108">
+  <path
+      android:pathData="M0,0 L108,0 L108,108 L0,108 z"
+      android:fillColor="#FF073042"/>
+  <path
+      android:pathData="M44.51,43.32L44.86,42.27C47.04,54.48 52.81,86.71 52.81,50.14C52.81,49.99 52.92,49.86 53.06,49.86H55.04C55.18,49.86 55.3,49.98 55.3,50.14C55.27,114.18 44.51,43.32 44.51,43.32Z"
+      android:fillColor="#3DDC84"/>
+  <path
+      android:name="planetary head"
+      android:pathData="M38.81,42.23L33.63,51.21C33.33,51.72 33.51,52.38 34.02,52.68C34.54,52.98 35.2,52.8 35.49,52.28L40.74,43.2C49.22,47 58.92,47 67.4,43.2L72.65,52.28C72.96,52.79 73.62,52.96 74.13,52.65C74.62,52.35 74.79,51.71 74.51,51.21L69.33,42.23C78.23,37.39 84.32,28.38 85.21,17.74H22.93C23.82,28.38 29.91,37.39 38.81,42.23Z"
+      android:fillColor="#ffffff"/>
+  <!-- yes it's an easter egg in a vector drawable -->
+  <path
+      android:name="planetary body"
+      android:pathData="M22.9,0 L85.1,0 L85.1,15.5 L22.9,15.5 z"
+      android:fillColor="#ffffff" />
+  <path
+      android:pathData="M54.96,43.32H53.1C52.92,43.32 52.77,43.47 52.77,43.65V48.04C52.77,48.22 52.92,48.37 53.1,48.37H54.96C55.15,48.37 55.3,48.22 55.3,48.04V43.65C55.3,43.47 55.15,43.32 54.96,43.32Z"
+      android:fillColor="#3DDC84"/>
+  <path
+      android:pathData="M54.99,40.61H53.08C52.91,40.61 52.77,40.75 52.77,40.92V41.56C52.77,41.73 52.91,41.87 53.08,41.87H54.99C55.16,41.87 55.3,41.73 55.3,41.56V40.92C55.3,40.75 55.16,40.61 54.99,40.61Z"
+      android:fillColor="#3DDC84"/>
+  <path
+      android:pathData="M41.49,47.88H40.86V48.51H41.49V47.88Z"
+      android:fillColor="#ffffff"/>
+  <path
+      android:pathData="M44.13,57.08H43.5V57.71H44.13V57.08Z"
+      android:fillColor="#ffffff"/>
+  <path
+      android:pathData="M72.29,66.76H71.66V67.39H72.29V66.76Z"
+      android:fillColor="#ffffff"/>
+  <path
+      android:pathData="M59.31,53.41H58.68V54.04H59.31V53.41Z"
+      android:fillColor="#ffffff"/>
+  <path
+      android:pathData="M64.47,48.19H63.84V48.83H64.47V48.19Z"
+      android:fillColor="#ffffff"/>
+  <path
+      android:pathData="M60.58,59.09H59.95V59.72H60.58V59.09Z"
+      android:fillColor="#ffffff"/>
+  <path
+      android:pathData="M66.95,56.7H65.69V57.97H66.95V56.7Z"
+      android:fillColor="#ffffff"/>
+  <path
+      android:pathData="M44.13,60.71H43.5V61.34H44.13V60.71Z"
+      android:fillColor="#ffffff"/>
+  <path
+      android:pathData="M49.66,51.33H48.4V52.6H49.66V51.33Z"
+      android:fillColor="#ffffff"/>
+  <path
+      android:pathData="M57.78,63.83H56.52V65.09H57.78V63.83Z"
+      android:fillColor="#ffffff"/>
+  <path
+      android:pathData="M61.1,68.57H59.83V69.83H61.1V68.57Z"
+      android:fillColor="#ffffff"/>
+  <path
+      android:pathData="M40.43,53.73H39.16V54.99H40.43V53.73Z"
+      android:fillColor="#ffffff"/>
+  <path
+      android:pathData="M74.47,44H73.21V45.26H74.47V44Z"
+      android:fillColor="#ffffff"/>
+  <path
+      android:pathData="M36.8,64.58H35.54V65.84H36.8V64.58Z"
+      android:fillColor="#ffffff"/>
+</vector>
diff --git a/packages/EasterEgg/res/drawable/android14_patch_adaptive_foreground.xml b/packages/EasterEgg/res/drawable/android14_patch_adaptive_foreground.xml
new file mode 100644
index 0000000..391d515
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/android14_patch_adaptive_foreground.xml
@@ -0,0 +1,60 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+Copyright (C) 2023 The Android Open Source Project
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+    you may not use this file except in compliance with the License.
+    You may obtain a copy of the License at
+
+         http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing, software
+    distributed under the License is distributed on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+    See the License for the specific language governing permissions and
+    limitations under the License.
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportWidth="108"
+    android:viewportHeight="108">
+  <path
+      android:pathData="M54.03,33.03C52.99,33.03 52.14,33.86 52.14,34.87V37.14C52.14,37.34 52.3,37.5 52.5,37.5C52.69,37.5 52.85,37.34 52.85,37.14V36.53C52.85,36.14 53.17,35.82 53.56,35.82H54.51C54.9,35.82 55.22,36.14 55.22,36.53V37.14C55.22,37.34 55.38,37.5 55.57,37.5C55.77,37.5 55.93,37.34 55.93,37.14V34.87C55.93,33.86 55.08,33.03 54.03,33.03H54.03Z"
+      android:fillColor="#3DDC84"/>
+  <path
+      android:pathData="M108,0H0V108H108V0ZM54,80.67C68.73,80.67 80.67,68.73 80.67,54C80.67,39.27 68.73,27.33 54,27.33C39.27,27.33 27.33,39.27 27.33,54C27.33,68.73 39.27,80.67 54,80.67Z"
+      android:fillColor="#F86734"
+      android:fillType="evenOdd"/>
+  <group>
+    <!-- the text doesn't look great everywhere but you can remove the clip to try it out. -->
+    <clip-path />
+    <path
+        android:pathData="M28.58,32.18L29.18,31.5L33.82,33.02L33.12,33.81L32.15,33.48L30.92,34.87L31.37,35.8L30.68,36.58L28.58,32.18L28.58,32.18ZM31.25,33.18L29.87,32.71L30.51,34.02L31.25,33.18V33.18Z"
+        android:fillColor="#ffffff"/>
+    <path
+        android:pathData="M38,29.76L34.61,28.79L36.23,31.04L35.42,31.62L32.8,27.99L33.5,27.48L36.88,28.45L35.26,26.21L36.08,25.62L38.7,29.25L38,29.76Z"
+        android:fillColor="#ffffff"/>
+    <path
+        android:pathData="M39.23,23.87L40.63,23.27C41.79,22.77 43.13,23.28 43.62,24.43C44.11,25.57 43.56,26.89 42.4,27.39L40.99,27.99L39.23,23.87ZM42.03,26.54C42.73,26.24 42.96,25.49 42.68,24.83C42.4,24.17 41.69,23.82 41,24.11L40.51,24.32L41.55,26.75L42.03,26.54Z"
+        android:fillColor="#ffffff"/>
+    <path
+        android:pathData="M45.91,21.43L47.64,21.09C48.47,20.93 49.12,21.41 49.27,22.15C49.38,22.72 49.15,23.14 48.63,23.45L50.57,25.08L49.39,25.31L47.57,23.79L47.41,23.82L47.76,25.63L46.78,25.83L45.91,21.43H45.91ZM47.87,22.86C48.16,22.8 48.34,22.59 48.29,22.34C48.24,22.07 48,21.96 47.71,22.02L47.07,22.14L47.24,22.98L47.87,22.86Z"
+        android:fillColor="#ffffff"/>
+    <path
+        android:pathData="M52.17,22.69C52.19,21.41 53.24,20.39 54.52,20.41C55.8,20.43 56.82,21.49 56.8,22.76C56.78,24.04 55.72,25.06 54.45,25.04C53.17,25.02 52.15,23.96 52.17,22.69ZM55.79,22.75C55.8,22.02 55.23,21.39 54.51,21.38C53.78,21.37 53.19,21.98 53.18,22.7C53.17,23.43 53.73,24.06 54.47,24.07C55.19,24.08 55.78,23.47 55.79,22.75H55.79Z"
+        android:fillColor="#ffffff"/>
+    <path
+        android:pathData="M60,21.01L60.98,21.2L60.12,25.6L59.14,25.41L60,21.01Z"
+        android:fillColor="#ffffff"/>
+    <path
+        android:pathData="M64.3,22.03L65.73,22.58C66.91,23.03 67.51,24.32 67.07,25.49C66.62,26.65 65.31,27.22 64.13,26.77L62.71,26.22L64.3,22.03L64.3,22.03ZM64.46,25.9C65.17,26.17 65.86,25.8 66.12,25.12C66.37,24.45 66.11,23.71 65.4,23.44L64.91,23.25L63.97,25.72L64.46,25.9Z"
+        android:fillColor="#ffffff"/>
+    <path
+        android:pathData="M73.59,27.94L72.94,27.44L73.51,26.69L74.92,27.77L72.2,31.34L71.45,30.76L73.59,27.94Z"
+        android:fillColor="#ffffff"/>
+    <path
+        android:pathData="M76.18,33.75L74.69,32.14L75.25,31.62L78.81,31.42L79.4,32.05L77.47,33.85L77.86,34.27L77.22,34.86L76.83,34.44L76.12,35.11L75.47,34.41L76.18,33.75ZM77.72,32.31L76.12,32.4L76.82,33.15L77.72,32.31Z"
+        android:fillColor="#ffffff"/>
+  </group>
+</vector>
diff --git a/packages/EasterEgg/res/drawable/android14_patch_monochrome.xml b/packages/EasterEgg/res/drawable/android14_patch_monochrome.xml
new file mode 100644
index 0000000..beef85c
--- /dev/null
+++ b/packages/EasterEgg/res/drawable/android14_patch_monochrome.xml
@@ -0,0 +1,84 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+Copyright (C) 2023 The Android Open Source Project
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+    you may not use this file except in compliance with the License.
+    You may obtain a copy of the License at
+
+         http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing, software
+    distributed under the License is distributed on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+    See the License for the specific language governing permissions and
+    limitations under the License.
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportWidth="108"
+    android:viewportHeight="108">
+  <group>
+    <clip-path
+        android:pathData="M0,0h108v108h-108z"/>
+    <group>
+      <clip-path
+          android:pathData="M22,22h64v64h-64z"/>
+      <path
+          android:pathData="M54,78C67.25,78 78,67.25 78,54C78,40.75 67.25,30 54,30C40.75,30 30,40.75 30,54C30,67.25 40.75,78 54,78Z"
+          android:strokeWidth="5"
+          android:fillColor="#00000000"
+          android:strokeColor="#000000"/>
+      <group>
+        <clip-path
+            android:pathData="M77.5,54C77.5,66.98 66.98,77.5 54,77.5C41.02,77.5 30.5,66.98 30.5,54C30.5,41.02 41.02,30.5 54,30.5C66.98,30.5 77.5,41.02 77.5,54Z"/>
+        <path
+            android:pathData="M61.5,46.06C56.7,47.89 51.4,47.89 46.61,46.06L44.04,50.51C43.49,51.46 42.28,51.79 41.33,51.24C40.39,50.69 40.06,49.48 40.61,48.53L43.06,44.28C37.97,41.03 34.54,35.56 34,29.19L33.88,27.74H74.22L74.1,29.19C73.57,35.56 70.14,41.03 65.04,44.28L67.51,48.56C68.03,49.49 67.71,50.66 66.8,51.21C65.87,51.77 64.65,51.47 64.08,50.54L64.07,50.51L61.5,46.06Z"
+            android:fillColor="#000000"/>
+      </group>
+      <path
+          android:pathData="M51.33,67.33h1.33v1.33h-1.33z"
+          android:fillColor="#000000"/>
+      <path
+          android:pathData="M48.67,62h1.33v1.33h-1.33z"
+          android:fillColor="#000000"/>
+      <path
+          android:pathData="M56.67,70h1.33v1.33h-1.33z"
+          android:fillColor="#000000"/>
+      <path
+          android:pathData="M56.67,62h2.67v2.67h-2.67z"
+          android:fillColor="#000000"/>
+      <path
+          android:pathData="M67.33,62h1.33v1.33h-1.33z"
+          android:fillColor="#000000"/>
+      <path
+          android:pathData="M59.33,51.33h2.67v2.67h-2.67z"
+          android:fillColor="#000000"/>
+      <path
+          android:pathData="M62,59.33h1.33v1.33h-1.33z"
+          android:fillColor="#000000"/>
+      <path
+          android:pathData="M70,54h1.33v1.33h-1.33z"
+          android:fillColor="#000000"/>
+      <path
+          android:pathData="M35.33,56.67h1.33v1.33h-1.33z"
+          android:fillColor="#000000"/>
+      <path
+          android:pathData="M35.33,48.67h1.33v1.33h-1.33z"
+          android:fillColor="#000000"/>
+      <path
+          android:pathData="M40.67,59.33h2.67v2.67h-2.67z"
+          android:fillColor="#000000"/>
+      <path
+          android:pathData="M46,51.33h1.33v1.33h-1.33z"
+          android:fillColor="#000000"/>
+      <path
+          android:pathData="M43.33,67.33h1.33v1.33h-1.33z"
+          android:fillColor="#000000"/>
+      <path
+          android:pathData="M54,54h1.33v1.33h-1.33z"
+          android:fillColor="#000000"/>
+    </group>
+  </group>
+</vector>
diff --git a/packages/EasterEgg/res/values/landroid_strings.xml b/packages/EasterEgg/res/values/landroid_strings.xml
new file mode 100644
index 0000000..1394f2f
--- /dev/null
+++ b/packages/EasterEgg/res/values/landroid_strings.xml
@@ -0,0 +1,371 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+    Copyright (C) 2023 The Android Open Source Project
+
+    Licensed under the Apache License, Version 2.0 (the "License");
+    you may not use this file except in compliance with the License.
+    You may obtain a copy of the License at
+
+         http://www.apache.org/licenses/LICENSE-2.0
+
+    Unless required by applicable law or agreed to in writing, software
+    distributed under the License is distributed on an "AS IS" BASIS,
+    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+    See the License for the specific language governing permissions and
+    limitations under the License.
+-->
+
+<resources>
+    <string name="u_egg_name" translatable="false">Android 14 Easter Egg</string>
+
+    <string-array name="planet_descriptors" translatable="false">
+        <item>earthy</item>
+        <item>swamp</item>
+        <item>frozen</item>
+        <item>grassy</item>
+        <item>arid</item>
+        <item>crowded</item>
+        <item>ancient</item>
+        <item>lively</item>
+        <item>homey</item>
+        <item>modern</item>
+        <item>boring</item>
+        <item>compact</item>
+        <item>expensive</item>
+        <item>polluted</item>
+        <item>rusty</item>
+        <item>sandy</item>
+        <item>undulating</item>
+        <item>verdant</item>
+        <item>tessellated</item>
+        <item>hollow</item>
+        <item>scalding</item>
+        <item>hemispherical</item>
+        <item>oblong</item>
+        <item>oblate</item>
+        <item>vacuum</item>
+        <item>high-pressure</item>
+        <item>low-pressure</item>
+        <item>plastic</item>
+        <item>metallic</item>
+        <item>burned-out</item>
+        <item>bucolic</item>
+    </string-array>
+
+    <string-array name="life_descriptors" translatable="false">
+        <item>aggressive</item>
+        <item>passive-aggressive</item>
+        <item>shy</item>
+        <item>timid</item>
+        <item>nasty</item>
+        <item>brutish</item>
+        <item>short</item>
+        <item>absent</item>
+        <item>teen-aged</item>
+        <item>confused</item>
+        <item>transparent</item>
+        <item>cubic</item>
+        <item>quadratic</item>
+        <item>higher-order</item>
+        <item>huge</item>
+        <item>tall</item>
+        <item>wary</item>
+        <item>loud</item>
+        <item>yodeling</item>
+        <item>purring</item>
+        <item>slender</item>
+        <item>cats</item>
+        <item>adorable</item>
+        <item>eclectic</item>
+        <item>electric</item>
+        <item>microscopic</item>
+        <item>trunkless</item>
+        <item>myriad</item>
+        <item>cantankerous</item>
+        <item>gargantuan</item>
+        <item>contagious</item>
+        <item>fungal</item>
+        <item>cattywampus</item>
+        <item>spatchcocked</item>
+        <item>rotisserie</item>
+        <item>farm-to-table</item>
+        <item>organic</item>
+        <item>synthetic</item>
+        <item>unfocused</item>
+        <item>focused</item>
+        <item>capitalist</item>
+        <item>communal</item>
+        <item>bossy</item>
+        <item>malicious</item>
+        <item>compliant</item>
+        <item>psychic</item>
+        <item>oblivious</item>
+        <item>passive</item>
+        <item>bonsai</item>
+    </string-array>
+
+    <string-array name="any_descriptors" translatable="false">
+        <item>silly</item>
+        <item>dangerous</item>
+        <item>vast</item>
+        <item>invisible</item>
+        <item>superfluous</item>
+        <item>superconducting</item>
+        <item>superior</item>
+        <item>alien</item>
+        <item>phantom</item>
+        <item>friendly</item>
+        <item>peaceful</item>
+        <item>lonely</item>
+        <item>uncomfortable</item>
+        <item>charming</item>
+        <item>fractal</item>
+        <item>imaginary</item>
+        <item>forgotten</item>
+        <item>tardy</item>
+        <item>gassy</item>
+        <item>fungible</item>
+        <item>bespoke</item>
+        <item>artisanal</item>
+        <item>exceptional</item>
+        <item>puffy</item>
+        <item>rusty</item>
+        <item>fresh</item>
+        <item>crusty</item>
+        <item>glossy</item>
+        <item>lovely</item>
+        <item>processed</item>
+        <item>macabre</item>
+        <item>reticulated</item>
+        <item>shocking</item>
+        <item>void</item>
+        <item>undefined</item>
+        <item>gothic</item>
+        <item>beige</item>
+        <item>mid</item>
+        <item>milquetoast</item>
+        <item>melancholy</item>
+        <item>unnerving</item>
+        <item>cheery</item>
+        <item>vibrant</item>
+        <item>heliotrope</item>
+        <item>psychedelic</item>
+        <item>nondescript</item>
+        <item>indescribable</item>
+        <item>tubular</item>
+        <item>toroidal</item>
+        <item>voxellated</item>
+        <item>low-poly</item>
+        <item>low-carb</item>
+        <item>100% cotton</item>
+        <item>synthetic</item>
+        <item>boot-cut</item>
+        <item>bell-bottom</item>
+        <item>bumpy</item>
+        <item>fluffy</item>
+        <item>sous-vide</item>
+        <item>tepid</item>
+        <item>upcycled</item>
+        <item>sous-vide</item>
+        <item>bedazzled</item>
+        <item>ancient</item>
+        <item>inexplicable</item>
+        <item>sparkling</item>
+        <item>still</item>
+        <item>lemon-scented</item>
+        <item>eccentric</item>
+        <item>tilted</item>
+        <item>pungent</item>
+        <item>pine-scented</item>
+        <item>corduroy</item>
+        <item>overengineered</item>
+        <item>bioengineered</item>
+        <item>impossible</item>
+    </string-array>
+
+    <string-array name="constellations" translatable="false">
+        <item>Aries</item>
+        <item>Taurus</item>
+        <item>Gemini</item>
+        <item>Cancer</item>
+        <item>Leo</item>
+        <item>Virgo</item>
+        <item>Libra</item>
+        <item>Scorpio</item>
+        <item>Sagittarius</item>
+        <item>Capricorn</item>
+        <item>Aquarius</item>
+        <item>Pisces</item>
+        <item>Andromeda</item>
+        <item>Cygnus</item>
+        <item>Draco</item>
+        <item>Alcor</item>
+        <item>Calamari</item>
+        <item>Cuckoo</item>
+        <item>Neko</item>
+        <item>Monoceros</item>
+        <item>Norma</item>
+        <item>Abnorma</item>
+        <item>Morel</item>
+        <item>Redlands</item>
+        <item>Cupcake</item>
+        <item>Donut</item>
+        <item>Eclair</item>
+        <item>Froyo</item>
+        <item>Gingerbread</item>
+        <item>Honeycomb</item>
+        <item>Icecreamsandwich</item>
+        <item>Jellybean</item>
+        <item>Kitkat</item>
+        <item>Lollipop</item>
+        <item>Marshmallow</item>
+        <item>Nougat</item>
+        <item>Oreo</item>
+        <item>Pie</item>
+        <item>Quincetart</item>
+        <item>Redvelvetcake</item>
+        <item>Snowcone</item>
+        <item>Tiramisu</item>
+        <item>Upsidedowncake</item>
+        <item>Vanillaicecream</item>
+        <item>Android</item>
+        <item>Binder</item>
+        <item>Campanile</item>
+        <item>Dread</item>
+    </string-array>
+
+    <!-- prob: 5% -->
+    <string-array name="constellations_rare" translatable="false">
+        <item>Jandycane</item>
+        <item>Zombiegingerbread</item>
+        <item>Astro</item>
+        <item>Bender</item>
+        <item>Flan</item>
+        <item>Untitled-1</item>
+        <item>Expedit</item>
+        <item>Petit Four</item>
+        <item>Worcester</item>
+        <item>Xylophone</item>
+        <item>Yellowpeep</item>
+        <item>Zebraball</item>
+        <item>Hutton</item>
+        <item>Klang</item>
+        <item>Frogblast</item>
+        <item>Exo</item>
+        <item>Keylimepie</item>
+        <item>Nat</item>
+        <item>Nrp</item>
+    </string-array>
+
+    <!-- prob: 75% -->
+    <string-array name="star_suffixes" translatable="false">
+        <item>Alpha</item>
+        <item>Beta</item>
+        <item>Gamma</item>
+        <item>Delta</item>
+        <item>Epsilon</item>
+        <item>Zeta</item>
+        <item>Eta</item>
+        <item>Theta</item>
+        <item>Iota</item>
+        <item>Kappa</item>
+        <item>Lambda</item>
+        <item>Mu</item>
+        <item>Nu</item>
+        <item>Xi</item>
+        <item>Omicron</item>
+        <item>Pi</item>
+        <item>Rho</item>
+        <item>Sigma</item>
+        <item>Tau</item>
+        <item>Upsilon</item>
+        <item>Phi</item>
+        <item>Chi</item>
+        <item>Psi</item>
+        <item>Omega</item>
+
+        <item>Prime</item>
+        <item>Secundo</item>
+        <item>Major</item>
+        <item>Minor</item>
+        <item>Diminished</item>
+        <item>Augmented</item>
+        <item>Ultima</item>
+        <item>Penultima</item>
+        <item>Mid</item>
+
+        <item>Proxima</item>
+        <item>Novis</item>
+
+        <item>Plus</item>
+    </string-array>
+
+    <!-- prob: 5% -->
+    <!-- more than one can be appended, with very low prob -->
+    <string-array name="star_suffixes_rare" translatable="false">
+        <item>Serif</item>
+        <item>Sans</item>
+        <item>Oblique</item>
+        <item>Grotesque</item>
+        <item>Handtooled</item>
+        <item>III “Trey”</item>
+        <item>Alfredo</item>
+        <item>2.0</item>
+        <item>(Final)</item>
+        <item>(Final (Final))</item>
+        <item>(Draft)</item>
+        <item>Con Carne</item>
+    </string-array>
+
+    <string-array name="planet_types" translatable="false">
+        <item>planet</item>
+        <item>planetoid</item>
+        <item>moon</item>
+        <item>moonlet</item>
+        <item>centaur</item>
+        <item>asteroid</item>
+        <item>space garbage</item>
+        <item>detritus</item>
+        <item>satellite</item>
+        <item>core</item>
+        <item>giant</item>
+        <item>body</item>
+        <item>slab</item>
+        <item>rock</item>
+        <item>husk</item>
+        <item>planemo</item>
+        <item>object</item>
+        <item>planetesimal</item>
+        <item>exoplanet</item>
+        <item>ploonet</item>
+    </string-array>
+
+    <string-array name="atmo_descriptors" translatable="false">
+        <item>toxic</item>
+        <item>breathable</item>
+        <item>radioactive</item>
+        <item>clear</item>
+        <item>calm</item>
+        <item>peaceful</item>
+        <item>vacuum</item>
+        <item>stormy</item>
+        <item>freezing</item>
+        <item>burning</item>
+        <item>humid</item>
+        <item>tropical</item>
+        <item>cloudy</item>
+        <item>obscured</item>
+        <item>damp</item>
+        <item>dank</item>
+        <item>clammy</item>
+        <item>frozen</item>
+        <item>contaminated</item>
+        <item>temperate</item>
+        <item>moist</item>
+        <item>minty</item>
+        <item>relaxed</item>
+        <item>skunky</item>
+        <item>breezy</item>
+        <item>soup </item>
+    </string-array>
+
+</resources>
diff --git a/packages/EasterEgg/res/values/strings.xml b/packages/EasterEgg/res/values/strings.xml
index 743947a..79957df 100644
--- a/packages/EasterEgg/res/values/strings.xml
+++ b/packages/EasterEgg/res/values/strings.xml
@@ -14,7 +14,7 @@
     limitations under the License.
 -->
 <resources xmlns:android="http://schemas.android.com/apk/res/android">
-    <string name="app_name" translatable="false">Android S Easter Egg</string>
+    <string name="app_name" translatable="false">Android Easter Egg</string>
 
     <!-- name of the Q easter egg, a nonogram-style icon puzzle -->
     <string name="q_egg_name" translatable="false">Icon Quiz</string>
diff --git a/packages/EasterEgg/src/com/android/egg/landroid/Colors.kt b/packages/EasterEgg/src/com/android/egg/landroid/Colors.kt
new file mode 100644
index 0000000..f5657ae
--- /dev/null
+++ b/packages/EasterEgg/src/com/android/egg/landroid/Colors.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.egg.landroid
+
+import androidx.compose.ui.graphics.Color
+
+/** Various UI colors. */
+object Colors {
+    val Eigengrau = Color(0xFF16161D)
+    val Eigengrau2 = Color(0xFF292936)
+    val Eigengrau3 = Color(0xFF3C3C4F)
+    val Eigengrau4 = Color(0xFFA7A7CA)
+
+    val Console = Color(0xFFB7B7FF)
+}
diff --git a/packages/EasterEgg/src/com/android/egg/landroid/ComposeTools.kt b/packages/EasterEgg/src/com/android/egg/landroid/ComposeTools.kt
new file mode 100644
index 0000000..d040fba
--- /dev/null
+++ b/packages/EasterEgg/src/com/android/egg/landroid/ComposeTools.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.egg.landroid
+
+import androidx.compose.animation.core.CubicBezierEasing
+import androidx.compose.animation.core.Easing
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Dp
+import kotlin.random.Random
+
+@Composable fun Dp.toLocalPx() = with(LocalDensity.current) { this@toLocalPx.toPx() }
+
+operator fun Easing.times(next: Easing) = { x: Float -> next.transform(transform(x)) }
+
+fun flickerFadeEasing(rng: Random) = Easing { frac -> if (rng.nextFloat() < frac) 1f else 0f }
+
+val flickerFadeIn =
+    fadeIn(
+        animationSpec =
+            tween(
+                durationMillis = 1000,
+                easing = CubicBezierEasing(0f, 1f, 1f, 0f) * flickerFadeEasing(Random)
+            )
+    )
diff --git a/packages/EasterEgg/src/com/android/egg/landroid/MainActivity.kt b/packages/EasterEgg/src/com/android/egg/landroid/MainActivity.kt
new file mode 100644
index 0000000..5a9b814
--- /dev/null
+++ b/packages/EasterEgg/src/com/android/egg/landroid/MainActivity.kt
@@ -0,0 +1,543 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.egg.landroid
+
+import android.content.res.Resources
+import android.os.Bundle
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.CubicBezierEasing
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.core.withInfiniteAnimationFrameNanos
+import androidx.compose.animation.fadeIn
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.border
+import androidx.compose.foundation.gestures.awaitFirstDown
+import androidx.compose.foundation.gestures.forEachGesture
+import androidx.compose.foundation.gestures.rememberTransformableState
+import androidx.compose.foundation.gestures.transformable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.AbsoluteAlignment.Left
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.PathEffect
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.drawscope.translate
+import androidx.compose.ui.input.pointer.PointerEvent
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Devices
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.core.math.MathUtils.clamp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.window.layout.FoldingFeature
+import androidx.window.layout.WindowInfoTracker
+import java.lang.Float.max
+import java.lang.Float.min
+import java.util.Calendar
+import java.util.GregorianCalendar
+import kotlin.math.absoluteValue
+import kotlin.math.floor
+import kotlin.math.sqrt
+import kotlin.random.Random
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+enum class RandomSeedType {
+    Fixed,
+    Daily,
+    Evergreen
+}
+
+const val TEST_UNIVERSE = false
+
+val RANDOM_SEED_TYPE = RandomSeedType.Daily
+
+const val FIXED_RANDOM_SEED = 5038L
+const val DEFAULT_CAMERA_ZOOM = 0.25f
+const val MIN_CAMERA_ZOOM = 250f / UNIVERSE_RANGE // 0.0025f
+const val MAX_CAMERA_ZOOM = 5f
+const val TOUCH_CAMERA_PAN = false
+const val TOUCH_CAMERA_ZOOM = true
+const val DYNAMIC_ZOOM = false // @@@ FIXME
+
+fun dailySeed(): Long {
+    val today = GregorianCalendar()
+    return today.get(Calendar.YEAR) * 10_000L +
+        today.get(Calendar.MONTH) * 100L +
+        today.get(Calendar.DAY_OF_MONTH)
+}
+
+fun randomSeed(): Long {
+    return when (RANDOM_SEED_TYPE) {
+        RandomSeedType.Fixed -> FIXED_RANDOM_SEED
+        RandomSeedType.Daily -> dailySeed()
+        else -> Random.Default.nextLong().mod(10_000_000).toLong()
+    }.absoluteValue
+}
+
+val DEBUG_TEXT = mutableStateOf("Hello Universe")
+const val SHOW_DEBUG_TEXT = false
+
+@Composable
+fun DebugText(text: MutableState<String>) {
+    if (SHOW_DEBUG_TEXT) {
+        Text(
+            modifier = Modifier.fillMaxWidth().border(0.5.dp, color = Color.Yellow).padding(2.dp),
+            fontFamily = FontFamily.Monospace,
+            fontWeight = FontWeight.Medium,
+            fontSize = 9.sp,
+            color = Color.Yellow,
+            text = text.value
+        )
+    }
+}
+
+@Composable
+fun ColumnScope.ConsoleText(
+    modifier: Modifier = Modifier,
+    visible: Boolean = true,
+    random: Random = Random.Default,
+    text: String
+) {
+    AnimatedVisibility(
+        modifier = modifier,
+        visible = visible,
+        enter =
+            fadeIn(
+                animationSpec =
+                    tween(
+                        durationMillis = 1000,
+                        easing = flickerFadeEasing(random) * CubicBezierEasing(0f, 1f, 1f, 0f)
+                    )
+            )
+    ) {
+        Text(
+            fontFamily = FontFamily.Monospace,
+            fontWeight = FontWeight.Medium,
+            fontSize = 12.sp,
+            color = Color(0xFFFF8000),
+            text = text
+        )
+    }
+}
+
+@Composable
+fun Telemetry(universe: VisibleUniverse) {
+    var topVisible by remember { mutableStateOf(false) }
+    var bottomVisible by remember { mutableStateOf(false) }
+
+    LaunchedEffect("blah") {
+        delay(1000)
+        bottomVisible = true
+        delay(1000)
+        topVisible = true
+    }
+
+    Column(modifier = Modifier.fillMaxSize().padding(6.dp)) {
+        universe.triggerDraw.value // recompose on every frame
+        val explored = universe.planets.filter { it.explored }
+
+        AnimatedVisibility(modifier = Modifier, visible = topVisible, enter = flickerFadeIn) {
+            Text(
+                fontFamily = FontFamily.Monospace,
+                fontWeight = FontWeight.Medium,
+                fontSize = 12.sp,
+                color = Colors.Console,
+                modifier = Modifier.align(Left),
+                text =
+                    with(universe.star) {
+                        "  STAR: $name (UDC-${universe.randomSeed % 100_000})\n" +
+                            " CLASS: ${cls.name}\n" +
+                            "RADIUS: ${radius.toInt()}\n" +
+                            "  MASS: %.3g\n".format(mass) +
+                            "BODIES: ${explored.size} / ${universe.planets.size}\n" +
+                            "\n"
+                    } +
+                        explored
+                            .map {
+                                "  BODY: ${it.name}\n" +
+                                    "  TYPE: ${it.description.capitalize()}\n" +
+                                    "  ATMO: ${it.atmosphere.capitalize()}\n" +
+                                    " FAUNA: ${it.fauna.capitalize()}\n" +
+                                    " FLORA: ${it.flora.capitalize()}\n"
+                            }
+                            .joinToString("\n")
+
+                // TODO: different colors, highlight latest discovery
+                )
+        }
+
+        Spacer(modifier = Modifier.weight(1f))
+
+        AnimatedVisibility(modifier = Modifier, visible = bottomVisible, enter = flickerFadeIn) {
+            Text(
+                fontFamily = FontFamily.Monospace,
+                fontWeight = FontWeight.Medium,
+                fontSize = 12.sp,
+                color = Colors.Console,
+                modifier = Modifier.align(Left),
+                text =
+                    with(universe.ship) {
+                        val closest = universe.closestPlanet()
+                        val distToClosest = (closest.pos - pos).mag().toInt()
+                        listOfNotNull(
+                                landing?.let { "LND: ${it.planet.name}" }
+                                    ?: if (distToClosest < 10_000) {
+                                        "ALT: $distToClosest"
+                                    } else null,
+                                if (thrust != Vec2.Zero) "THR: %.0f%%".format(thrust.mag() * 100f)
+                                else null,
+                                "POS: %s".format(pos.str("%+7.0f")),
+                                "VEL: %.0f".format(velocity.mag())
+                            )
+                            .joinToString("\n")
+                    }
+            )
+        }
+    }
+}
+
+class MainActivity : ComponentActivity() {
+    private var foldState = mutableStateOf<FoldingFeature?>(null)
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+
+        onWindowLayoutInfoChange()
+
+        val universe = VisibleUniverse(namer = Namer(resources), randomSeed = randomSeed())
+
+        if (TEST_UNIVERSE) {
+            universe.initTest()
+        } else {
+            universe.initRandom()
+        }
+
+        setContent {
+            Spaaaace(modifier = Modifier.fillMaxSize(), u = universe, foldState = foldState)
+            DebugText(DEBUG_TEXT)
+
+            val minRadius = 50.dp.toLocalPx()
+            val maxRadius = 100.dp.toLocalPx()
+            FlightStick(
+                modifier = Modifier.fillMaxSize(),
+                minRadius = minRadius,
+                maxRadius = maxRadius,
+                color = Color.Green
+            ) { vec ->
+                (universe.follow as? Spacecraft)?.let { ship ->
+                    if (vec == Vec2.Zero) {
+                        ship.thrust = Vec2.Zero
+                    } else {
+                        val a = vec.angle()
+                        ship.angle = a
+
+                        val m = vec.mag()
+                        if (m < minRadius) {
+                            // within this radius, just reorient
+                            ship.thrust = Vec2.Zero
+                        } else {
+                            ship.thrust =
+                                Vec2.makeWithAngleMag(
+                                    a,
+                                    lexp(minRadius, maxRadius, m).coerceIn(0f, 1f)
+                                )
+                        }
+                    }
+                }
+            }
+            Telemetry(universe)
+        }
+    }
+
+    private fun onWindowLayoutInfoChange() {
+        val windowInfoTracker = WindowInfoTracker.getOrCreate(this@MainActivity)
+
+        lifecycleScope.launch(Dispatchers.Main) {
+            lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
+                windowInfoTracker.windowLayoutInfo(this@MainActivity).collect { layoutInfo ->
+                    foldState.value =
+                        layoutInfo.displayFeatures.filterIsInstance<FoldingFeature>().firstOrNull()
+                    Log.v("Landroid", "fold updated: $foldState")
+                }
+            }
+        }
+    }
+}
+
+@Preview(name = "phone", device = Devices.PHONE)
+@Preview(name = "fold", device = Devices.FOLDABLE)
+@Preview(name = "tablet", device = Devices.TABLET)
+@Composable
+fun MainActivityPreview() {
+    val universe = VisibleUniverse(namer = Namer(Resources.getSystem()), randomSeed = randomSeed())
+
+    universe.initTest()
+
+    Spaaaace(modifier = Modifier.fillMaxSize(), universe)
+    DebugText(DEBUG_TEXT)
+    Telemetry(universe)
+}
+
+@Composable
+fun FlightStick(
+    modifier: Modifier,
+    minRadius: Float = 0f,
+    maxRadius: Float = 1000f,
+    color: Color = Color.Green,
+    onStickChanged: (vector: Vec2) -> Unit
+) {
+    val origin = remember { mutableStateOf(Vec2.Zero) }
+    val target = remember { mutableStateOf(Vec2.Zero) }
+
+    Box(
+        modifier =
+            modifier
+                .pointerInput(Unit) {
+                    forEachGesture {
+                        awaitPointerEventScope {
+                            // ACTION_DOWN
+                            val down = awaitFirstDown(requireUnconsumed = false)
+                            origin.value = down.position
+                            target.value = down.position
+
+                            do {
+                                // ACTION_MOVE
+                                val event: PointerEvent = awaitPointerEvent()
+                                target.value = event.changes[0].position
+
+                                onStickChanged(target.value - origin.value)
+                            } while (
+                                !event.changes.any { it.isConsumed } &&
+                                    event.changes.count { it.pressed } == 1
+                            )
+
+                            // ACTION_UP / CANCEL
+                            target.value = Vec2.Zero
+                            origin.value = Vec2.Zero
+
+                            onStickChanged(Vec2.Zero)
+                        }
+                    }
+                }
+                .drawBehind {
+                    if (origin.value != Vec2.Zero) {
+                        val delta = target.value - origin.value
+                        val mag = min(maxRadius, delta.mag())
+                        val r = max(minRadius, mag)
+                        val a = delta.angle()
+                        drawCircle(
+                            color = color,
+                            center = origin.value,
+                            radius = r,
+                            style =
+                                Stroke(
+                                    width = 2f,
+                                    pathEffect =
+                                        if (mag < minRadius)
+                                            PathEffect.dashPathEffect(
+                                                floatArrayOf(this.density * 1f, this.density * 2f)
+                                            )
+                                        else null
+                                )
+                        )
+                        drawLine(
+                            color = color,
+                            start = origin.value,
+                            end = origin.value + Vec2.makeWithAngleMag(a, mag),
+                            strokeWidth = 2f
+                        )
+                    }
+                }
+    )
+}
+
+@Composable
+fun Spaaaace(
+    modifier: Modifier,
+    u: VisibleUniverse,
+    foldState: MutableState<FoldingFeature?> = mutableStateOf(null)
+) {
+    LaunchedEffect(u) {
+        while (true) withInfiniteAnimationFrameNanos { frameTimeNanos ->
+            u.simulateAndDrawFrame(frameTimeNanos)
+        }
+    }
+
+    var cameraZoom by remember { mutableStateOf(1f) }
+    var cameraOffset by remember { mutableStateOf(Offset.Zero) }
+
+    val transformableState =
+        rememberTransformableState { zoomChange, offsetChange, rotationChange ->
+            if (TOUCH_CAMERA_PAN) cameraOffset += offsetChange / cameraZoom
+            if (TOUCH_CAMERA_ZOOM)
+                cameraZoom = clamp(cameraZoom * zoomChange, MIN_CAMERA_ZOOM, MAX_CAMERA_ZOOM)
+        }
+
+    var canvasModifier = modifier
+
+    if (TOUCH_CAMERA_PAN || TOUCH_CAMERA_ZOOM) {
+        canvasModifier = canvasModifier.transformable(transformableState)
+    }
+
+    val halfFolded = foldState.value?.let { it.state == FoldingFeature.State.HALF_OPENED } ?: false
+    val horizontalFold =
+        foldState.value?.let { it.orientation == FoldingFeature.Orientation.HORIZONTAL } ?: false
+
+    val centerFracX: Float by
+        animateFloatAsState(if (halfFolded && !horizontalFold) 0.25f else 0.5f, label = "centerX")
+    val centerFracY: Float by
+        animateFloatAsState(if (halfFolded && horizontalFold) 0.25f else 0.5f, label = "centerY")
+
+    Canvas(modifier = canvasModifier) {
+        drawRect(Colors.Eigengrau, Offset.Zero, size)
+
+        val closest = u.closestPlanet()
+        val distToNearestSurf = max(0f, (u.ship.pos - closest.pos).mag() - closest.radius * 1.2f)
+        //        val normalizedDist = clamp(distToNearestSurf, 50f, 50_000f) / 50_000f
+        if (DYNAMIC_ZOOM) {
+            //            cameraZoom = lerp(0.1f, 5f, smooth(1f-normalizedDist))
+            cameraZoom = clamp(500f / distToNearestSurf, MIN_CAMERA_ZOOM, MAX_CAMERA_ZOOM)
+        } else if (!TOUCH_CAMERA_ZOOM) cameraZoom = DEFAULT_CAMERA_ZOOM
+        if (!TOUCH_CAMERA_PAN) cameraOffset = (u.follow?.pos ?: Vec2.Zero) * -1f
+
+        // cameraZoom: metersToPixels
+        // visibleSpaceSizeMeters: meters
+        // cameraOffset: meters ≈ vector pointing from ship to (0,0) (e.g. -pos)
+        val visibleSpaceSizeMeters = size / cameraZoom // meters x meters
+        val visibleSpaceRectMeters =
+            Rect(
+                -cameraOffset -
+                    Offset(
+                        visibleSpaceSizeMeters.width * centerFracX,
+                        visibleSpaceSizeMeters.height * centerFracY
+                    ),
+                visibleSpaceSizeMeters
+            )
+
+        var gridStep = 1000f
+        while (gridStep * cameraZoom < 32.dp.toPx()) gridStep *= 10
+
+        DEBUG_TEXT.value =
+            ("SIMULATION //\n" +
+                // "normalizedDist=${normalizedDist} \n" +
+                "entities: ${u.entities.size} // " +
+                "zoom: ${"%.4f".format(cameraZoom)}x // " +
+                "fps: ${"%3.0f".format(1f / u.dt)} " +
+                "dt: ${u.dt}\n" +
+                ((u.follow as? Spacecraft)?.let {
+                    "ship: p=%s v=%7.2f a=%6.3f t=%s\n".format(
+                        it.pos.str("%+7.1f"),
+                        it.velocity.mag(),
+                        it.angle,
+                        it.thrust.str("%+5.2f")
+                    )
+                }
+                    ?: "") +
+                "star: '${u.star.name}' designation=UDC-${u.randomSeed % 100_000} " +
+                "class=${u.star.cls.name} r=${u.star.radius.toInt()} m=${u.star.mass}\n" +
+                "planets: ${u.planets.size}\n" +
+                    u.planets.joinToString("\n") {
+                        val range = (u.ship.pos - it.pos).mag()
+                        val vorbit = sqrt(GRAVITATION * it.mass / range)
+                        val vescape = sqrt(2 * GRAVITATION * it.mass / it.radius)
+                        " * ${it.name}:\n" +
+                                if (it.explored) {
+                                    "   TYPE:  ${it.description.capitalize()}\n" +
+                                            "   ATMO:  ${it.atmosphere.capitalize()}\n" +
+                                            "   FAUNA: ${it.fauna.capitalize()}\n" +
+                                            "   FLORA: ${it.flora.capitalize()}\n"
+                                } else {
+                                    "   (Unexplored)\n"
+                                } +
+                                "   orbit=${(it.pos - it.orbitCenter).mag().toInt()}" +
+                                " radius=${it.radius.toInt()}" +
+                                " mass=${"%g".format(it.mass)}" +
+                                " vel=${(it.speed).toInt()}" +
+                                " // range=${"%.0f".format(range)}" +
+                                " vorbit=${vorbit.toInt()} vescape=${vescape.toInt()}"
+                    })
+
+        zoom(cameraZoom) {
+            // All coordinates are space coordinates now.
+
+            translate(
+                -visibleSpaceRectMeters.center.x + size.width * 0.5f,
+                -visibleSpaceRectMeters.center.y + size.height * 0.5f
+            ) {
+                // debug outer frame
+                // drawRect(
+                //     Colors.Eigengrau2,
+                //     visibleSpaceRectMeters.topLeft,
+                //     visibleSpaceRectMeters.size,
+                //     style = Stroke(width = 10f / cameraZoom)
+                // )
+
+                var x = floor(visibleSpaceRectMeters.left / gridStep) * gridStep
+                while (x < visibleSpaceRectMeters.right) {
+                    drawLine(
+                        color = Colors.Eigengrau2,
+                        start = Offset(x, visibleSpaceRectMeters.top),
+                        end = Offset(x, visibleSpaceRectMeters.bottom),
+                        strokeWidth = (if ((x % (gridStep * 10) == 0f)) 3f else 1.5f) / cameraZoom
+                    )
+                    x += gridStep
+                }
+
+                var y = floor(visibleSpaceRectMeters.top / gridStep) * gridStep
+                while (y < visibleSpaceRectMeters.bottom) {
+                    drawLine(
+                        color = Colors.Eigengrau2,
+                        start = Offset(visibleSpaceRectMeters.left, y),
+                        end = Offset(visibleSpaceRectMeters.right, y),
+                        strokeWidth = (if ((y % (gridStep * 10) == 0f)) 3f else 1.5f) / cameraZoom
+                    )
+                    y += gridStep
+                }
+
+                this@zoom.drawUniverse(u)
+            }
+        }
+    }
+}
diff --git a/packages/EasterEgg/src/com/android/egg/landroid/Maths.kt b/packages/EasterEgg/src/com/android/egg/landroid/Maths.kt
new file mode 100644
index 0000000..fdf29f7
--- /dev/null
+++ b/packages/EasterEgg/src/com/android/egg/landroid/Maths.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.egg.landroid
+
+import kotlin.math.pow
+
+/** smoothstep. Ken Perlin's version */
+fun smooth(x: Float): Float {
+    return x * x * x * (x * (x * 6 - 15) + 10)
+}
+
+/** Kind of like an inverted smoothstep, but */
+fun invsmoothish(x: Float): Float {
+    return 0.25f * ((2f * x - 1f).pow(5f) + 1f) + 0.5f * x
+}
+
+/** Compute the fraction that progress represents between start and end (inverse of lerp). */
+fun lexp(start: Float, end: Float, progress: Float): Float {
+    return (progress - start) / (end - start)
+}
diff --git a/packages/EasterEgg/src/com/android/egg/landroid/Namer.kt b/packages/EasterEgg/src/com/android/egg/landroid/Namer.kt
new file mode 100644
index 0000000..67d536e
--- /dev/null
+++ b/packages/EasterEgg/src/com/android/egg/landroid/Namer.kt
@@ -0,0 +1,96 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.egg.landroid
+
+import android.content.res.Resources
+import kotlin.random.Random
+
+import com.android.egg.R
+
+const val SUFFIX_PROB = 0.75f
+const val LETTER_PROB = 0.3f
+const val NUMBER_PROB = 0.3f
+const val RARE_PROB = 0.05f
+
+class Namer(resources: Resources) {
+    private val planetDescriptors = Bag(resources.getStringArray(R.array.planet_descriptors))
+    private val lifeDescriptors = Bag(resources.getStringArray(R.array.life_descriptors))
+    private val anyDescriptors = Bag(resources.getStringArray(R.array.any_descriptors))
+    private val atmoDescriptors = Bag(resources.getStringArray(R.array.atmo_descriptors))
+
+    private val planetTypes = Bag(resources.getStringArray(R.array.planet_types))
+    private val constellations = Bag(resources.getStringArray(R.array.constellations))
+    private val constellationsRare = Bag(resources.getStringArray(R.array.constellations_rare))
+    private val suffixes = Bag(resources.getStringArray(R.array.star_suffixes))
+    private val suffixesRare = Bag(resources.getStringArray(R.array.star_suffixes_rare))
+
+    private val planetTable = RandomTable(0.75f to planetDescriptors, 0.25f to anyDescriptors)
+
+    private var lifeTable = RandomTable(0.75f to lifeDescriptors, 0.25f to anyDescriptors)
+
+    private var constellationsTable =
+        RandomTable(RARE_PROB to constellationsRare, 1f - RARE_PROB to constellations)
+
+    private var suffixesTable = RandomTable(RARE_PROB to suffixesRare, 1f - RARE_PROB to suffixes)
+
+    private var atmoTable = RandomTable(0.75f to atmoDescriptors, 0.25f to anyDescriptors)
+
+    private var delimiterTable =
+        RandomTable(
+            15f to " ",
+            3f to "-",
+            1f to "_",
+            1f to "/",
+            1f to ".",
+            1f to "*",
+            1f to "^",
+            1f to "#",
+            0.1f to "(^*!%@##!!"
+        )
+
+    fun describePlanet(rng: Random): String {
+        return planetTable.roll(rng).pull(rng) + " " + planetTypes.pull(rng)
+    }
+
+    fun describeLife(rng: Random): String {
+        return lifeTable.roll(rng).pull(rng)
+    }
+
+    fun nameSystem(rng: Random): String {
+        val parts = StringBuilder()
+        parts.append(constellationsTable.roll(rng).pull(rng))
+        if (rng.nextFloat() <= SUFFIX_PROB) {
+            parts.append(delimiterTable.roll(rng))
+            parts.append(suffixesTable.roll(rng).pull(rng))
+            if (rng.nextFloat() <= RARE_PROB) parts.append(' ').append(suffixesRare.pull(rng))
+        }
+        if (rng.nextFloat() <= LETTER_PROB) {
+            parts.append(delimiterTable.roll(rng))
+            parts.append('A' + rng.nextInt(0, 26))
+            if (rng.nextFloat() <= RARE_PROB) parts.append(delimiterTable.roll(rng))
+        }
+        if (rng.nextFloat() <= NUMBER_PROB) {
+            parts.append(delimiterTable.roll(rng))
+            parts.append(rng.nextInt(2, 5039))
+        }
+        return parts.toString()
+    }
+
+    fun describeAtmo(rng: Random): String {
+        return atmoTable.roll(rng).pull(rng)
+    }
+}
diff --git a/packages/EasterEgg/src/com/android/egg/landroid/PathTools.kt b/packages/EasterEgg/src/com/android/egg/landroid/PathTools.kt
new file mode 100644
index 0000000..8510640
--- /dev/null
+++ b/packages/EasterEgg/src/com/android/egg/landroid/PathTools.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.egg.landroid
+
+import android.util.Log
+import androidx.compose.ui.graphics.Path
+import kotlin.math.cos
+import kotlin.math.sin
+
+fun createPolygon(radius: Float, sides: Int): Path {
+    return Path().apply {
+        moveTo(radius, 0f)
+        val angleStep = PI2f / sides
+        for (i in 1 until sides) {
+            lineTo(radius * cos(angleStep * i), radius * sin(angleStep * i))
+        }
+        close()
+    }
+}
+
+fun createStar(radius1: Float, radius2: Float, points: Int): Path {
+    return Path().apply {
+        val angleStep = PI2f / points
+        moveTo(radius1, 0f)
+        lineTo(radius2 * cos(angleStep * (0.5f)), radius2 * sin(angleStep * (0.5f)))
+        for (i in 1 until points) {
+            lineTo(radius1 * cos(angleStep * i), radius1 * sin(angleStep * i))
+            lineTo(radius2 * cos(angleStep * (i + 0.5f)), radius2 * sin(angleStep * (i + 0.5f)))
+        }
+        close()
+    }
+}
+
+fun Path.parseSvgPathData(d: String) {
+    Regex("([A-Z])([-.,0-9e ]+)").findAll(d.trim()).forEach {
+        val cmd = it.groups[1]!!.value
+        val args =
+            it.groups[2]?.value?.split(Regex("\\s+"))?.map { v -> v.toFloat() } ?: emptyList()
+        Log.d("Landroid", "cmd = $cmd, args = " + args.joinToString(","))
+        when (cmd) {
+            "M" -> moveTo(args[0], args[1])
+            "C" -> cubicTo(args[0], args[1], args[2], args[3], args[4], args[5])
+            "L" -> lineTo(args[0], args[1])
+            "Z" -> close()
+            else -> Log.v("Landroid", "unsupported SVG command: $cmd")
+        }
+    }
+}
diff --git a/packages/EasterEgg/src/com/android/egg/landroid/Physics.kt b/packages/EasterEgg/src/com/android/egg/landroid/Physics.kt
new file mode 100644
index 0000000..fc66ad6
--- /dev/null
+++ b/packages/EasterEgg/src/com/android/egg/landroid/Physics.kt
@@ -0,0 +1,160 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.egg.landroid
+
+import android.util.ArraySet
+import kotlin.random.Random
+
+// artificially speed up or slow down the simulation
+const val TIME_SCALE = 1f
+
+// if it's been over 1 real second since our last timestep, don't simulate that elapsed time.
+// this allows the simulation to "pause" when, for example, the activity pauses
+const val MAX_VALID_DT = 1f
+
+interface Entity {
+    // Integrate.
+    // Compute accelerations from forces, add accelerations to velocity, save old position,
+    // add velocity to position.
+    fun update(sim: Simulator, dt: Float)
+
+    // Post-integration step, after constraints are satisfied.
+    fun postUpdate(sim: Simulator, dt: Float)
+}
+
+open class Body(var name: String = "Unknown") : Entity {
+    var pos = Vec2.Zero
+    var opos = Vec2.Zero
+    var velocity = Vec2.Zero
+
+    var mass = 0f
+    var angle = 0f
+    var radius = 0f
+
+    var collides = true
+
+    var omega: Float
+        get() = angle - oangle
+        set(value) {
+            oangle = angle - value
+        }
+
+    var oangle = 0f
+
+    override fun update(sim: Simulator, dt: Float) {
+        if (dt <= 0) return
+
+        // integrate velocity
+        val vscaled = velocity * dt
+        opos = pos
+        pos += vscaled
+
+        // integrate angular velocity
+        //        val wscaled = omega * timescale
+        //        oangle = angle
+        //        angle = (angle + wscaled) % PI2f
+    }
+
+    override fun postUpdate(sim: Simulator, dt: Float) {
+        if (dt <= 0) return
+        velocity = (pos - opos) / dt
+    }
+}
+
+interface Constraint {
+    // Solve constraints. Pick up objects and put them where they are "supposed" to be.
+    fun solve(sim: Simulator, dt: Float)
+}
+
+open class Container(val radius: Float) : Constraint {
+    private val list = ArraySet<Body>()
+    private val softness = 0.0f
+
+    override fun toString(): String {
+        return "Container($radius)"
+    }
+
+    fun add(p: Body) {
+        list.add(p)
+    }
+
+    fun remove(p: Body) {
+        list.remove(p)
+    }
+
+    override fun solve(sim: Simulator, dt: Float) {
+        for (p in list) {
+            if ((p.pos.mag() + p.radius) > radius) {
+                p.pos =
+                    p.pos * (softness) +
+                        Vec2.makeWithAngleMag(p.pos.angle(), radius - p.radius) * (1f - softness)
+            }
+        }
+    }
+}
+
+open class Simulator(val randomSeed: Long) {
+    private var wallClockNanos: Long = 0L
+    var now: Float = 0f
+    var dt: Float = 0f
+    val rng = Random(randomSeed)
+    val entities = ArraySet<Entity>(1000)
+    val constraints = ArraySet<Constraint>(100)
+
+    fun add(e: Entity) = entities.add(e)
+    fun remove(e: Entity) = entities.remove(e)
+    fun add(c: Constraint) = constraints.add(c)
+    fun remove(c: Constraint) = constraints.remove(c)
+
+    open fun updateAll(dt: Float, entities: ArraySet<Entity>) {
+        entities.forEach { it.update(this, dt) }
+    }
+
+    open fun solveAll(dt: Float, constraints: ArraySet<Constraint>) {
+        constraints.forEach { it.solve(this, dt) }
+    }
+
+    open fun postUpdateAll(dt: Float, entities: ArraySet<Entity>) {
+        entities.forEach { it.postUpdate(this, dt) }
+    }
+
+    fun step(nanos: Long) {
+        val firstFrame = (wallClockNanos == 0L)
+
+        dt = (nanos - wallClockNanos) / 1_000_000_000f * TIME_SCALE
+        this.wallClockNanos = nanos
+
+        // we start the simulation on the next frame
+        if (firstFrame || dt > MAX_VALID_DT) return
+
+        // simulation is running; we start accumulating simulation time
+        this.now += dt
+
+        val localEntities = ArraySet(entities)
+        val localConstraints = ArraySet(constraints)
+
+        // position-based dynamics approach:
+        // 1. apply acceleration to velocity, save positions, apply velocity to position
+        updateAll(dt, localEntities)
+
+        // 2. solve all constraints
+        solveAll(dt, localConstraints)
+
+        // 3. compute new velocities from updated positions and saved positions
+        postUpdateAll(dt, localEntities)
+    }
+}
diff --git a/packages/EasterEgg/src/com/android/egg/landroid/Randomness.kt b/packages/EasterEgg/src/com/android/egg/landroid/Randomness.kt
new file mode 100644
index 0000000..ebbb2bd
--- /dev/null
+++ b/packages/EasterEgg/src/com/android/egg/landroid/Randomness.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.egg.landroid
+
+import kotlin.random.Random
+
+/**
+ * A bag of stones. Each time you pull one out it is not replaced, preventing duplicates. When the
+ * bag is exhausted, all the stones are replaced and reshuffled.
+ */
+class Bag<T>(items: Array<T>) {
+    private val remaining = items.copyOf()
+    private var next = remaining.size // will cause a shuffle on first pull()
+
+    /** Return the next random item from the bag, without replacing it. */
+    fun pull(rng: Random): T {
+        if (next >= remaining.size) {
+            remaining.shuffle(rng)
+            next = 0
+        }
+        return remaining[next++]
+    }
+}
+
+/**
+ * A loot table. The weight of each possibility is in the first of the pair; the value to be
+ * returned in the second. They need not add up to 1f (we will do that for you, free of charge).
+ */
+class RandomTable<T>(private vararg val pairs: Pair<Float, T>) {
+    private val total = pairs.map { it.first }.sum()
+
+    /** Select a random value from the weighted table. */
+    fun roll(rng: Random): T {
+        var x = rng.nextFloatInRange(0f, total)
+        for ((weight, result) in pairs) {
+            x -= weight
+            if (x < 0f) return result
+        }
+        return pairs.last().second
+    }
+}
+
+/** Return a random float in the range [from, until). */
+fun Random.nextFloatInRange(from: Float, until: Float): Float =
+    from + ((until - from) * nextFloat())
+
+/** Return a random float in the range [start, end). */
+fun Random.nextFloatInRange(fromUntil: ClosedFloatingPointRange<Float>): Float =
+    nextFloatInRange(fromUntil.start, fromUntil.endInclusive)
+/** Return a random float in the range [first, second). */
+fun Random.nextFloatInRange(fromUntil: Pair<Float, Float>): Float =
+    nextFloatInRange(fromUntil.first, fromUntil.second)
+
+/** Choose a random element from an array. */
+fun <T> Random.choose(array: Array<T>) = array[nextInt(array.size)]
diff --git a/packages/EasterEgg/src/com/android/egg/landroid/Universe.kt b/packages/EasterEgg/src/com/android/egg/landroid/Universe.kt
new file mode 100644
index 0000000..fec3ab3
--- /dev/null
+++ b/packages/EasterEgg/src/com/android/egg/landroid/Universe.kt
@@ -0,0 +1,513 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.egg.landroid
+
+import android.util.ArraySet
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.util.lerp
+import kotlin.math.absoluteValue
+import kotlin.math.pow
+import kotlin.math.sqrt
+
+const val UNIVERSE_RANGE = 200_000f
+
+val NUM_PLANETS_RANGE = 1..10
+val STAR_RADIUS_RANGE = (1_000f..8_000f)
+val PLANET_RADIUS_RANGE = (50f..2_000f)
+val PLANET_ORBIT_RANGE = (STAR_RADIUS_RANGE.endInclusive * 2f)..(UNIVERSE_RANGE * 0.75f)
+
+const val GRAVITATION = 1e-2f
+const val KEPLER_CONSTANT = 50f // * 4f * PIf * PIf / GRAVITATION
+
+// m = d * r
+const val PLANETARY_DENSITY = 2.5f
+const val STELLAR_DENSITY = 0.5f
+
+const val SPACECRAFT_MASS = 10f
+
+const val CRAFT_SPEED_LIMIT = 5_000f
+const val MAIN_ENGINE_ACCEL = 1000f // thrust effect, pixels per second squared
+const val LAUNCH_MECO = 2f // how long to suspend gravity when launching
+
+const val SCALED_THRUST = true
+
+interface Removable {
+    fun canBeRemoved(): Boolean
+}
+
+open class Planet(
+    val orbitCenter: Vec2,
+    radius: Float,
+    pos: Vec2,
+    var speed: Float,
+    var color: Color = Color.White
+) : Body() {
+    var atmosphere = ""
+    var description = ""
+    var flora = ""
+    var fauna = ""
+    var explored = false
+    private val orbitRadius: Float
+    init {
+        this.radius = radius
+        this.pos = pos
+        orbitRadius = pos.distance(orbitCenter)
+        mass = 4 / 3 * PIf * radius.pow(3) * PLANETARY_DENSITY
+    }
+
+    override fun update(sim: Simulator, dt: Float) {
+        val orbitAngle = (pos - orbitCenter).angle()
+        // constant linear velocity
+        velocity = Vec2.makeWithAngleMag(orbitAngle + PIf / 2f, speed)
+
+        super.update(sim, dt)
+    }
+
+    override fun postUpdate(sim: Simulator, dt: Float) {
+        // This is kind of like a constraint, but whatever.
+        val orbitAngle = (pos - orbitCenter).angle()
+        pos = orbitCenter + Vec2.makeWithAngleMag(orbitAngle, orbitRadius)
+        super.postUpdate(sim, dt)
+    }
+}
+
+enum class StarClass {
+    O,
+    B,
+    A,
+    F,
+    G,
+    K,
+    M
+}
+
+fun starColor(cls: StarClass) =
+    when (cls) {
+        StarClass.O -> Color(0xFF6666FF)
+        StarClass.B -> Color(0xFFCCCCFF)
+        StarClass.A -> Color(0xFFEEEEFF)
+        StarClass.F -> Color(0xFFFFFFFF)
+        StarClass.G -> Color(0xFFFFFF66)
+        StarClass.K -> Color(0xFFFFCC33)
+        StarClass.M -> Color(0xFFFF8800)
+    }
+
+class Star(val cls: StarClass, radius: Float) :
+    Planet(orbitCenter = Vec2.Zero, radius = radius, pos = Vec2.Zero, speed = 0f) {
+    init {
+        pos = Vec2.Zero
+        mass = 4 / 3 * PIf * radius.pow(3) * STELLAR_DENSITY
+        color = starColor(cls)
+        collides = false
+    }
+    var anim = 0f
+    override fun update(sim: Simulator, dt: Float) {
+        anim += dt
+    }
+}
+
+open class Universe(val namer: Namer, randomSeed: Long) : Simulator(randomSeed) {
+    var latestDiscovery: Planet? = null
+    lateinit var star: Star
+    lateinit var ship: Spacecraft
+    val planets: MutableList<Planet> = mutableListOf()
+    var follow: Body? = null
+    val ringfence = Container(UNIVERSE_RANGE)
+
+    fun initTest() {
+        val systemName = "TEST SYSTEM"
+        star =
+            Star(
+                    cls = StarClass.A,
+                    radius = STAR_RADIUS_RANGE.endInclusive,
+                )
+                .apply { name = "TEST SYSTEM" }
+
+        repeat(NUM_PLANETS_RANGE.last) {
+            val thisPlanetFrac = it.toFloat() / (NUM_PLANETS_RANGE.last - 1)
+            val radius =
+                lerp(PLANET_RADIUS_RANGE.start, PLANET_RADIUS_RANGE.endInclusive, thisPlanetFrac)
+            val orbitRadius =
+                lerp(PLANET_ORBIT_RANGE.start, PLANET_ORBIT_RANGE.endInclusive, thisPlanetFrac)
+
+            val period = sqrt(orbitRadius.pow(3f) / star.mass) * KEPLER_CONSTANT
+            val speed = 2f * PIf * orbitRadius / period
+
+            val p =
+                Planet(
+                    orbitCenter = star.pos,
+                    radius = radius,
+                    pos = star.pos + Vec2.makeWithAngleMag(thisPlanetFrac * PI2f, orbitRadius),
+                    speed = speed,
+                    color = Colors.Eigengrau4
+                )
+            android.util.Log.v(
+                "Landroid",
+                "created planet $p with period $period and vel $speed"
+            )
+            val num = it + 1
+            p.description = "TEST PLANET #$num"
+            p.atmosphere = "radius=$radius"
+            p.flora = "mass=${p.mass}"
+            p.fauna = "speed=$speed"
+            planets.add(p)
+            add(p)
+        }
+
+        planets.sortBy { it.pos.distance(star.pos) }
+        planets.forEachIndexed { idx, planet -> planet.name = "$systemName ${idx + 1}" }
+        add(star)
+
+        ship = Spacecraft()
+
+        ship.pos = star.pos + Vec2.makeWithAngleMag(PIf / 4, PLANET_ORBIT_RANGE.start)
+        ship.angle = 0f
+        add(ship)
+
+        ringfence.add(ship)
+        add(ringfence)
+
+        follow = ship
+    }
+
+    fun initRandom() {
+        val systemName = namer.nameSystem(rng)
+        star =
+            Star(
+                cls = rng.choose(StarClass.values()),
+                radius = rng.nextFloatInRange(STAR_RADIUS_RANGE)
+            )
+        star.name = systemName
+        repeat(rng.nextInt(NUM_PLANETS_RANGE.first, NUM_PLANETS_RANGE.last + 1)) {
+            val radius = rng.nextFloatInRange(PLANET_RADIUS_RANGE)
+            val orbitRadius =
+                lerp(
+                    PLANET_ORBIT_RANGE.start,
+                    PLANET_ORBIT_RANGE.endInclusive,
+                    rng.nextFloat().pow(1f)
+                )
+
+            // Kepler's third law
+            val period = sqrt(orbitRadius.pow(3f) / star.mass) * KEPLER_CONSTANT
+            val speed = 2f * PIf * orbitRadius / period
+
+            val p =
+                Planet(
+                    orbitCenter = star.pos,
+                    radius = radius,
+                    pos = star.pos + Vec2.makeWithAngleMag(rng.nextFloat() * PI2f, orbitRadius),
+                    speed = speed,
+                    color = Colors.Eigengrau4
+                )
+            android.util.Log.v(
+                "Landroid",
+                "created planet $p with period $period and vel $speed"
+            )
+            p.description = namer.describePlanet(rng)
+            p.atmosphere = namer.describeAtmo(rng)
+            p.flora = namer.describeLife(rng)
+            p.fauna = namer.describeLife(rng)
+            planets.add(p)
+            add(p)
+        }
+        planets.sortBy { it.pos.distance(star.pos) }
+        planets.forEachIndexed { idx, planet -> planet.name = "$systemName ${idx + 1}" }
+        add(star)
+
+        ship = Spacecraft()
+
+        ship.pos =
+            star.pos +
+                Vec2.makeWithAngleMag(
+                    rng.nextFloat() * PI2f,
+                    rng.nextFloatInRange(PLANET_ORBIT_RANGE.start, PLANET_ORBIT_RANGE.endInclusive)
+                )
+        ship.angle = rng.nextFloat() * PI2f
+        add(ship)
+
+        ringfence.add(ship)
+        add(ringfence)
+
+        follow = ship
+    }
+
+    override fun updateAll(dt: Float, entities: ArraySet<Entity>) {
+        // check for passing in front of the sun
+        ship.transit = false
+
+        (planets + star).forEach { planet ->
+            val vector = planet.pos - ship.pos
+            val d = vector.mag()
+            if (d < planet.radius) {
+                if (planet is Star) ship.transit = true
+            } else if (
+                now > ship.launchClock + LAUNCH_MECO
+            ) { // within MECO sec of launch, no gravity at all
+                // simulate gravity: $ f_g = G * m1 * m2 * 1/d^2 $
+                ship.velocity =
+                    ship.velocity +
+                        Vec2.makeWithAngleMag(
+                            vector.angle(),
+                            GRAVITATION * (ship.mass * planet.mass) / d.pow(2)
+                        ) * dt
+            }
+        }
+
+        super.updateAll(dt, entities)
+    }
+
+    fun closestPlanet(): Planet {
+        val bodiesByDist =
+            (planets + star)
+                .map { planet -> (planet.pos - ship.pos) to planet }
+                .sortedBy { it.first.mag() }
+
+        return bodiesByDist[0].second
+    }
+
+    override fun solveAll(dt: Float, constraints: ArraySet<Constraint>) {
+        if (ship.landing == null) {
+            val planet = closestPlanet()
+
+            if (planet.collides) {
+                val d = (ship.pos - planet.pos).mag() - ship.radius - planet.radius
+                val a = (ship.pos - planet.pos).angle()
+
+                if (d < 0) {
+                    // landing, or impact?
+
+                    // 1. relative speed
+                    val vDiff = (ship.velocity - planet.velocity).mag()
+                    // 2. landing angle
+                    val aDiff = (ship.angle - a).absoluteValue
+
+                    // landing criteria
+                    if (aDiff < PIf / 4
+                    //                        &&
+                    //                        vDiff < 100f
+                    ) {
+                        val landing = Landing(ship, planet, a)
+                        ship.landing = landing
+                        ship.velocity = planet.velocity
+                        add(landing)
+
+                        planet.explored = true
+                        latestDiscovery = planet
+                    } else {
+                        val impact = planet.pos + Vec2.makeWithAngleMag(a, planet.radius)
+                        ship.pos =
+                            planet.pos + Vec2.makeWithAngleMag(a, planet.radius + ship.radius - d)
+
+                        //                        add(Spark(
+                        //                            lifetime = 1f,
+                        //                            style = Spark.Style.DOT,
+                        //                            color = Color.Yellow,
+                        //                            size = 10f
+                        //                        ).apply {
+                        //                            pos = impact
+                        //                            opos = impact
+                        //                            velocity = Vec2.Zero
+                        //                        })
+                        //
+                        (1..10).forEach {
+                            Spark(
+                                    lifetime = rng.nextFloatInRange(0.5f, 2f),
+                                    style = Spark.Style.DOT,
+                                    color = Color.White,
+                                    size = 1f
+                                )
+                                .apply {
+                                    pos =
+                                        impact +
+                                            Vec2.makeWithAngleMag(
+                                                rng.nextFloatInRange(0f, 2 * PIf),
+                                                rng.nextFloatInRange(0.1f, 0.5f)
+                                            )
+                                    opos = pos
+                                    velocity =
+                                        ship.velocity * 0.8f +
+                                            Vec2.makeWithAngleMag(
+                                                //                                            a +
+                                                // rng.nextFloatInRange(-PIf, PIf),
+                                                rng.nextFloatInRange(0f, 2 * PIf),
+                                                rng.nextFloatInRange(0.1f, 0.5f)
+                                            )
+                                    add(this)
+                                }
+                        }
+                    }
+                }
+            }
+        }
+
+        super.solveAll(dt, constraints)
+    }
+
+    override fun postUpdateAll(dt: Float, entities: ArraySet<Entity>) {
+        super.postUpdateAll(dt, entities)
+
+        entities
+            .filterIsInstance<Removable>()
+            .filter(predicate = Removable::canBeRemoved)
+            .filterIsInstance<Entity>()
+            .forEach { remove(it) }
+    }
+}
+
+class Landing(val ship: Spacecraft, val planet: Planet, val angle: Float) : Constraint {
+    private val landingVector = Vec2.makeWithAngleMag(angle, ship.radius + planet.radius)
+    override fun solve(sim: Simulator, dt: Float) {
+        val desiredPos = planet.pos + landingVector
+        ship.pos = (ship.pos * 0.5f) + (desiredPos * 0.5f) // @@@ FIXME
+        ship.angle = angle
+    }
+}
+
+class Spark(
+    var lifetime: Float,
+    collides: Boolean = false,
+    mass: Float = 0f,
+    val style: Style = Style.LINE,
+    val color: Color = Color.Gray,
+    val size: Float = 2f
+) : Removable, Body() {
+    enum class Style {
+        LINE,
+        LINE_ABSOLUTE,
+        DOT,
+        DOT_ABSOLUTE,
+        RING
+    }
+
+    init {
+        this.collides = collides
+        this.mass = mass
+    }
+    override fun update(sim: Simulator, dt: Float) {
+        super.update(sim, dt)
+        lifetime -= dt
+    }
+    override fun canBeRemoved(): Boolean {
+        return lifetime < 0
+    }
+}
+
+const val TRACK_LENGTH = 10_000
+const val SIMPLE_TRACK_DRAWING = true
+
+class Track {
+    val positions = ArrayDeque<Vec2>(TRACK_LENGTH)
+    private val angles = ArrayDeque<Float>(TRACK_LENGTH)
+    fun add(x: Float, y: Float, a: Float) {
+        if (positions.size >= (TRACK_LENGTH - 1)) {
+            positions.removeFirst()
+            angles.removeFirst()
+            positions.removeFirst()
+            angles.removeFirst()
+        }
+        positions.addLast(Vec2(x, y))
+        angles.addLast(a)
+    }
+}
+
+class Spacecraft : Body() {
+    var thrust = Vec2.Zero
+    var launchClock = 0f
+
+    var transit = false
+
+    val track = Track()
+
+    var landing: Landing? = null
+
+    init {
+        mass = SPACECRAFT_MASS
+        radius = 12f
+    }
+
+    override fun update(sim: Simulator, dt: Float) {
+        // check for thrusters
+        val thrustMag = thrust.mag()
+        if (thrustMag > 0) {
+            var deltaV = MAIN_ENGINE_ACCEL * dt
+            if (SCALED_THRUST) deltaV *= thrustMag.coerceIn(0f, 1f)
+
+            if (landing == null) {
+                // we are free in space, so we attempt to pivot toward the desired direction
+                // NOTE: no longer required thanks to FlightStick
+                // angle = thrust.angle()
+            } else
+                landing?.let { landing ->
+                    if (launchClock == 0f) launchClock = sim.now + 1f /* @@@ TODO extract */
+
+                    if (sim.now > launchClock) {
+                        // first-stage to orbit has 1000x power
+                        //                    deltaV *= 1000f
+                        sim.remove(landing)
+                        this.landing = null
+                    } else {
+                        deltaV = 0f
+                    }
+                }
+
+            // this is it. impart thrust to the ship.
+            // note that we always thrust in the forward direction
+            velocity += Vec2.makeWithAngleMag(angle, deltaV)
+        } else {
+            if (launchClock != 0f) launchClock = 0f
+        }
+
+        // apply global speed limit
+        if (velocity.mag() > CRAFT_SPEED_LIMIT)
+            velocity = Vec2.makeWithAngleMag(velocity.angle(), CRAFT_SPEED_LIMIT)
+
+        super.update(sim, dt)
+    }
+
+    override fun postUpdate(sim: Simulator, dt: Float) {
+        super.postUpdate(sim, dt)
+
+        // special effects all need to be added after the simulation step so they have
+        // the correct position of the ship.
+        track.add(pos.x, pos.y, angle)
+
+        val mag = thrust.mag()
+        if (sim.rng.nextFloat() < mag) {
+            // exhaust
+            sim.add(
+                Spark(
+                        lifetime = sim.rng.nextFloatInRange(0.5f, 1f),
+                        collides = true,
+                        mass = 1f,
+                        style = Spark.Style.RING,
+                        size = 3f,
+                        color = Color(0x40FFFFFF)
+                    )
+                    .also { spark ->
+                        spark.pos = pos
+                        spark.opos = pos
+                        spark.velocity =
+                            velocity +
+                                Vec2.makeWithAngleMag(
+                                    angle + sim.rng.nextFloatInRange(-0.2f, 0.2f),
+                                    -MAIN_ENGINE_ACCEL * mag * 10f * dt
+                                )
+                    }
+            )
+        }
+    }
+}
diff --git a/packages/EasterEgg/src/com/android/egg/landroid/Vec2.kt b/packages/EasterEgg/src/com/android/egg/landroid/Vec2.kt
new file mode 100644
index 0000000..82bae75
--- /dev/null
+++ b/packages/EasterEgg/src/com/android/egg/landroid/Vec2.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.egg.landroid
+
+import androidx.compose.ui.geometry.Offset
+import kotlin.math.PI
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.sin
+
+const val PIf = PI.toFloat()
+const val PI2f = (2 * PI).toFloat()
+
+typealias Vec2 = Offset
+
+fun Vec2.str(fmt: String = "%+.2f"): String = "<$fmt,$fmt>".format(x, y)
+
+fun Vec2(x: Float, y: Float): Vec2 = Offset(x, y)
+
+fun Vec2.mag(): Float {
+    return getDistance()
+}
+
+fun Vec2.distance(other: Vec2): Float {
+    return (this - other).mag()
+}
+
+fun Vec2.angle(): Float {
+    return atan2(y, x)
+}
+
+fun Vec2.dot(o: Vec2): Float {
+    return x * o.x + y * o.y
+}
+
+fun Vec2.product(f: Float): Vec2 {
+    return Vec2(x * f, y * f)
+}
+
+fun Offset.Companion.makeWithAngleMag(a: Float, m: Float): Vec2 {
+    return Vec2(m * cos(a), m * sin(a))
+}
+
+fun Vec2.rotate(angle: Float, origin: Vec2 = Vec2.Zero): Offset {
+    val translated = this - origin
+    return origin +
+        Offset(
+            (translated.x * cos(angle) - translated.y * sin(angle)),
+            (translated.x * sin(angle) + translated.y * cos(angle))
+        )
+}
diff --git a/packages/EasterEgg/src/com/android/egg/landroid/VisibleUniverse.kt b/packages/EasterEgg/src/com/android/egg/landroid/VisibleUniverse.kt
new file mode 100644
index 0000000..24b9c6a
--- /dev/null
+++ b/packages/EasterEgg/src/com/android/egg/landroid/VisibleUniverse.kt
@@ -0,0 +1,334 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.egg.landroid
+
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.PathEffect
+import androidx.compose.ui.graphics.PointMode
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.drawscope.rotateRad
+import androidx.compose.ui.graphics.drawscope.scale
+import androidx.compose.ui.graphics.drawscope.translate
+import androidx.compose.ui.util.lerp
+import androidx.core.math.MathUtils.clamp
+import java.lang.Float.max
+import kotlin.math.sqrt
+
+const val DRAW_ORBITS = true
+const val DRAW_GRAVITATIONAL_FIELDS = true
+const val DRAW_STAR_GRAVITATIONAL_FIELDS = true
+
+val STAR_POINTS = android.os.Build.VERSION.SDK_INT.takeIf { it in 1..99 } ?: 31
+
+/**
+ * A zoomedDrawScope is one that is scaled, but remembers its zoom level, so you can correct for it
+ * if you want to draw single-pixel lines. Which we do.
+ */
+interface ZoomedDrawScope : DrawScope {
+    val zoom: Float
+}
+
+fun DrawScope.zoom(zoom: Float, block: ZoomedDrawScope.() -> Unit) {
+    val ds =
+        object : ZoomedDrawScope, DrawScope by this {
+            override var zoom = zoom
+        }
+    ds.scale(zoom) { block(ds) }
+}
+
+class VisibleUniverse(namer: Namer, randomSeed: Long) : Universe(namer, randomSeed) {
+    // Magic variable. Every time we update it, Compose will notice and redraw the universe.
+    val triggerDraw = mutableStateOf(0L)
+
+    fun simulateAndDrawFrame(nanos: Long) {
+        // By writing this value, Compose will look for functions that read it (like drawZoomed).
+        triggerDraw.value = nanos
+
+        step(nanos)
+    }
+}
+
+fun ZoomedDrawScope.drawUniverse(universe: VisibleUniverse) {
+    with(universe) {
+        triggerDraw.value // Please recompose when this value changes.
+
+        //        star.drawZoomed(ds, zoom)
+        //        planets.forEach { p ->
+        //            p.drawZoomed(ds, zoom)
+        //            if (p == follow) {
+        //                drawCircle(Color.Red, 20f / zoom, p.pos)
+        //            }
+        //        }
+        //
+        //        ship.drawZoomed(ds, zoom)
+
+        constraints.forEach {
+            when (it) {
+                is Landing -> drawLanding(it)
+                is Container -> drawContainer(it)
+            }
+        }
+        drawStar(star)
+        entities.forEach {
+            if (it === ship || it === star) return@forEach // draw the ship last
+            when (it) {
+                is Spacecraft -> drawSpacecraft(it)
+                is Spark -> drawSpark(it)
+                is Planet -> drawPlanet(it)
+            }
+        }
+        drawSpacecraft(ship)
+    }
+}
+
+fun ZoomedDrawScope.drawContainer(container: Container) {
+    drawCircle(
+        color = Color(0xFF800000),
+        radius = container.radius,
+        center = Vec2.Zero,
+        style =
+            Stroke(
+                width = 1f / zoom,
+                pathEffect = PathEffect.dashPathEffect(floatArrayOf(8f / zoom, 8f / zoom), 0f)
+            )
+    )
+    //    val path = Path().apply {
+    //        fillType = PathFillType.EvenOdd
+    //        addOval(Rect(center = Vec2.Zero, radius = container.radius))
+    //        addOval(Rect(center = Vec2.Zero, radius = container.radius + 10_000))
+    //    }
+    //    drawPath(
+    //        path = path,
+    //
+    //    )
+}
+
+fun ZoomedDrawScope.drawGravitationalField(planet: Planet) {
+    val rings = 8
+    for (i in 0 until rings) {
+        val force =
+            lerp(
+                200f,
+                0.01f,
+                i.toFloat() / rings
+            ) // first rings at force = 1N, dropping off after that
+        val r = sqrt(GRAVITATION * planet.mass * SPACECRAFT_MASS / force)
+        drawCircle(
+            color = Color(1f, 0f, 0f, lerp(0.5f, 0.1f, i.toFloat() / rings)),
+            center = planet.pos,
+            style = Stroke(2f / zoom),
+            radius = r
+        )
+    }
+}
+
+fun ZoomedDrawScope.drawPlanet(planet: Planet) {
+    with(planet) {
+        if (DRAW_ORBITS)
+            drawCircle(
+                color = Color(0x8000FFFF),
+                radius = pos.distance(orbitCenter),
+                center = orbitCenter,
+                style =
+                    Stroke(
+                        width = 1f / zoom,
+                    )
+            )
+
+        if (DRAW_GRAVITATIONAL_FIELDS) {
+            drawGravitationalField(this)
+        }
+
+        drawCircle(color = Colors.Eigengrau, radius = radius, center = pos)
+        drawCircle(color = color, radius = radius, center = pos, style = Stroke(2f / zoom))
+    }
+}
+
+fun ZoomedDrawScope.drawStar(star: Star) {
+    translate(star.pos.x, star.pos.y) {
+        drawCircle(color = star.color, radius = star.radius, center = Vec2.Zero)
+
+        if (DRAW_STAR_GRAVITATIONAL_FIELDS) this@drawStar.drawGravitationalField(star)
+
+        rotateRad(radians = star.anim / 23f * PI2f, pivot = Vec2.Zero) {
+            drawPath(
+                path =
+                    createStar(
+                        radius1 = star.radius + 80,
+                        radius2 = star.radius + 250,
+                        points = STAR_POINTS
+                    ),
+                color = star.color,
+                style =
+                    Stroke(
+                        width = 3f / this@drawStar.zoom,
+                        pathEffect = PathEffect.cornerPathEffect(radius = 200f)
+                    )
+            )
+        }
+        rotateRad(radians = star.anim / -19f * PI2f, pivot = Vec2.Zero) {
+            drawPath(
+                path =
+                    createStar(
+                        radius1 = star.radius + 20,
+                        radius2 = star.radius + 200,
+                        points = STAR_POINTS + 1
+                    ),
+                color = star.color,
+                style =
+                    Stroke(
+                        width = 3f / this@drawStar.zoom,
+                        pathEffect = PathEffect.cornerPathEffect(radius = 200f)
+                    )
+            )
+        }
+    }
+}
+
+val spaceshipPath =
+    Path().apply {
+        parseSvgPathData(
+            """
+M11.853 0
+C11.853 -4.418 8.374 -8 4.083 -8
+L-5.5 -8
+C-6.328 -8 -7 -7.328 -7 -6.5
+C-7 -5.672 -6.328 -5 -5.5 -5
+L-2.917 -5
+C-1.26 -5 0.083 -3.657 0.083 -2
+L0.083 2
+C0.083 3.657 -1.26 5 -2.917 5
+L-5.5 5
+C-6.328 5 -7 5.672 -7 6.5
+C-7 7.328 -6.328 8 -5.5 8
+L4.083 8
+C8.374 8 11.853 4.418 11.853 0
+Z
+"""
+        )
+    }
+val thrustPath = createPolygon(-3f, 3).also { it.translate(Vec2(-4f, 0f)) }
+
+fun ZoomedDrawScope.drawSpacecraft(ship: Spacecraft) {
+    with(ship) {
+        rotateRad(angle, pivot = pos) {
+            translate(pos.x, pos.y) {
+                //                drawPath(
+                //                    path = createStar(200f, 100f, 3),
+                //                    color = Color.White,
+                //                    style = Stroke(width = 2f / zoom)
+                //                )
+                drawPath(path = spaceshipPath, color = Colors.Eigengrau) // fauxpaque
+                drawPath(
+                    path = spaceshipPath,
+                    color = if (transit) Color.Black else Color.White,
+                    style = Stroke(width = 2f / this@drawSpacecraft.zoom)
+                )
+                if (thrust != Vec2.Zero) {
+                    drawPath(
+                        path = thrustPath,
+                        color = Color(0xFFFF8800),
+                        style =
+                            Stroke(
+                                width = 2f / this@drawSpacecraft.zoom,
+                                pathEffect = PathEffect.cornerPathEffect(radius = 1f)
+                            )
+                    )
+                }
+                //                drawRect(
+                //                    topLeft = Offset(-1f, -1f),
+                //                    size = Size(2f, 2f),
+                //                    color = Color.Cyan,
+                //                    style = Stroke(width = 2f / zoom)
+                //                )
+                //                drawLine(
+                //                    start = Vec2.Zero,
+                //                    end = Vec2(20f, 0f),
+                //                    color = Color.Cyan,
+                //                    strokeWidth = 2f / zoom
+                //                )
+            }
+        }
+        //        // DEBUG: draw velocity vector
+        //        drawLine(
+        //            start = pos,
+        //            end = pos + velocity,
+        //            color = Color.Red,
+        //            strokeWidth = 3f / zoom
+        //        )
+        drawTrack(track)
+    }
+}
+
+fun ZoomedDrawScope.drawLanding(landing: Landing) {
+    val v = landing.planet.pos + Vec2.makeWithAngleMag(landing.angle, landing.planet.radius)
+    drawLine(Color.Red, v + Vec2(-5f, -5f), v + Vec2(5f, 5f), strokeWidth = 1f / zoom)
+    drawLine(Color.Red, v + Vec2(5f, -5f), v + Vec2(-5f, 5f), strokeWidth = 1f / zoom)
+}
+
+fun ZoomedDrawScope.drawSpark(spark: Spark) {
+    with(spark) {
+        if (lifetime < 0) return
+        when (style) {
+            Spark.Style.LINE ->
+                if (opos != Vec2.Zero) drawLine(color, opos, pos, strokeWidth = size)
+            Spark.Style.LINE_ABSOLUTE ->
+                if (opos != Vec2.Zero) drawLine(color, opos, pos, strokeWidth = size / zoom)
+            Spark.Style.DOT -> drawCircle(color, size, pos)
+            Spark.Style.DOT_ABSOLUTE -> drawCircle(color, size, pos / zoom)
+            Spark.Style.RING -> drawCircle(color, size, pos, style = Stroke(width = 1f / zoom))
+        //                drawPoints(listOf(pos), PointMode.Points, color, strokeWidth = 2f/zoom)
+        //            drawCircle(color, 2f/zoom, pos)
+        }
+        //        drawCircle(Color.Gray, center = pos, radius = 1.5f / zoom)
+    }
+}
+
+fun ZoomedDrawScope.drawTrack(track: Track) {
+    with(track) {
+        if (SIMPLE_TRACK_DRAWING) {
+            drawPoints(
+                positions,
+                pointMode = PointMode.Lines,
+                color = Color.Green,
+                strokeWidth = 1f / zoom
+            )
+            //            if (positions.size < 2) return
+            //            drawPath(Path()
+            //                .apply {
+            //                    val p = positions[positions.size - 1]
+            //                    moveTo(p.x, p.y)
+            //                    positions.reversed().subList(1, positions.size).forEach { p ->
+            //                        lineTo(p.x, p.y)
+            //                    }
+            //                },
+            //                color = Color.Green, style = Stroke(1f/zoom))
+        } else {
+            if (positions.size < 2) return
+            var prev: Vec2 = positions[positions.size - 1]
+            var a = 0.5f
+            positions.reversed().subList(1, positions.size).forEach { pos ->
+                drawLine(Color(0f, 1f, 0f, a), prev, pos, strokeWidth = max(1f, 1f / zoom))
+                prev = pos
+                a = clamp((a - 1f / TRACK_LENGTH), 0f, 1f)
+            }
+        }
+    }
+}