goo for AI

goo is a C#-only retained UI framework over Sandbox.UI.Panel for s&box. Build() returns a tree of blob value-structs; goo diffs it against the previous tree and applies minimal ops to the engine panels. drop the React/Razor priors: there is no markup, no stylesheet, no every-frame render loop. the tree is plain C#, rebuilt only when asked.

the surface map

reach for these before hand-rolling anything:

need use
layout, style, events Container plus blobs: Text, Image, Sector, Arc, Polygon, ScenePanel, SvgPanel, WebPanel, TextEntry, Embed, Virtual
full-screen HUD scaffolding Hud.Overlay(), Hud.Anchored(anchor, content), Hud.Fill(), Hud.Scrim(color), Hud.Wallpaper(tex, path), Hud.Spacer(), Hud.Divider()
corner pinning math Layout.Anchor (4 corners, top/bottom center, center) via Hud.Anchored
rings, discs, wedges Goo.Shapes (Ring, Disc) and the raw shape blobs
ready-made controls Goo.Controls.Button(...), Goo.Controls.Slider(...)
encapsulated stateful widgets Cell.Mount<TCell>(key, seed, configure) - see composition
dampers and tweens Goo.Animation: Spring/Smooth/Decay x Float/Color/Vector2, Tween, Animator, Timeline, Easing
custom shaders on a panel Effect = new ShaderEffect("shaders/x.shader", GrabMode.None/Sharp/Blurred) { ["Uniform"] = value }
tag-addressed effects Tag = "frost" on a blob + override EffectMap Effects => new() { ["frost"] = effect } on the host; inline Effect wins
screen-space particles a ParticleField field + Hud.Particles(field) overlay; spawn via field.Burst(...)/Emit(...) - self-driving, no Tick needed - see particles
virtualized long lists/grids Virtual.List(items, itemHeight, row) / Virtual.Grid(items, itemSize, row) - only visible rows exist as panels; keyed stateful rows - see virtual lists
keyboard Goo.Input.KeyTracker (Poll() per frame, JustPressed, modifiers) - see input
drag and drop DragSource / DropZone / DragLayer - see drag-and-drop
drag math under UI scale PointerDrag with Current(Panel)
theming values Tokens.Scope / Tokens.Get - see tokens

