Recursive AI

Build a game screen with the UI kit

All onegame recipes

Build a C# game screen from the onegame component kit: a table of items bound to synced state, a primary button, a confirm dialog, a theme with your own accent, and scenarios that click through it.

A screen is a tree of elements built with Ui.* and the component kit UiKit.*. It is styled on the CSS model: selectors, a cascade and tokens, with flexbox layout. Components are controlled: they read the game’s state through a lambda and change it by sending commands, so the synced state stays the single source of truth.

The game

A market stall with gold and the stock of three goods. Shop holds the state and the commands; the screen shows a table, a Buy button and a Sell stock back button behind a confirm dialog.

C#
using System.Linq;
using OneGame.Atlas;
using OneGame.Core;
using OneGame.Host;
using OneGame.UI;
using OneGame.UI.Kit;
 
namespace Stall;
 
/// <summary>A market stall: gold and the stock of three goods.</summary>
[Entity]
public partial class Shop : Entity
{
    public static readonly string[] Names = ["Apples", "Pears", "Plums"];
    public static readonly int[] Prices = [3, 4, 6];
 
    [Synced] public partial int Gold { get; private set; }
    [Synced] public SyncList<int> Stock { get; } = new();
    [Synced] public partial int Sales { get; private set; }
 
    public void Open()
    {
        Gold = 50;
        Stock.Add(12);
        Stock.Add(8);
        Stock.Add(5);
    }
 
    [Command]
    public bool Buy(int item)
    {
        if (item < 0 || item >= Stock.Count || Stock[item] == 0 || Gold < Prices[item])
        {
            return false;
        }
 
        Gold -= Prices[item];
        Stock[item] -= 1;
        Sales++;
        return true;
    }
 
    [Command]
    public void SellBack()
    {
        for (var i = 0; i < Stock.Count; i++)
        {
            Gold += Stock[i] * Prices[i] / 2;
            Stock[i] = 0;
        }
 
        Sales++;
    }
}
 
public sealed record Good(int Index, string Name, int Price);
 
public sealed class StallGame : GameDefinition
{
    // The rows never change; only the stock they show does. Keeping the same items keeps the table's selection.
    private static readonly Good[] Goods = [.. Shop.Names.Select((name, i) => new Good(i, name, Shop.Prices[i]))];
 
    private KitHost? _kit;
 
    public override string Name => "stall";
 
    public override void RegisterEntities(EntityRegistry registry) => OneGame.Generated.Stall.EntityTypes.Register(registry);
 
    public override void Setup(World world)
    {
        var shop = new Shop();
        shop.Open();
        world.Add(shop);
    }
 
    // The kit's SVGs (sort arrows, chevrons, frame, glyphs) go into the atlas.
    public override void DefineArt(ArtAtlas art)
    {
        foreach (var a in KitArt.Assets)
        {
            art.AddSvg(a.Id, a.Svg, new NineSlice(a.Slice, a.Slice, a.Slice, a.Slice));
        }
    }
 
    // The kit's theme, with a green accent; KitTheme.Create repairs any text color that would fall short of WCAG AA.
    public override void Style(UiTree ui)
    {
        _kit = new KitHost(ui);
        _kit.SetTheme(KitTheme.Create(KitTokens.Dark with { Accent = Color.Hex("#367D5B"), Radius = Length.Ui(6) }));
        ui.AddStyleSheet(new StyleSheet()
            .Rule(".gold", s => s.FontSize(Length.Ui(18)))
            .Rule(".actions", s => s.Gap(Length.Ui(8))));
    }
 
