---
title: "Build a game screen with the UI kit"
url: https://recursiveai.org/onegame/cookbook/game-ui-screen/
description: "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…"
version: "1.2.0"
---

# Build a game screen with the UI kit

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.

```csharp
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](https://recursiveai.org/onegame/cookbook/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 }
```

```shell
onegame test
```

```text
PASS buy-with-mouse (36 ms, tick 1, hash 29d21e7ab9234ce8)
PASS buy-with-pad (3 ms, tick 3, hash f8fcbc6f2c91948b)
PASS sell-back (7 ms, tick 2, hash 28be7134e60a0f97)
3 passed, 0 failed
```

`buy-with-pad` is the controller version, in [Controller and keyboard navigation](https://recursiveai.org/onegame/cookbook/controller-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](https://recursiveai.org/onegame/docs/ui-guide/).
