diff --git a/.plans/mockups/stars-demo-needed-features-2026-05-26/01-spawn-scatter-stars.svg b/.plans/mockups/stars-demo-needed-features-2026-05-26/01-spawn-scatter-stars.svg
new file mode 100644
index 0000000..f485278
--- /dev/null
+++ b/.plans/mockups/stars-demo-needed-features-2026-05-26/01-spawn-scatter-stars.svg
@@ -0,0 +1,100 @@
+
diff --git a/.plans/mockups/stars-demo-needed-features-2026-05-26/02-randomize-color-and-position.svg b/.plans/mockups/stars-demo-needed-features-2026-05-26/02-randomize-color-and-position.svg
new file mode 100644
index 0000000..95d88a0
--- /dev/null
+++ b/.plans/mockups/stars-demo-needed-features-2026-05-26/02-randomize-color-and-position.svg
@@ -0,0 +1,109 @@
+
diff --git a/.plans/mockups/stars-demo-needed-features-2026-05-26/03-moveuntil-wrap-callback-reroll-x.svg b/.plans/mockups/stars-demo-needed-features-2026-05-26/03-moveuntil-wrap-callback-reroll-x.svg
new file mode 100644
index 0000000..f8c4520
--- /dev/null
+++ b/.plans/mockups/stars-demo-needed-features-2026-05-26/03-moveuntil-wrap-callback-reroll-x.svg
@@ -0,0 +1,80 @@
+
diff --git a/.plans/pattern_demo_workflow.md b/.plans/pattern_demo_workflow.md
index 418fbc8..fc7cf48 100644
--- a/.plans/pattern_demo_workflow.md
+++ b/.plans/pattern_demo_workflow.md
@@ -1,13 +1,5 @@
# Studio workflow: recreate `pattern_demo.py` and Save YAML (updated for new QoL features)
-This version assumes the features in `.plans/implementation-plan-qol-text-bounds-duplicate-loops-layout-2026-05-17.md` exist:
-
-- Text entities (labels)
-- Bounds Helper calculator
-- Duplicate carries behaviors + `Duplicate…` options dialog
-- Loop Templates (“Loops” tab in Add Step)
-- Layout panel (Align/Distribute/Spacing) accessible from the on-canvas selection bar
-
## 1. Start a new project/scene
- Set Startup to New Empty Scene (or otherwise reset to an empty scene).
@@ -16,8 +8,10 @@ This version assumes the features in `.plans/implementation-plan-qol-text-bounds
## 2. Import the ship sprite once, then duplicate it
- Use **A20 — Import Asset into Project** to import a ship image (or use any existing sprite asset you already have).
+ NOTE: In the res/images directory, there is a ship_sidesA.png that I use.
- Use **A21 — Drag Asset to Target**: drag it onto the canvas to create your first ship entity.
-- Duplicate until you have 7 ship entities:
+ NOTE: You may want to use the Scale X and Scale Y feature in the Inspector to reduce the sprite size to 0.5 if it's too large.
+- Duplicate the sprite until you have 7 separate sprite entities:
- Fast path: **Alt+Drag** to duplicate and place.
- Alternative: Entity List `⋯` → **Duplicate** (or **Duplicate…** if you need to change options).
- Name the 7 ships (via **A4 — Rename Item (inline)** in Entity List):
@@ -113,8 +107,8 @@ Several patterns below say “Add **Repeat** with X children”. Use the Loop Te
- In **Bounds → Edit mode**, switch to **Center/Span**.
- Use **Auto from selection** to fill center (and pull sprite size).
- Set travel span:
- - `± X Span = 60`
- - `± Y Span = 40`
+ - `± X Span = 50`
+ - `± Y Span = 60`
- Click **Apply** (writes the computed values into Bounds Min/Max).
### Patrol
diff --git a/.plans/stars_demo_workflow.md b/.plans/stars_demo_workflow.md
new file mode 100644
index 0000000..125d460
--- /dev/null
+++ b/.plans/stars_demo_workflow.md
@@ -0,0 +1,107 @@
+# Studio workflow: attempt to recreate `stars.py` (ArcadeActions starfield) — current blockers
+
+Reference: `/home/bcorfman/dev/arcadeactions/examples/stars.py`
+
+This document is written in the same “do-this-in-Studio” style as `.plans/pattern_demo_workflow.md`, but `stars.py` cannot currently be duplicated faithfully in Studio without missing/extra product capabilities (listed in **Why this can’t be duplicated (yet)**).
+
+## Target behavior (what `stars.py` does)
+
+- Scene size: `W=720`, `H=1280` with a solid black background.
+- ~`400` tiny square “star” sprites:
+ - Spawned at random `x∈[0..W]`, `y∈[0..H]` (with a small vertical margin).
+ - Each star has a random bright-ish color.
+- 5 blinking groups with different blink rates (~0.2s → ~0.4s).
+- A repeating velocity “phase loop” affecting *all* stars’ vertical speed:
+ - 1s stopped → 2s accelerate down (ease-in) → 5s hold down speed
+ - 0.5s accelerate up (ease-out) → 1.5s hold up speed
+ - 2s ease back to stopped → repeat forever
+- When a star wraps vertically, it re-enters on the opposite side *with a new random X* (to avoid vertical columns).
+
+## What you can build in Studio today (approximation)
+
+If you are willing to accept visible differences vs `stars.py`, you can approximate a “starfield” as follows.
+
+### 1. Start a new project/scene
+
+- Set Startup to New Empty Scene (or otherwise reset to an empty scene).
+- Set Scene World Size to `W=720`, `H=1280`.
+- Set scene background color to black.
+
+### 2. Create/import a star sprite asset
+
+Studio currently can’t create `SpriteSolidColor` programmatically the way Arcade does, so you’ll need an asset.
+
+- Import a tiny square image (e.g. `3x3` or `4x4`) as a sprite asset.
+ - Use white by default (you won’t be able to randomize per-star tint yet).
+
+### 3. Create “star” entities (manual placement only)
+
+- Drag the star sprite onto the canvas to create a `Star` entity.
+- Duplicate it many times (Alt+Drag / Duplicate in Entity List).
+- Roughly distribute the stars across the full world.
+
+Optional organization (recommended if you do this manually):
+
+- Put stars into 5 groups (e.g. `StarsBlink1` … `StarsBlink5`) so you can apply different blink rates.
+
+### 4. Add blinking (per group)
+
+For each blink group:
+
+- Select the group and attach **Blink Until**.
+- Set **Seconds Until Change** to values spanning ~`0.2` to `0.4` seconds (example: `0.20`, `0.25`, `0.30`, `0.35`, `0.40`).
+- Leave “Stop After” disabled (infinite blinking).
+
+### 5. Add movement with wrapping (per group)
+
+For each star group:
+
+- Attach **Move Until**.
+- Set `Velocity X = 0`.
+- Set `Velocity Y` to a constant speed (pick either downward or upward).
+- Enable **Bounds** and set:
+ - Behavior: **Wrap**
+ - Bounds to the full world (optionally add a small ±Y margin).
+
+This yields a continuous scrolling starfield with wrap, but:
+
+- Stars will wrap keeping their original X (creating “columns” over time).
+- Speed won’t follow the phase/tween schedule from `stars.py`.
+
+### 6. (Now possible) Add a smooth “velocity phase loop” using Tween Until (optional)
+
+Studio now has **Tween Until**, which can animate a numeric property (including `vy`) with easing.
+
+To make the starfield speed ramp up/down smoothly, you can run two attachments in parallel on the same target:
+
+- Attachment A (runs forever): **Move Until** with **Bounds → Wrap** enabled
+ - Set `Velocity X = 0`, `Velocity Y = 0`
+ - Bounds Behavior: **Wrap**
+ - This attachment is responsible for translating entities every update tick (it reads `vx/vy` each frame).
+- Attachment B (loops forever): **Repeat** wrapping a sequence of **Tween Until** + **Wait** steps that updates `vy`
+ - Use **Tween Until** with:
+ - Property: `vy`
+ - From: `Current value`
+ - Duration: set per phase
+ - Easing: `easeIn` / `easeOut` / `easeInOut` / `linear`
+
+Note: you still won’t match `stars.py` perfectly until you can respawn with a new random X on wrap, but the easing/phase feel can now be reproduced without scripting.
+
+## Why this can’t be duplicated (yet)
+
+`stars.py` still relies on capabilities that Studio does not currently expose as authorable workflow steps:
+
+1. **Per-instance randomization (color + wrap respawn X)**
+ - `stars.py` assigns each star a random RGB color at creation time.
+ - On vertical wrap, it repositions the star to the opposite edge *and picks a new random X*.
+ - Studio’s **Bounds → Wrap** behavior wraps deterministically (no “on wrap” hook/callback to randomize X).
+
+Because of (1), even a manual “400 stars” setup will still diverge visually from `stars.py` (especially the lack of random X re-roll on wrap, and lack of per-star random tint).
+
+## Minimal product additions that would make a faithful workflow possible
+
+If/when these exist, a Studio workflow for `stars.py` becomes straightforward:
+
+- A “Scatter / Randomize Placement” authoring tool (randomize selected entities inside bounds, with seed + distribution options).
+- A per-entity RNG helper usable in steps (random float/int, random color, etc.).
+- A “Callback / On Bounds Wrap” hook (or “Move Until: wrap callback”) to re-seed X on vertical wrap.
diff --git a/public/editor-registry.yaml b/public/editor-registry.yaml
index 82ec398..6572022 100644
--- a/public/editor-registry.yaml
+++ b/public/editor-registry.yaml
@@ -146,6 +146,27 @@ actions:
parameters:
- { name: dx, type: number, default: 0 }
- { name: dy, type: number, default: 0 }
+ - type: TweenUntil
+ displayName: Tween Until
+ category: transforms
+ targetKinds: [entity, group]
+ implemented: true
+ propertyTargets:
+ - { key: x, type: number, tweenable: true, affectsBounds: true }
+ - { key: y, type: number, tweenable: true, affectsBounds: true }
+ - { key: rotationDeg, type: number, tweenable: true, affectsBounds: true }
+ - { key: scaleX, type: number, tweenable: true, affectsBounds: true }
+ - { key: scaleY, type: number, tweenable: true, affectsBounds: true }
+ - { key: alpha, type: number, tweenable: true, affectsBounds: false }
+ - { key: vx, type: number, tweenable: true, affectsBounds: false }
+ - { key: vy, type: number, tweenable: true, affectsBounds: false }
+ parameters:
+ - { name: property, type: string, default: x }
+ - { name: from, type: string, default: current }
+ - { name: startValue, type: number, default: 0 }
+ - { name: endValue, type: number, default: 0 }
+ - { name: durationMs, type: number, default: 250 }
+ - { name: easing, type: string, default: linear }
- type: MoveXUntil
displayName: Move X Until
category: movement
diff --git a/src/compiler/compileAttachments.ts b/src/compiler/compileAttachments.ts
index 2e4f399..5eef2a9 100644
--- a/src/compiler/compileAttachments.ts
+++ b/src/compiler/compileAttachments.ts
@@ -16,6 +16,7 @@ import { MoveYUntil } from '../runtime/actions/MoveYUntil';
import { BlinkUntil } from '../runtime/actions/BlinkUntil';
import { CallbackUntil } from '../runtime/actions/CallbackUntil';
import { CycleFramesUntil } from '../runtime/actions/CycleFramesUntil';
+import { TweenUntil } from '../runtime/actions/TweenUntil';
import { AddSelfToCollection } from '../runtime/actions/AddSelfToCollection';
import { AddToCounter } from '../runtime/actions/AddToCounter';
import { ClampCounter } from '../runtime/actions/ClampCounter';
@@ -238,6 +239,29 @@ function compileAtomicAttachment(attachment: AttachmentSpec, ctx: CompileContext
const target = resolveTarget(targetRef, ctx.targets);
return new MoveBy(target, { dx: Number.isFinite(dx) ? dx : 0, dy: Number.isFinite(dy) ? dy : 0 });
}
+ if (presetId === 'TweenUntil') {
+ const property = String(attachment.params?.property ?? 'x');
+ const fromRaw = String(attachment.params?.from ?? 'current');
+ const from = fromRaw === 'value' ? 'value' : 'current';
+ const startValue = typeof attachment.params?.startValue === 'number' ? attachment.params.startValue : Number(attachment.params?.startValue);
+ const endValue = Number(attachment.params?.endValue ?? 0);
+ const durationMs = Number(attachment.params?.durationMs ?? 250);
+ const easingRaw = String(attachment.params?.easing ?? 'linear');
+ const easing = easingRaw === 'easeIn' || easingRaw === 'easeOut' || easingRaw === 'easeInOut' ? easingRaw : 'linear';
+
+ const targetRef = targetOverride ?? attachment.target;
+ const target = resolveTarget(targetRef, ctx.targets);
+ const condition = instantiateInlineCondition(attachment.condition, ctx);
+ return new TweenUntil(target, {
+ property,
+ from,
+ ...(Number.isFinite(startValue) ? { startValue } : {}),
+ endValue: Number.isFinite(endValue) ? endValue : 0,
+ durationMs: Number.isFinite(durationMs) ? durationMs : 0,
+ easing: easing as any,
+ condition,
+ });
+ }
if (presetId === 'WavePattern') {
const amplitude = Number(attachment.params?.amplitude ?? 30);
const length = Number(attachment.params?.length ?? 80);
diff --git a/src/editor/EventsPanel.tsx b/src/editor/EventsPanel.tsx
index 1ee52ba..d03ab33 100644
--- a/src/editor/EventsPanel.tsx
+++ b/src/editor/EventsPanel.tsx
@@ -10,6 +10,7 @@ const SUPPORTED_PRESETS = new Set([
'MoveUntil',
'MoveTo',
'MoveBy',
+ 'TweenUntil',
'MoveXUntil',
'MoveYUntil',
'WavePattern',
diff --git a/src/editor/Inspector.tsx b/src/editor/Inspector.tsx
index 6353473..6fe2243 100644
--- a/src/editor/Inspector.tsx
+++ b/src/editor/Inspector.tsx
@@ -2709,6 +2709,96 @@ function AttachmentInspector({
)}
+ {attachment.presetId === 'TweenUntil' && (
+ foldouts.toggle('attachment.tweenuntil', true)}
+ >
+
+
+
+
+ {String(params.from ?? 'current') === 'value' && (
+
+ )}
+
+