hard rules

  1. C# only. no Razor, no SCSS, no markup, no DSL.

  2. only the twelve blob types exist. do not invent or subclass blob types. helpers like Shapes.Ring return Container subtrees. Embed is the escape hatch: it mounts an arbitrary engine Panel (Create runs once at mount, Update runs every owner rebuild) and goo never diffs inside it.

  3. init-only property syntax. no fluent chains; .Padding(8) does not exist. properties go inside { } on construction. to extend an already-built blob use a with expression (this is your style-spread); with shallow-copies, so the copy shares the original's Children list - call helpers fresh per use.

  4. children go in Children = { ... }. mixing init properties with bare child items in one brace block is a CS0747 compile error. after construction Children is a live list: Add and AddRange(items, (i, item) => ...) (auto-keys by index; your Key wins). two elements work inline in the initializer itself: a nullable child skips silently (Children = { header, cond ? badge : null }, also the idiom for a conditional child) and Each.Of(items, (i, item) => ...) runs a loop in place with the same auto-keying as AddRange (pass a keyOf argument for lists that reorder).

  5. mount by subclassing GooPanel<TRoot> on a GameObject under a ScreenPanel or WorldPanel. no manual instantiation, no Render().

  6. Build() does not run every frame. it runs at mount, hotload, re-enable, and on rebuild. event handlers trigger a rebuild automatically after they run, so OnClick = e => _count++ is complete - no Rebuild() call needed in handlers. state that changes outside a handler (timers, network, polls) either calls Rebuild() manually or, better, lives in a State<T> created with Track(initial) on the panel or cell: writing state.Value = x marks the owner dirty automatically (equal writes are no-ops). for continuous motion override Tick(float dt) and return true while moving.

  7. state lives in fields on the GooPanel (or Cell) subclass. no hooks, no observables, no binding. for a reusable widget with private state, subclass Cell<TRoot> and mount with Cell.Mount<TCell>(seed: c => c.Initial = x, configure: c => c.Label = y) - seed runs once at first mount, configure runs every rebuild and wins on overlap.

  8. state variants are free. HoverBackgroundColor, ActiveFontColor, FocusBackgroundColor etc resolve engine-side with no rebuild. never wire OnMouseEnter/OnMouseLeave just for visual feedback. pair with TransitionMs for fades. declare the resting BackgroundColor and its variant together on the same blob.

  9. compose controls. a button is a Container with OnClick and a hover variant; Goo.Controls covers button/slider; everything else is function extraction: static Container Card(string title) => new Container { ... };. repeated property bundles are a missing helper, not a style class. promote repeated literals to a theme constants class.

  10. never hold a Container across rebuilds. its children list comes from a per-build pool. extract the function that builds it; never cache the blob in a field or static readonly. Text and Image are pool-free and safe to cache.

  11. key every child of a list that reorders, filters, or grows, and never mix keyed with unkeyed siblings. a mixed list makes the reconciler abandon keys for the whole list (a warning plus real per-frame cost). keys must be unique and derived from a stable id, never a display value that can repeat. Children.AddRange keys loops for you.

  12. Position = Absolute resolves against the nearest positioned ancestor. give the intended parent Position = PositionMode.Relative. the inverse trap: sibling shapes meant to overlap must each be Position = Absolute, Top = 0, Left = 0 or flex lays them side by side (shapes).

  13. animate through the cheap channels. per-frame changes to transforms (Transform = PanelTransform.Rotate(deg).Scale(s)), colors, opacity, and positions are style writes and effectively free. per-frame changes to these are NOT free and tank the frame rate:

    • Polygon points driven every frame bake a new points-texture each frame - keep the points fixed and move the panel with a Transform instead. Sector and Arc geometry (StartAngle/EndAngle, radii, StrokeWidth) is shader uniforms (GPU SDF, no bake) and cheap to animate freely;
    • any style on a panel carrying state variants re-emits its hover stylesheet each frame - keep variant-carrying panels statically styled;
    • shader motion belongs in uniforms: a ShaderEffect uniform accepts a literal or a per-frame Func<T> (["LightPos"] = (Func<Vector2>)(() => Mouse.Position / Screen.Size) - the explicit Func cast is required, a bare lambda does not compile), bypassing the reconciler entirely.
  14. animation state is a damper field, advanced in Tick, sampled in Build. SpringFloat _pulse = new(1f, 4f, 0.5f); then _pulse.Tick(dt) in Tick, set .Target (or kick .Velocity) on input, read .Current in Build. easing names are exactly: Linear, Ease, EaseIn, EaseOut, EaseInOut, ExpoIn, ExpoOut, ExpoInOut, BounceIn, BounceOut, BounceInOut, SineIn, SineOut, SineInOut, StepStart, StepEnd.

  15. do not declare PointerEvents defensively. goo auto-gates: handlers (or TextEntry/WebPanel) resolve to All, state variants force All, everything else resolves to None. the one case auto-gating does not cover: a scroll viewport, which needs an explicit PointerEvents.All or it looks inert. a scroll container needs all three of: a bounded size on the scroll axis, OverflowY = OverflowMode.Scroll (not Auto, not Hidden), and PointerEvents = PointerEvents.All.

  16. SwallowClick = true stops a click from bubbling. use it on content drawn over a dismiss-on-click scrim. an empty OnClick = e => { } does NOT stop propagation.

  17. if a property seems missing, do not invent it. the generated files Container.Style.g.cs, Text.Style.g.cs, etc under the goo library's Code/Core/ are the property tables (grep *.Style.g.cs from the project root). if it is not there, it does not exist - say so instead of guessing.

  18. two C# name traps. PanelTransform is ambiguous between Goo and Sandbox.UI when both are imported: add using PanelTransform = Goo.PanelTransform;. do not alias anything to UI - it collides with the Sandbox.UI namespace from inside namespace Sandbox. (the ready-made controls sidestep a third trap by construction: the static class is Controls, not Components, so a bare reference never collides with an app-level Components.Button - just using Goo; then Controls.Button(...).)