    public override void DefineScreens(ScreenSet screens) =>
        screens.Add("main", game =>
        {
            var table = UiKit.Table<Good>(
                [
                    new TableColumn<Good>("Good", g => g.Name, ColumnWidth.Flex(2)),
                    new TableColumn<Good>("Price", g => g.Price.ToString(), ColumnWidth.Fixed(Length.Ui(80)), alignEnd: true),
                    new TableColumn<Good>("Stock", g => ShopOf(game).Stock[g.Index].ToString(), ColumnWidth.Fixed(Length.Ui(80)), alignEnd: true),
                ],
                () => Goods,
                rowHeight: Length.Ui(28),
                onActivate: (_, index) => ShopOf(game).Send.Buy(index),
                id: "goods",
                version: () => ShopOf(game).Sales);   // a sale re-reads the cells
 
            var confirm = UiKit.ConfirmDialog(
                "Sell the stock back?",
                "Everything left goes back to the farm at half price.",
                onConfirm: () => ShopOf(game).Send.SellBack(),
                confirmText: "Sell back",
                cancelText: "Keep it",
                id: "confirm",
                danger: true);
 
            var screen = Ui.Column(
                    Ui.Label(() => $"{ShopOf(game).Gold} gold").Class("gold").Named("gold"),
                    Ui.Column(table).Height(Length.Ui(120)),
                    Ui.Row(
                        UiKit.Button("Buy one", () => ShopOf(game).Send.Buy(table.Body.SelectedIndex), "buy", ButtonKind.Primary),
                        UiKit.Button("Sell stock back", () => confirm.Open(), "sell")).Class("actions"))
                .Padding(Length.Ui(24)).Gap(Length.Ui(12)).Width(Length.Ui(420));
 
            // A controller player starts in the table. RequestFocus applies after the next update, once the screen is in the tree.
            game.Ui.RequestFocus(table.Body);
 
            // The dialog goes last so it paints over everything; the kit's tooltip layer lives inside the screen.
            return _kit!.AttachTo(Ui.Column(screen, confirm));
        });
 
    private static Shop ShopOf(GameInstance game) => (Shop)game.World.Entities[0];
}

onegame capture --out shots/main.png --size 720p draws it: “50 gold” above a three-row table with Good, Price and Stock columns, apples selected and focused, a green Buy one button and a Sell stock back button.

  • Plugging in the kit takes three places: its art in DefineArt, its theme in Style (the host calls it once, before the first screen), and AttachTo on the screen so the tooltip layer lives inside it.
  • The table reads its rows through () => Goods every frame. It creates elements only for the rows in view, so thousands of rows cost the same as three. Return the same list while nothing changed; bump version: when the cells should be read again, as each sale does here.
  • Ids make the screen testable. A component’s id prefixes its parts: goods/body is the table body, goods/row/2 the third row, goods/header/0 the first header cell.
  • Commands only. Buttons and the table send Buy and SellBack; no view code writes Gold. Writing it from a view is a compile error.
  • Styles come after the kit’s sheet in Style, so they win ties. KitTokens.Dark with { Accent = ... } changes the theme’s tokens and KitTheme.Create builds the sheet from them. Check UI contrast explains why the accent is #367D5B.

Test it with scenarios

Buy with the mouse:

YAML
name: buy-with-mouse
description: Select plums in the table and buy one with the Buy button.
steps:
  - click: goods/row/2
  - click: buy
  - step: 1
expect:
  - state: { entity: Shop, property: Gold, equals: 44 }
  - state: { entity: Shop, property: "Stock[2]", equals: 4 }
  - ui: { id: gold, text: "44 gold" }

The dialog: Escape closes it without selling, then Tab moves from Keep it (where a danger dialog starts) to Sell back:

YAML
name: sell-back
description: The sell-back dialog starts on Cancel, Escape closes it, and confirming sells everything at half price.
steps:
  - click: sell
  - expect:
      ui: { id: confirm, visible: true }
  - press: Escape
  - step: 1
  - expect:
      ui: { id: confirm, visible: false }
  - click: sell
  - press: Tab
  - press: Enter
  - step: 1
expect:
  - state: { entity: Shop, property: Gold, equals: 99 }
  - state: { entity: Shop, property: "Stock[0]", equals: 0 }
  - ui: { id: confirm, visible: false }
Terminal
onegame test
Text
PASS buy-with-mouse (35 ms, tick 1, hash 29d21e7ab9234ce8)
PASS buy-with-pad (4 ms, tick 3, hash f8fcbc6f2c91948b)
PASS sell-back (8 ms, tick 2, hash 28be7134e60a0f97)
3 passed, 0 failed

buy-with-pad is the controller version, in Controller and keyboard navigation.

Good to know

  • The kit also has toggles, radio groups, sliders, number steppers, progress bars, tabs, scroll views, drop-downs, text input, tooltips and input glyphs. Each is reachable by keyboard and gamepad and allocates nothing on a frame where nothing changed.
  • game.Ui is the screen’s tree: Find(id), Query(selector) and DumpJson() are what tests and live control see.
  • All rows of a table have the same height.
  • With ImplicitUsings off (the SDK’s setting), add using System.Linq; and the like yourself.

Every component, property and pattern is in the UI guide.