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.
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 inStyle(the host calls it once, before the first screen), andAttachToon the screen so the tooltip layer lives inside it. - The table reads its rows through
() => Goodsevery 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; bumpversion: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/bodyis the table body,goods/row/2the third row,goods/header/0the first header cell. - Commands only. Buttons and the table send
BuyandSellBack; no view code writesGold. 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 andKitTheme.Createbuilds the sheet from them. Check UI contrast explains why the accent is#367D5B.
Test it with scenarios
Buy with the mouse:
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:
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 }
onegame testPASS 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.Uiis the screen’s tree:Find(id),Query(selector)andDumpJson()are what tests and live control see.- All rows of a table have the same height.
- With
ImplicitUsingsoff (the SDK’s setting), addusing System.Linq;and the like yourself.
Every component, property and pattern is in the UI guide.