Preview system
How the “Test in Play” button compiles a .sui into a real PanelComponent, opens a stage scene, and mounts the UI on a player at runtime.
Table of contents
- Goal
- Sequence
- The three pieces
- Why the polling step?
- Why open a scene? Can’t we just mount on the current one?
- Preview cache vs final compile
- Limitations
- Diagnostics
- See also
Goal
Let the user see their UI rendered by the real s&box CSS engine on a real player, without leaving the SUI Designer workflow. One click → playing.
The canvas preview is fast and round-trippable but it’s a separate render path (Canvas renderer). The preview system uses the actual engine — Yoga layout, runtime SCSS compilation, real ScreenPanel — so you see exactly what ships.
Sequence
User clicks "Test in Play"
│
▼
SuiPreviewLauncher.Launch( document )
│
├──▶ SuiPreviewCacheWriter.Write( document )
│ ├── Generate via SuiGenerationPipeline (Mode.Preview)
│ ├── Write Code/_sui_preview/<ClassName>/<ClassName>.razor
│ └── Write Code/_sui_preview/<ClassName>/<ClassName>.razor.scss
│
│ → engine hot-reloads → PanelComponent type registers in TypeLibrary
│
├──▶ Poll TypeLibrary.GetType( fqn ) — up to 6 seconds
│
├──▶ SuiPreviewState.Set( fqn )
│
├──▶ AssetSystem.FindByPath( "sui_preview/preview_stage.scene" )
│ .OpenInEditor()
│
└──▶ EditorScene.Play( session )
│
▼
Scene loads. SuiPreviewMount.OnAwake() fires:
├── Reads SuiPreviewState.PendingTypeFullName
├── Looks up TypeDescription
├── Creates child "ScreenPanelHost"
├── Adds ScreenPanel component
├── Components.Create(typeDesc) — instantiates user's UI
└── Clears SuiPreviewState
5 steps. The launcher does steps 1–5; the in-scene SuiPreviewMount does the actual mount in step 5.
The three pieces
1. Preview cache writer (SuiPreviewCacheWriter)
Same generator output as the final compile, written to a different folder:
Code/_sui_preview/<ClassName>/
├── <ClassName>.razor (Game.UI.SuiPreview.<ClassName>)
└── <ClassName>.razor.scss
Why under Code/? Because the s&box Razor compiler only picks up .razor files inside Code/. Outside that path the file gets ignored.
Why .SuiPreview sub-namespace? Without it, the preview file would declare partial class MyHud in Game.UI — the same as the final compile output. The engine raises CS0111: duplicate render-tree members. Adding .SuiPreview makes them distinct types (Game.UI.SuiPreview.MyHud vs Game.UI.MyHud) — coexistence works.
Why no .User.scss? Because the sidecar file doesn’t exist at this path. The generator skips the @import in Preview mode for exactly this reason — see Generator pipeline.
2. State handoff (SuiPreviewState)
A static class with a single string:
public static class SuiPreviewState
{
public static string PendingTypeFullName { get; private set; }
public static void Set( string fqn ) => PendingTypeFullName = fqn;
public static void Clear() => PendingTypeFullName = null;
}
The launcher sets it before invoking EditorScene.Play. The in-scene SuiPreviewMount reads it in OnAwake and clears it. The launcher and mount run in different “process modes” (editor vs play) — sharing state via a static is the simplest cross-mode handoff.
3. In-scene mount (SuiPreviewMount)
Lives on a GameObject in preview_stage.scene. The scene is bundled with the addon:
Libraries/kikozl.sbox_ui_designer/Assets/sui_preview/preview_stage.scene
Contains:
- A ground plane
- A skybox / ambient light
- A player prefab (TPS character so you can move around)
- A GameObject with
SuiPreviewMountattached
On OnAwake:
var fqn = SuiPreviewState.PendingTypeFullName;
if ( fqn == null ) return;
var typeDesc = TypeLibrary.GetType( fqn );
if ( typeDesc == null ) return;
_panelHost = new GameObject( true, "ScreenPanelHost" );
_panelHost.SetParent( GameObject );
_panelHost.GetOrAddComponent<ScreenPanel>();
_panelHost.Components.Create( typeDesc );
SuiPreviewState.Clear();
ScreenPanel is the screen-space root needed by any PanelComponent to render as a HUD overlay. Without it the panel is in the scene but has no draw surface.
Why the polling step?
After writing the .razor file, the engine needs to:
- Notice the file system change.
- Compile it via the Razor compiler.
- Register the new
PanelComponenttype inTypeLibrary.
This is asynchronous. Typical latency: 200–800 ms. The launcher polls TypeLibrary.GetType(fqn) every 100 ms up to 60 attempts (= 6 seconds).
If the type doesn’t appear in 6 s, the launcher gives up with a warning. The common cause is a SCSS or Razor compile error — visible in the engine console. Common culprits:
display: gridin.User.scss(engine doesn’t support it).rgba()typo in a color value.- Hand-edited
.razorwith C# syntax errors.
Why open a scene? Can’t we just mount on the current one?
Two reasons:
- Determinism — your current scene might be empty, full of dev objects, or have its own conflicting HUDs. A bundled stage gives a clean baseline.
- API constraint —
EditorScene.Playrequires a scene to play. Mounting on an in-flight scene means hijacking whatever was open.
The trade-off: after you stop play, you remain on preview_stage.scene. You re-open your real scene manually from the Asset Browser. There’s no “restore previous scene” API in s&box that the addon can use safely.
Preview cache vs final compile
| Preview cache | Final compile | |
|---|---|---|
| Folder | Code/_sui_preview/<ClassName>/ | User-picked (typically Code/UI/) |
| Namespace | Game.UI.SuiPreview.<ClassName> | Game.UI.<ClassName> (or doc-configured) |
@import "<Name>.User.scss" | No (sidecar doesn’t exist here) | Yes |
.User.scss sidecar | Not written | Written once |
| SUI:GENERATED header | Yes (same as final) | Yes |
| Manifest entry | No (preview doesn’t track manifest) | Yes |
| Backup on overwrite | No (transient cache) | Yes |
| Engine hot-reload | Yes | Yes |
Preview cache is a transient working area — the user never opens those files directly. Cleaning preview caches is part of Tools → Clean SUI Caches.
Limitations
- No
.User.scssstyles — sidecar isn’t imported in Preview mode. If you have hovers, animations, or shadows in.User.scss, they won’t appear in Test in Play. Use real Compile + Play to see them. - One Play at a time — s&box doesn’t nest play sessions.
- No scene auto-restore — after Stop, you’re on
preview_stage.scene. - No window minimize — the SUI Designer window stays where it was. To see the Scene Viewport that’s playing, switch tabs.
- Stack count badges missing —
PreviewCounton InventorySlot/ItemIcon shows in canvas but not in runtime yet. Tracked as ISSUE-005. <label>rgba alpha quirk —<label>elements may renderbackground-colorrgba as opaque. Workaround: wrap text in a Panel. Tracked as ISSUE-004.
Diagnostics
The launcher and mount log to the engine console:
| Message | Meaning |
|---|---|
[SuiPreviewLauncher] Compiled '<fqn>' | Step 1 OK |
[SuiPreviewLauncher] Type loaded: <fqn> | Step 2 OK |
[SuiPreviewLauncher] Opened preview stage scene. | Step 4 OK |
[SuiPreviewLauncher] EditorScene.Play() invoked. | Step 5 OK |
[SuiPreviewMount] Mounted '<fqn>' on ScreenPanel. | Final mount OK — UI should appear |
Timed out waiting for type ... to compile | SCSS/Razor compile error — check console |
Preview stage not found at ... | Addon Assets folder broken — reinstall |
Already in Play. | Stop the current play session first |
[SuiPreviewMount] No PendingTypeFullName set | Scene was opened without the launcher — re-click Test in Play |
See also
- Test in Play workflow — user-facing version
- Generator pipeline — Preview vs Final mode differences
- Known issues — ISSUE-004 / ISSUE-005