canonical shapes

every Goo source file opens with this header (the PanelTransform alias from rule 18 is non-optional whenever you touch Transform, and dampers need Goo.Animation):

using Goo;
using Sandbox;
using Sandbox.UI;
using PanelTransform = Goo.PanelTransform;   // rule 18: PanelTransform is otherwise ambiguous with Sandbox.UI
using Goo.Animation;                         // only when using a damper (SpringFloat/SmoothFloat/DecayFloat/...)

counter (state, auto-rebuild, hover variant)

public class CounterUI : GooPanel<Container>
{
    int _count;

    protected override Container Build() => new Container
    {
        Padding = 16, Gap = 12, FlexDirection = FlexDirection.Row, AlignItems = Align.Center,
        BackgroundColor = Color.White,
        Children =
        {
            new Text( _count.ToString() ),
            new Container
            {
                Padding = 8, BorderRadius = 6,
                BackgroundColor = Color.Gray, HoverBackgroundColor = Color.White, TransitionMs = 150,
                OnClick = e => _count++,   // handler triggers the rebuild automatically
                Children = { new Text( "+" ) },
            },
        },
    };
}

continuous motion (Tick + damper + transform channel)

using Goo;
using Goo.Animation;                         // SpringFloat lives here, NOT in Goo
using Sandbox;
using PanelTransform = Goo.PanelTransform;   // required: Sandbox.UI also defines PanelTransform

public class PulseUI : GooPanel<Container>
{
    float _t;
    SpringFloat _scale = new( 1f, 4f, 0.5f );

    protected override bool Tick( float dt ) { _t += dt; _scale.Tick( dt ); return true; }

    protected override Container Build() => new Container
    {
        Width = 64, Height = 64, BorderRadius = 8,
        Transform = PanelTransform.Rotate( _t * 45f ).Scale( _scale.Current ),
        BackgroundColor = Color.Red,
        OnClick = e => _scale.Velocity += 5f,   // springy kick on click
    };
}

generated list (AddRange auto-keys)

var grid = new Container { FlexWrap = Wrap.Wrap, Gap = 4, Width = 232 };
grid.Children.AddRange( _items, ( i, item ) => new Container
{
    Width = 52, Height = 52,
    BackgroundColor = item.Color, HoverBackgroundColor = Color.White,
    OnClick = e => Select( item ),
} );

HUD with anchored corners and an effect layer

protected override Container Build()
{
    var root = Hud.Overlay();   // full-screen, pointer-through, Column
    root.Children.Add( Hud.Fill() with
    {
        Effect = new ShaderEffect( "shaders/ui_particles.shader" ) { ["Speed"] = 0.4f },
    } );
    root.Children.Add( Hud.Anchored( Layout.Anchor.TopRight,   Minimap(),    padding: Px.Of( 16 ) ) );
    root.Children.Add( Hud.Anchored( Layout.Anchor.BottomLeft, HealthCard(), padding: Px.Of( 32 ) ) );
    return root;
}

note: a Hud factory result (and any pre-styled factory output) is overridable with with; goo style lists resolve last-declared-wins, so place an override after the factory or shorthand, never before.

scrollable container

var viewport = new Container
{
    Height = 240,                          // bounded so children overflow
    FlexDirection = FlexDirection.Column,
    OverflowY = OverflowMode.Scroll,       // Scroll, not Auto or Hidden
    PointerEvents = PointerEvents.All,     // required or the wheel never arrives
};

no visible scrollbar is rendered; scrolling is wheel and drag. programmatic scroll offset is not exposed on blobs - surface that as a gap rather than faking it.

see also

if anything in this primer contradicts the code, the code wins: trust the live library and open an issue on the goo repository.