# onegame docs, all pages (onegame 1.2.0)
Every page of https://recursiveai.org/onegame/docs/ and https://recursiveai.org/onegame/cookbook/ as markdown, getting started first. Index: https://recursiveai.org/onegame/llms.txt
# Getting started with onegame
Install the onegame command from NuGet, create a game, run, test and export it, then write an entity with synced state, a command and a view, and test it with a scenario.
onegame is a C# game engine with no editor. A game is plain C# plus text assets, and the `onegame` command builds, runs, tests, captures, replays and exports it from a terminal. Every command and every line of output on this page comes from one run of the 1.2.0 packages from NuGet, on Linux x64 with the .NET 10 SDK and no engine checkout.
## Install
You need the .NET 10 SDK. The `onegame` command is a .NET tool:
```shell
dotnet tool install -g RecursiveAI.OneGame.Cli --version 1.2.0
```
```text
You can invoke the tool using the following command: onegame
Tool 'recursiveai.onegame.cli' (version '1.2.0') was successfully installed.
```
- The tool is framework-dependent. If .NET lives in your home folder (the `dotnet-install` script puts it in `~/.dotnet`) rather than in a system location, set `DOTNET_ROOT` to that folder, or the tool stops with “You must install .NET to run this application.”
- `onegame help` lists every command. The exit codes are the same for all of them: 0 success, 1 a check failed, 2 bad command line, 3 the build failed, 4 the game or tool could not run, 5 the option needs a Debug build.
- The license is MIT or Apache-2.0, at your choice.
## Create a game
```shell
onegame new orchard
cd orchard
```
```text
created /tmp/onegame/orchard
cd /tmp/onegame/orchard
onegame build
onegame run --headless --frames 60
onegame test
```
The folder holds `orchard.csproj`, `Program.cs`, `Game.cs`, two scenarios (`scenarios/steer-ship.yaml` and `scenarios/ship-looks-right.yaml`, with its reference picture in `scenarios/refs/`), a `README.md`, `art/ship.png` and `fonts/` with Noto Sans Regular and its license. The game is a ship drawn from `art/ship.png` that you fly with WASD, the arrow keys or a gamepad’s left stick, and Space or the pad’s south button boosts. The project file is the whole build setup:
```xml
Orchard
Orchard
```
The `RecursiveAI.OneGame.Sdk` package sets the target framework and the build rules (warnings are errors, nullable on, implicit usings off), references the engine libraries at its own version, adds the development tools to Debug builds and carries the export targets. Everything restores from nuget.org. [Your first game](https://recursiveai.org/onegame/docs/first-game/) walks through the scaffold’s `Game.cs` line by line: the sprite, the input actions and the hot-reloaded picture.
## Run it
```shell
onegame run --headless --frames 60 --json
```
```text
{"ok":true,"game":"orchard","headless":true,"frames":60,"tick":60,"hash":"a367632d3cc24f14","width":1280,"height":720}
```
`run` builds the game and runs it. `--headless` needs no window or display, and `--frames 60` stops after 60 frames. `hash` is the hash of the whole world after the last tick: the same game, seed and inputs give the same hash every time. `onegame run` without `--headless` opens a window, and while it runs, an edit to `art/ship.png` shows in the next frame.
## Test it
```shell
onegame test
```
```text
PASS ship-looks-right (459 ms, tick 40, hash 32a7749f302a0994)
PASS steer-ship (4 ms, tick 22, hash eac71440f63f090d)
2 passed, 0 failed
```
`test` runs every file in `scenarios/` in a headless game, plus any C# test projects in the folder. `steer-ship` presses the `move` and `boost` actions and checks where the ship got to; `ship-looks-right` compares a drawn frame with its reference picture and checks that `art/ship.png` loaded. With `--json` the result is one object, with each step and expectation and the line it came from:
```text
{"ok":true,"scenarios":{"ok":true,"passed":2,"failed":0,"scenarios":[{"name":"ship-looks-right","source":"/tmp/onegame/orchard/scenarios/ship-looks-right.yaml","passed":true,"tick":40,"hash":"32a7749f302a0994","elapsedMs":444,"steps":[{"index":0,"kind":"action","line":5,"ok":true},{"index":1,"kind":"step","line":6,"ok":true}],"expectations":[{"index":0,"kind":"capture","line":8,"passed":true,"target":"refs/ship.png","actual":"0 px differ, max channel delta 1"},{"index":1,"kind":"asset","line":9,"passed":true,"target":"asset.art/ship.png"}]},{"name":"steer-ship","source":"/tmp/onegame/orchard/scenarios/steer-ship.yaml","passed":true,"tick":22,"hash":"eac71440f63f090d","elapsedMs":4,"steps":[{"index":0,"kind":"action","line":4,"ok":true},{"index":1,"kind":"step","line":5,"ok":true},{"index":2,"kind":"action","line":6,"ok":true},{"index":3,"kind":"step","line":7,"ok":true},{"index":4,"kind":"expect","line":8,"ok":true},{"index":5,"kind":"action","line":10,"ok":true},{"index":6,"kind":"action","line":11,"ok":true},{"index":7,"kind":"step","line":12,"ok":true}],"expectations":[{"index":4,"kind":"state","line":9,"passed":true,"target":"Ship.X","actual":"15"},{"index":0,"kind":"action","line":14,"passed":true,"target":"action","expected":"boost: held=true","actual":"boost: held=true"},{"index":1,"kind":"state","line":15,"passed":true,"target":"Ship.Boost","expected":"true","actual":"true"},{"index":2,"kind":"state","line":16,"passed":true,"target":"Ship.Y","actual":"30"}]}]},"unitTests":{"ran":false,"ok":true,"total":0,"passed":0,"failed":0,"skipped":0,"projects":[]}}
```
The capture scenario draws a frame, so it needs a Vulkan driver (a software one is fine). Here it ran on the GPU.
## Export it
```shell
onegame export --target linux
```
```text
exported orchard 1.0.0 for linux-x64 (nativeaot): 6 files, 7622 KiB
folder: /tmp/onegame/orchard/export/orchard-1.0.0-linux-x64
archive: /tmp/onegame/orchard/export/orchard-1.0.0-linux-x64.tar.gz (3689 KiB)
warning glibc_too_new: Orchard needs glibc 2.38, newer than 2.31 in Steam's Linux runtime (and so on the Steam Deck). Build on an older system, or inside the Steam Linux Runtime SDK: see docs/exporting.md.
```
The folder holds the NativeAOT program `Orchard`, `run.sh`, `art/ship.png`, `fonts/` and `THIRD-PARTY-NOTICES.txt`. The development tools are left out, so the exported program draws the picture it ships and doesn’t watch it for changes. It gives the same hash as `onegame run`:
```shell
export/orchard-1.0.0-linux-x64/run.sh --headless --frames 60 --json
```
```text
{"ok":true,"game":"orchard","headless":true,"frames":60,"tick":60,"hash":"a367632d3cc24f14","width":1280,"height":720}
```
- The warning matters only for Steam: the export was built on a system with a newer glibc than the Steam Linux runtime. See [Ship a game for Steam and the Steam Deck](https://recursiveai.org/onegame/cookbook/steam-and-steam-deck/).
- `--target win` and `--target osx` also work from the packages. NativeAOT needs a host of the same OS, so from Linux they make a self-contained build instead and say so in a warning. See [Export for Linux, Windows and macOS](https://recursiveai.org/onegame/cookbook/export-desktop/).
- For a NativeAOT build the repository’s docs list the platform’s C++ toolchain: `clang` and `zlib1g-dev` on Linux, the Visual Studio C++ tools on Windows.
- **Web, Android and iOS exports need an engine checkout at 1.2.0.** A game made from the packages gets `no_engine_root` from `--target web`. See [Export for the web, Android and iOS](https://recursiveai.org/onegame/cookbook/export-web-android-ios/).
## Write an entity
An entity is one class that holds synced state, commands, the simulation step and view code. Replace `Game.cs` with an apple tree: apples grow on their own, and players pick them. It draws with the UI alone; [Draw sprites, shapes and text](https://recursiveai.org/onegame/cookbook/draw-sprites-2d/) and [Input actions](https://recursiveai.org/onegame/cookbook/input-actions/) take the scaffold’s ship further.
```csharp
using OneGame.Core;
using OneGame.Host;
using OneGame.UI;
namespace Orchard;
/// An apple tree. Apples grow on their own; players pick them.
[Entity]
public partial class Tree : Entity
{
[Synced] public partial int Apples { get; private set; }
[Synced] public partial int Picked { get; private set; }
[Synced] public partial int Growth { get; private set; }
// A command: the UI and tests send it, the authority checks it and runs it.
[Command]
public bool Pick(int count)
{
if (count < 1 || count > Apples)
{
return false; // refused: the state does not change
}
Apples -= count;
Picked += count;
return true;
}
// Every 30 ticks, zero to two new apples, drawn from a named random stream so a replay draws the same.
protected override void OnTick()
{
if (++Growth < 30)
{
return;
}
Growth = 0;
Apples += World!.Rng["growth"].NextInt(3);
}
[View]
public string Label() => $"{Apples} on the tree, {Picked} picked";
}
public sealed class OrchardGame : GameDefinition
{
public override string Name => "orchard";
public override void RegisterEntities(EntityRegistry registry) => OneGame.Generated.Orchard.EntityTypes.Register(registry);
public override void Setup(World world) => world.Add(new Tree());
public override void DefineScreens(ScreenSet screens) =>
screens.Add("main", game => Ui.Column(
Ui.Label(() => TreeOf(game).Label()).Named("apples"),
Ui.Button("Pick one", () => TreeOf(game).Send.Pick(1)).Named("pick").Padding(Length.Px(8)).Background(Color.Rgb(0x2F6B4F)))
.Padding(Length.Px(24)).Gap(Length.Px(12)).Background(Color.Rgb(0x1E2A22)).TextColor(Color.White));
private static Tree TreeOf(GameInstance game) => (Tree)game.World.Entities[0];
}
```
- **Synced state** is a `[Synced]` partial property. The source generator completes it with change tracking, a binary codec, deltas and snapshots. The setters are private, so only the entity’s own sim code writes them.
- **A command** is a `[Command]` method. Views and tests call it through `Send` (`TreeOf(game).Send.Pick(1)`), and the authority runs it on the next tick. Returning `false` refuses it. Commands are the only way a view changes the world.
- **The simulation step** is `OnTick`, run at a fixed rate. Randomness comes from named streams (`World.Rng["growth"]`) seeded from the world’s seed, so the draws are part of the state. Floats, clocks, `System.Random`, threads and I/O in sim code are compile errors. See [Deterministic simulation](https://recursiveai.org/onegame/cookbook/deterministic-simulation/).
- **A view** is a `[View]` member. It reads state and never writes it: assigning `Picked` inside `Label` is a compile error.
- **The screen** is built in `DefineScreens` with a C# builder. Lambdas such as `() => TreeOf(game).Label()` are bindings, read again every frame. `Named("pick")` gives an element the id that scenarios and tests use.
Build and run it twice with the default seed, then with seed 7:
```shell
onegame build
onegame run --headless --frames 300 --json
onegame run --headless --frames 300 --seed 7 --json
```
```text
build succeeded (Debug, 0 warnings, 0.79s)
{"ok":true,"game":"orchard","headless":true,"frames":300,"tick":300,"hash":"d0045829889cf126","width":1280,"height":720}
{"ok":true,"game":"orchard","headless":true,"frames":300,"tick":300,"hash":"d5ef04c0af6a538b","width":1280,"height":720}
```
A different seed grows a different tree; the same seed always grows the same one.
## Test it with a scenario
Delete `scenarios/steer-ship.yaml`, `scenarios/ship-looks-right.yaml` and `scenarios/refs/`, which test the ship, and add `scenarios/pick-apples.yaml`:
```yaml
name: pick-apples
description: Apples grow, picking takes them, and picking more than there are is refused.
seed: 7
steps:
- expect:
ui: { id: apples, text: "0 on the tree, 0 picked" }
- click: pick # nothing to pick yet: refused
- step: 300
- click: pick
- step: 1
- command: { entity: Tree, name: Pick, args: [99] } # more than there are: refused
- step: 1
expect:
- state: { entity: Tree, property: Picked, equals: 1 }
- state: { entity: Tree, property: Apples, equals: 9 }
- ui: { id: apples, text: "9 on the tree, 1 picked" }
```
```shell
onegame test
```
```text
PASS pick-apples (39 ms, tick 302, hash 9a447aae324c764b)
1 passed, 0 failed
```
A scenario clicks elements by id, sends commands, steps frames and checks state and UI. `seed: 7` fixes the world’s random streams, so the nine apples left are the same on every run. A failing expectation prints the line, what was expected and what was found, and `onegame test` exits 1. [Automated game testing with scenarios](https://recursiveai.org/onegame/cookbook/automated-game-testing/) covers the rest of the format.
`onegame capture --size 720p --after 300` writes `captures/frame.png`, the screen after 300 ticks, so you can look at it.
## Packages by area
A game references only the SDK. It brings `Host`, `Core`, `UI` and `Audio` (and `Live` in Debug builds), and through them the rest of the engine. Every package is version 1.2.0, MIT or Apache-2.0.
| Package | What it is |
|---|---|
| `RecursiveAI.OneGame.Cli` | The `onegame` command, a .NET tool: create, build, run, test, capture, replay, export. |
| `RecursiveAI.OneGame.Sdk` | The MSBuild SDK for games: references, rules and export targets, so a game builds from nuget.org without an engine checkout. |
| `RecursiveAI.OneGame.Core` | The entity runtime: fixed-point numbers, random streams and the attributes that declare entities. It carries the source generators and analyzers. |
| `RecursiveAI.OneGame.Sync` | Nodes, scopes, saves, the command log, replay and drift hashes. |
| `RecursiveAI.OneGame.Net` | Reliable UDP and WebSocket transports, and a network conditioner for tests. |
| `RecursiveAI.OneGame.Server`, `.Server.Live` | The headless server host (fixed-step scopes, YAML configuration, the plugin API) and its live control. |
| `RecursiveAI.OneGame.Host` | The game host: window, input, fixed-step simulation, UI and render stage, windowed or headless. `Draw2D` for sprites, shapes and text from view code, and `Assets` for cached, hot-reloaded files. |
| `RecursiveAI.OneGame.Platform` | Windows, input, controllers, input actions and the app lifecycle per operating system. |
| `RecursiveAI.OneGame.UI`, `.UI.Kit` | The retained UI tree with CSS-model styling and flex layout, and the component kit: buttons, tables, lists, tabs, dialogs, text input. |
| `RecursiveAI.OneGame.Text`, `.Atlas` | Fonts, shaping and the glyph atlas; the SVG and image atlas for the 2D renderer. |
| `RecursiveAI.OneGame.Render2D` | Sprites, sprite sheets and clips, the 2D camera, tilemaps and text. |
| `RecursiveAI.OneGame.Render3D`, `.Gltf` | The physically based 3D renderer, and glTF 2.0 import that is safe on untrusted files. |
| `RecursiveAI.OneGame.Gpu`, `.Gpu.Vulkan`, `.Gpu.Metal`, `.Gpu.WebGpu`, `.Gpu.Backends` | The graphics layer and render graph, its three backends, and the package that picks one for the machine. |
| `RecursiveAI.OneGame.Audio` | Audio device output and the audio service. |
| `RecursiveAI.OneGame.Steam` | Steamworks bindings, Steam Input, and store-neutral achievements, stats, cloud saves and presence. |
| `RecursiveAI.OneGame.Purchases` | In-app purchases over StoreKit, Google Play Billing and a scriptable fake store. |
| `RecursiveAI.OneGame.Control`, `.Live` | The control layer the command line, tests and live control share; the development tools of a Debug build. |
| `RecursiveAI.OneGame.Testing` | The C# test API: in-process clusters, scenarios, recordings, captures and determinism checks. |
## What onegame doesn’t do
- **No editor.** That is the design: everything is code and text, driven from the terminal, tests and live control.
- **No physics engine.** Collision and movement are your own sim code. The 3D scene docs list physics under “not built.”
- **Not an ECS.** Entities are classes that hold their own state and logic. For hot loops over many rows there are `[HotData]` struct-of-arrays tables.
- **Web, Android and iOS exports need an engine checkout at 1.2.0**, as do Play Billing in Android builds and plugin servers. Desktop exports and dedicated servers work from the packages.
## Where next
Cookbook recipes, each run on Linux against the 1.2.0 packages:
- [Automated game testing with scenarios](https://recursiveai.org/onegame/cookbook/automated-game-testing/), [record and replay a game](https://recursiveai.org/onegame/cookbook/record-and-replay/), [screenshot tests](https://recursiveai.org/onegame/cookbook/screenshot-tests/)
- [Test a game from C#](https://recursiveai.org/onegame/cookbook/csharp-game-tests/), [start tests from fixtures and snapshots](https://recursiveai.org/onegame/cookbook/fixtures-and-snapshots/), [drive a running game over MCP](https://recursiveai.org/onegame/cookbook/live-control-mcp/)
- [Deterministic simulation](https://recursiveai.org/onegame/cookbook/deterministic-simulation/), [client/server sync with a dedicated server](https://recursiveai.org/onegame/cookbook/client-server-sync/), [client-side prediction](https://recursiveai.org/onegame/cookbook/client-side-prediction/)
- [Draw sprites, shapes and text](https://recursiveai.org/onegame/cookbook/draw-sprites-2d/), [input actions for keyboard, gamepad and tests](https://recursiveai.org/onegame/cookbook/input-actions/)
- [Build a game screen with the UI kit](https://recursiveai.org/onegame/cookbook/game-ui-screen/), [controller and keyboard navigation](https://recursiveai.org/onegame/cookbook/controller-navigation/), [check UI contrast](https://recursiveai.org/onegame/cookbook/ui-contrast/)
- [Export for Linux, Windows and macOS](https://recursiveai.org/onegame/cookbook/export-desktop/), [ship for Steam and the Steam Deck](https://recursiveai.org/onegame/cookbook/steam-and-steam-deck/), [export for the web, Android and iOS](https://recursiveai.org/onegame/cookbook/export-web-android-ios/)
The guides:
- [Your first game](https://recursiveai.org/onegame/docs/first-game/): the scaffold line by line
- [Driving a game](https://recursiveai.org/onegame/docs/driving-a-game/): scenarios, captures, replays, live control, servers and clusters
- [The UI guide](https://recursiveai.org/onegame/docs/ui-guide/): screens, styles, the component kit, focus and theming
- [Exporting](https://recursiveai.org/onegame/docs/exporting/) and [checking on devices](https://recursiveai.org/onegame/docs/devices/)
- [Changelog](https://recursiveai.org/onegame/docs/changelog/)
Coming from another engine: [onegame for MonoGame developers](https://recursiveai.org/onegame/articles/onegame-for-monogame-developers/) and [onegame for Unity developers](https://recursiveai.org/onegame/articles/onegame-for-unity-developers/).
Ready for more than the ship? Build a full game in [C# game development without an editor](https://recursiveai.org/onegame/articles/csharp-game-development-without-editor/).
# Your first game
From nothing to an exported program, with only the .NET 10 SDK and nuget.org.
```
dotnet tool install -g RecursiveAI.OneGame.Cli
onegame new mygame
cd mygame
```
`onegame new` writes the files you edit (a project, an entry point, the game, two scenarios, a readme), a default font and a ship picture, `art/ship.png`. The result is a ship you fly with the keyboard or a gamepad, drawn from the picture, with its code, tests and a hot-reloaded asset in about 90 lines. The project file is the whole build setup: the `RecursiveAI.OneGame.Sdk` package brings the target framework, the engine libraries, the analyzers that make the sim and view rules compile errors, the development tools of Debug builds and the export targets.
**`mygame.csproj`**
```xml
Mygame
Mygame
```
**`Program.cs`**
```csharp
using Mygame;
using OneGame.Host;
// A named class rather than top-level statements, so the Android entry point (onegame export --target android) can call Main.
internal static class Program
{
internal static int Main(string[] args) => GameApp.Run(new MygameGame(), args);
}
```
**`Game.cs`**
```csharp
using System;
using System.Numerics;
using OneGame.Core;
using OneGame.Host;
using OneGame.Platform;
using OneGame.UI;
namespace Mygame;
/// A ship. Synced state is fixed-point and changes only through commands; the view draws it from art/ship.png and never writes state.
[Entity]
public partial class Ship : Entity, IView2D
{
[Synced] public partial Fixed X { get; private set; }
[Synced] public partial Fixed Y { get; private set; }
[Synced] public partial int Dx { get; private set; }
[Synced] public partial int Dy { get; private set; }
[Synced] public partial bool Boost { get; private set; }
[Command]
public void Steer(int dx, int dy, bool boost) => (Dx, Dy, Boost) = (dx, dy, boost);
// Runs once per fixed simulation tick: deterministic code only (no floats, clocks or I/O in synced state).
protected override void OnTick()
{
var speed = Boost ? Fixed.FromInt(3) : Fixed.FromRatio(3, 2);
X += speed * Dx;
Y += speed * Dy;
}
// View code reads state and never writes it. The picture is an asset: edit art/ship.png while the game runs and it changes.
[View]
public void Draw(Draw2D d) =>
d.Sprite("art/ship.png", new Vector2(X.ToSingle(), Y.ToSingle()), scale: 2, tint: Boost ? Color.Rgb(0xFFD080) : Color.White);
[View]
public string Label() => $"x {X.ToInt()} y {Y.ToInt()}";
}
/// The game: its entity types, its starting world, its input actions and its screens.
public sealed class MygameGame : GameDefinition
{
public override string Name => "mygame";
public override void RegisterEntities(EntityRegistry registry) => OneGame.Generated.Mygame.EntityTypes.Register(registry);
public override void Setup(World world) => world.Add(new Ship());
// Input is declared as actions with default bindings. One declaration answers the keyboard, a gamepad and the test harness.
public override void DefineActions(ActionMap actions) =>
actions.Context("play", c => c
.Vector("move", Bind.Wasd, Bind.Arrows, Bind.LeftStick)
.Button("boost", Bind.Key(Key.Space), Bind.Pad(GamepadButton.South)));
// View code reads the actions and sends the ship a command when they change.
public override void OnEachFrame(GameInstance game)
{
var ship = ShipOf(game);
var move = game.Actions.Vector("move");
int dx = (int)MathF.Round(move.X), dy = (int)MathF.Round(move.Y);
var boost = game.Actions.Held("boost");
if (dx != ship.Dx || dy != ship.Dy || boost != ship.Boost)
{
ship.Send.Steer(dx, dy, boost);
}
}
public override void DefineScreens(ScreenSet screens) =>
screens.Add("main", game => Ui.Label(() => ShipOf(game).Label()).Named("pos").Padding(Length.Px(8)).TextColor(Color.White));
// Read the world through the instance: it is replaced when a snapshot or fixture is loaded.
private static Ship ShipOf(GameInstance game) => (Ship)game.World.Entities[0];
}
```
What is in it:
- **A moving sprite.** `X`, `Y`, `Dx`, `Dy` and `Boost` are **synced state**: partial properties the generator completes with change tracking, binary codecs, deltas and snapshots. Positions are `Fixed` (fixed-point, so every machine computes the same numbers); private setters mean only sim code writes them. `OnTick` is the **simulation step**, run at a fixed rate; a `float`, the clock or I/O in it is a compile error with an `OG....` id and the fix in the message. `Draw` is a **view member** of an `IView2D` entity: the 2D renderer calls it every frame and the sprite is drawn where the ship is. Assigning `X` inside it is a compile error. See `docs/interfaces/render2d.md` for sprites, shapes, text, sprite sheets and the camera.
- **An action-mapped input.** `DefineActions` declares a `move` vector and a `boost` button with default bindings (WASD, arrows, the left stick; Space, the pad's south button). `OnEachFrame` reads them and sends the ship a command, `Steer`, when they change. Commands are the only way a view changes the world: the authority runs them, so replays, saves and the network see commands, never keys. The same declaration answers the keyboard, a gamepad, Steam Input and the scenario step `action:`; players can rebind (`docs/interfaces/input.md`).
- **A hot-reloaded asset.** `d.Sprite("art/ship.png", ...)` loads the file once through the asset service and caches it. Run `onegame run`, edit `art/ship.png` in any image editor, save: the next frame shows the new picture and the game keeps running (Debug builds only; an export reads the files it ships). A damaged file keeps the old picture and reports the error. See `docs/interfaces/assets.md`.
- `DefineScreens` builds the UI with a declarative C# builder; the lambda `() => ShipOf(game).Label()` is a binding re-read every frame. The ship is read through the instance because the world is replaced when a snapshot or fixture is loaded.
**`scenarios/steer-ship.yaml`**
```yaml
name: steer-ship
description: The move action steers the ship, and holding boost makes it faster.
steps:
- action: { name: move, state: down, x: 1, y: 0 }
- step: 10
- action: { name: move, state: up }
- step: 2
- expect:
state: { entity: Ship, property: X, min: 14, max: 17 }
- action: { name: boost, state: down }
- action: { name: move, state: down, x: 0, y: 1 }
- step: 10
expect:
- action: { name: boost, held: true }
- state: { entity: Ship, property: Boost, equals: true }
- state: { entity: Ship, property: Y, min: 27, max: 33 }
```
**`scenarios/ship-looks-right.yaml`**
```yaml
name: ship-looks-right
description: The ship is drawn from art/ship.png after it has flown a little, and the picture is loaded.
window: 640x360
steps:
- action: { name: move, state: down, x: 1, y: -1 }
- step: 40
expect:
- capture: { reference: refs/ship.png, tolerance: 48, max-diff: 0.01 }
- asset: { path: art/ship.png, state: ready }
```
A scenario is a test written as text: it presses actions, steps frames and checks state, assets and a captured picture. `action:` steps go through the same actions the keyboard does. The capture compares the drawn frame with `scenarios/refs/ship.png`; it needs a Vulkan driver (a software one is fine).
## Run it
```
onegame run --headless --frames 60 --json
# {"ok":true,"game":"mygame","headless":true,"frames":60,"tick":60,"hash":"...","width":1280,"height":720}
onegame run # the same game in a window: fly with WASD, edit art/ship.png and save
```
`hash` is the hash of the whole world after the last tick. The same game, seed and inputs give the same hash on every system.
## Test it
```
onegame test --json
# {"ok":true,"scenarios":{"ok":true,"passed":2,"failed":0,...},"unitTests":{...}}
```
This runs every file in `scenarios/` in a headless game, and any C# test projects next to the game. Add a scenario by adding a file; `onegame capture --size 720p` writes a picture of the screen to look at.
## Export it
```
onegame export --target linux # or win, osx
```
The result is a folder and an archive in `./export`: a NativeAOT program, its fonts and pictures and a licence-notices file, with the development tools left out. Run it with `--headless --frames 60 --json` to check it starts; the hash equals the one from `onegame run`. NativeAOT needs the platform's C++ linker (`clang` and `zlib1g-dev` on Linux, the Visual Studio C++ tools on Windows); an export on another OS's host falls back to a self-contained build and says so in `warnings`.
## Next
- [driving-a-game.md](https://recursiveai.org/onegame/docs/driving-a-game/): scenarios in depth, replays, captures and live control for an agent.
- [ui-guide.md](https://recursiveai.org/onegame/docs/ui-guide/): screens, styling and the component kit.
- [exporting.md](https://recursiveai.org/onegame/docs/exporting/): every target, Steam, servers.
# Driving a game
onegame has no editor. You build a game, run it, look at it, poke it and replay it through one set of operations that comes in three forms: the `onegame` command line, a C# test API, and a live connection (MCP) to a running Debug build. This page shows each, in the order you usually need them. The operation list and JSON shapes are in `docs/interfaces/control.md`.
## 1. Make and run a game
Install the command once with `dotnet tool install -g RecursiveAI.OneGame.Cli`; a game it creates builds from nuget.org (`Sdk="RecursiveAI.OneGame.Sdk/"` in its project file). Inside an engine checkout, `onegame new shop --engine ` makes a game that builds against the checkout's projects instead.
```
onegame new shop # shop/: shop.csproj, Program.cs, Game.cs, scenarios/, README.md
cd shop
onegame build --json # {"ok":true,"errors":0,"warnings":0,...}
onegame run --headless --frames 60 --json
# {"ok":true,"game":"shop","frames":60,"tick":60,"hash":"...","width":1280,"height":720}
```
A game is a `GameDefinition` (entities, `Setup`, screens, fixtures) plus entity classes: synced state as partial properties, commands as methods, simulation in `OnTick`, views as `[View]` members. The scaffold shows all of it in 40 lines. Warnings are errors; a sim or view rule you break is a compile error with an `OG....` id and a message that says the fix.
Fonts: put `.ttf`/`.otf` files in a `fonts/` folder next to the game (or override `DefineFonts`). With fonts the 2D renderer draws the UI through the GPU, using the shaders the engine compiles during its own build; without fonts a plain preview is drawn, enough to check layout. SVG art goes in `DefineArt`.
## 2. Scenarios: tests as text
`scenarios/*.yaml`, one file per case:
```yaml
name: click-button
window: 320x200 # optional; also fixture, snapshot, seed
steps:
- click: add # an element id; or {selector: "Button"} or {x: 10, y: 20}
- step: 1 # frames
- command: { entity: Counter, name: Increment, args: [5] }
- set: { entity: Counter, property: Count, value: 40 }
- set: { table: lanes, row: 2, property: Heat, value: 7 } # one cell of a [HotData] table
- press: Tab # keys, also: type: "text", nav: next, wheel, screen, fixture, snapshot, reset: 77, ui-scale: 1.5, invoke
- steam-input: { connect: 3 } # the stand-in Steam Input controller (ONEGAME_STEAM_INPUT=fake): connect, disconnect, press, release, analog + x/y; runs 2 frames
- steam-input: { press: map_select }
- action: jump # an input action by name, as the keyboard would: also {name: move, x: 1, y: 0}, {name: move, state: down|up}
- pad: { button: south } # the harness's virtual gamepad; also {axis: leftx, value: 1}; press: {key: d, state: down|up} holds a key
- assets: { reload: art/ship.png } # check files now / reload one / apply-data (staged YAML); expect with asset: {path, state, version}
- nav: start # a controller button: up down left right next previous activate cancel, or any button by name (start, back, west, north, x, y, left_stick...)
expect:
- state: { entity: Counter, property: Count, equals: 3 }
- state: { entity: Ship, property: Fitted.count, equals: 2 } # a SyncList: count, [index], or the whole list as JSON
- state: { table: lanes, row: 2, property: Heat, equals: 7 }
- ui: { id: count, text: "Count: 3" } # or selector + count/contains/visible/enabled/focused/pressed
- steam-input: { set: Map, controllers: 1, down: map_select, keyboard: false } # active action set, pads, held actions, on-screen keyboard (docs/interfaces/steam.md)
- sound: { name: coin, count: 1, frames: 5 } # the game played the clip loaded as "coin" (or coin.ogg) once in the last 5 frames; also min, max; no bounds = at least once
- action: { name: move, held: true, x: 1 } # an action's current state (also pressed, released, y)
- asset: { path: art/ship.png, state: ready, version: 1 }
- tick: 3
- capture: { reference: refs/main.png, size: 320x200, tolerance: 2, max-diff: 0.001 }
```
**Sounds.** A scenario can assert what the game played without a sound device: `sound:` counts the sounds the game asked the audio service for since the scenario began (earlier ones don't count), matched by the name the clip was loaded under (`audio.LoadSound(stream, "coin")`; a file extension in that name is optional in the scenario). `frames: N` counts only those requested in the last N frames. Every scenario result carries the whole event log as `sounds` (sequence, frame, sound, bus, volume, loop, music, started), and the `audio_events` operation reads it live. Name clips when loading them: the default name is `sound`.
**Input and assets.** `action:` and `pad:` drive the game's input actions and a virtual gamepad (`docs/interfaces/input.md`); the live operations `actions` (list or drive actions) and `input_pad` do the same from an agent. Changes show after the next `step:`. `assets:` and `asset:` check and reload loaded files, and the `assets` operation lists them with their version and state. A 2D game's drawing is visible through `draw_view` (sprites and text drawn last frame, the camera, screen to world conversion). Hot reload works in a running Debug game: save the picture and the next `capture_frame` shows it (`docs/interfaces/assets.md`).
`onegame test --json` runs them all (and the project's C# tests; `--no-unit` skips those). A failing expectation reports `expected` and `actual` with the line. Unknown keys are errors with a line number. Run one with `onegame test --scenario scenarios/x.yaml`. `onegame test --update-references` creates missing capture references and rewrites changed ones; look at them before committing. With `--json`, the result has `references: {created: [...], updated: [...]}` (absolute paths) and each such expectation carries `referenceAction`.
**Addressing state.** `entity:` is a numeric id, a type name (the first of that type) or `Type#n` (the nth, from 1). `property:` is a path into that entity's JSON: a name (`Gold`), an index into a list (`Fitted[0]`), more names after it (`Fitted[0].Kind`), and on a list `count` for its length. A bare list name reads the whole list as JSON text. A hot table is addressed by its name (`[HotData(Name = "lanes")]`, default the struct's name) with `table:` instead of `entity:`; `row:` (from 0) picks a row and `property:` is then a column (without `row:`, a path into the whole table such as `[2].Heat` or `count`). `set:` writes one scalar (number, bool, string, `Fixed` like `12.5`, enum by number) of an entity property or of a table cell; lists and nested values cannot be set. `set:` changes state outside commands, so a running recording ends its segment and continues from the edited state, and replay reproduces the hashes.
From C#:
```csharp
using var cluster = TestCluster.Start(new ShopGame(), new TestOptions { Width = 320, Height = 200 });
cluster.Client.Click("buy").Step(2);
Assert.Equal("2", cluster.Client.Get("Shop", "Stock"));
Determinism.RunTwice(() => new ShopGame(), drive, 120).ThrowIfDifferent();
Determinism.SnapshotRestore(() => new ShopGame(), drive, 120, splitFrame: 50).ThrowIfDifferent();
Determinism.Replay(() => new ShopGame(), drive, 120).ThrowIfDifferent();
```
## 3. Look at it
```
onegame capture --out shots/main.png --size 1080p --scale 2 --after 30 --json
onegame capture --sequence shots/run --count 60 --every 2 # frame-0000.png ... + manifest.json
onegame lint-theme --json # WCAG AA contrast of the UI theme; exit 1 and the failing token pairs when one fails
```
Sizes: `360p 720p 1080p 1440p 4k deck phone tablet` or `WxH`. Open the PNG and look at it: a hash that matches proves repeatability, not that the picture is right. The JSON says which renderer made it (`cpu` or the GPU's name). `ONEGAME_GPU_VALIDATION=1` turns on the Vulkan validation layer.
## 4. Record and replay
```
onegame run --headless -- --scenario scenarios/click-button.yaml --record run.ogrp --artifacts .
onegame replay run.ogrp --json # {"ok":true,"identical":true,"ticks":2,"commands":2,"finalHash":"..."}
```
A replay that diverges exits 1 and names the first tick and the hashes. Recordings start from a snapshot, so a bug found in a live session can be replayed after the fix. A recording is a session: one segment per world the run used. A game that starts a new campaign (`Game.Reset(seed)`), a scenario `reset:`, `fixture:` or `snapshot:`, and a `set:` or `state_patch` (a change outside commands) each end a segment and start the next from the new state, seed included, so replay reproduces the run exactly; the replay summary reports `segments`, and a divergence names the `segment`. In code: `game.StartSessionRecording()`, `RecordingFile.Save(session, path)`, `game.Replay(session)`. A single-segment session is the plain recording file older tools read. A `set:` while commands are queued and a recording runs is rejected (step first). The scenario's own start state counts, so a scenario `fixture:`/`seed:`/`snapshot:` is recorded too. A replay of a recording with no ticks and no commands exits 1 (`identical:false`) because nothing was compared; `--record` itself fails with `record_empty` when a scenario ran ticks but the recording holds none.
Ids may contain `/` (the kit makes ids like `qty/track`) and, in a selector, any other character escaped with a backslash (`#a\.b`). A selector that does not parse is a failed step or expectation with its line and reason (`bad_args: Bad selector ...`), never a crash.
## 5. Drive a running game (MCP)
Debug builds listen on a local socket: `onegame run -- --live` (add `--realtime` to let it run on its own; otherwise it waits for `step`). Point an MCP client at the stdio bridge:
```json
{ "mcpServers": { "shop": { "command": "onegame", "args": ["mcp", "--project", "."] } } }
```
`--project` starts the game and bridges to it; `--socket ` attaches to a given one; with neither, the bridge attaches to the only live game. Tools: `info`, `hash`, `state_query`, `state_patch`, `commands_list`, `command_send`, `input_pointer`, `input_key`, `input_text`, `input_composition`, `clipboard_get`, `clipboard_set`, `input_nav`, `step`, `ui_tree`, `ui_query`, `ui_invoke`, `ui_click`, `ui_hit`, `screen_open`, `fixture_load`, `reset`, `snapshot_save`, `snapshot_load`, `capture_frame`, `capture_sequence`, `shaders`, `quit`. A typical session: `info` → `state_query` → `ui_click` → `step 10` → `ui_tree` → `capture_frame`. Errors come back as `{"error":{"code","message"}}` with a stable code. Release builds contain none of this.
## 6. Run it in a window
`onegame run` without `--headless` opens a real window on Wayland or X11, or Cocoa on a Mac (`--backend wayland|x11|cocoa`, `--display NAME`, `--present vsync|lowlatency|immediate`). Add `--live` and an agent can drive it exactly like a headless one while it presents:
```
onegame run -- --live /tmp/shop.sock --realtime --backend x11 --display :88
onegame mcp --socket /tmp/shop.sock # then: info, ui_click, input_text, clipboard_set, capture_frame {"presented":true}
```
`capture_frame` renders offscreen at any size; with `"presented": true` it returns the frame the window actually shows. `input_*` operations go through the same queue as the keyboard and mouse, so a text field cannot tell them apart; `input_composition` plays an input method's pre-edit text and `clipboard_get`/`clipboard_set` read and write the system clipboard (paste with `input_key` `v` + `Control`). With no display (a CI machine) run headless, or start `Xvfb :88 -noreset` for a window.
### Shader hot reload
In a development build, a game that registered its material shaders (`docs/interfaces/render2d.md`) picks up edits to the `.hlsl` file while it runs; no restart, and the tick and frame counts carry on. `capture_frame` and `shaders` check the files first, so edit, then capture, and the frame shows the change. The `shaders` result lists each shader with `state`:
```
{"shaders":[{"name":"glow","path":"Shaders/Glow.hlsl","state":"ok","reloads":2}],"changed":["glow"]}
{"shaders":[{"name":"glow","state":"incompatible","message":"The shader's interface changed, so it cannot replace the running one: Params.P changed array length from 2 to 3. Rebuild the project to use it."}]}
{"shaders":[{"name":"glow","state":"compile_error","problems":[{"file":"Shaders/Glow.hlsl","line":21,"column":48,"code":"OSH0200","message":"Expected ';' ..."}]}]}
```
On `incompatible` or `compile_error` the previous shader keeps drawing; fix the file and the next check recovers. A changed cbuffer layout, vertex input or binding needs a rebuild. Exports and Release builds have none of this.
## 7. Servers and clusters
A game with a dedicated server (`onegame new shop --server` adds `server/`, a small project that compiles the game's `Sim.cs`) runs as separate processes on a real network:
```
onegame export --server --project server --out ./srv # NativeAOT linux-x64 server, same JSON report as a game export
onegame run -- --connect udp://127.0.0.1:7777 --headless --live /tmp/p1.sock --realtime # a client on the server's world
```
A cluster file names the processes; `onegame cluster` starts, inspects and stops them, and every node has its own live socket:
```yaml
name: shop
nodes:
- { name: arena, role: server, exe: srv/shop-server-1.0.0-linux-x64/shopServer, listen: udp+ws, scopes: [{ id: 1, seed: 7 }] }
- { name: p1, role: client, exe: bin/shop, connect: arena, transport: udp, net: { latencyMs: 100, lossRate: 0.02 } }
- { name: p2, role: client, exe: bin/shop, connect: arena, transport: ws }
```
```
onegame cluster up --spec cluster.yaml --json # pids, sockets, bound ports
onegame cluster call p1 command_send '{"entity":"Counter","command":"Increment","args":["1"]}'
onegame cluster hashes --json # every replica against its server at the same tick; exit 1 on a mismatch or drift
onegame cluster test scenarios/two-players.yaml --spec cluster.yaml # up, run, down
onegame cluster down
onegame mcp --cluster .onegame-cluster # one MCP server for all nodes; every tool takes a node argument
```
In C#: `TestCluster.Launch(spec)` returns a `ProcessCluster` (`Call`, `WaitForTick`, `Hashes`, `Run(scenario)`, `Down`). A cluster scenario aims steps and expectations at nodes with `node:`; `wait`, `agree` and `drift` are its own verbs. `docs/interfaces/cluster.md` has the details.
## 8. Ship it
```
onegame export --target linux-x64 --steam --json # a folder and a .tar.gz under ./export, nothing else in them
onegame export --target win-x64 --json # a folder and a .zip
onegame export --target osx --json # Game.app (ad-hoc signed) and a .tar.gz, on an Apple-silicon Mac
```
The JSON lists every file with its size and hash, and the warnings worth acting on. Release builds only; see `docs/exporting.md`.
## Exit codes
0 ok, 1 a check failed (scenario, replay, tests), 2 bad command line, 3 build failed, 4 could not run, 5 the option needs a Debug build.
# Building a screen: the UI guide
This guide is for someone who has never seen onegame and has to build game screens from it. Read it top to bottom once; after that the API
documentation (XML docs on every public type) answers the details. Every code block marked `csharp` compiles, and most of them also run, in
`tests/OneGame.UI.Kit.Tests/GuideSnippetTests.cs`.
## 1. The picture in one page
- A screen is a **tree of `Element`s**. Each element has a *kind* (its type selector, such as `Button`), an optional *id*, *classes*, children,
inline style and event handlers. The tree lives in a `UiTree`.
- You **build** the tree with factory calls: `Ui.Column(...)`, `Ui.Label(...)` and the **component kit** `UiKit.Button(...)`,
`UiKit.Table(...)`... (project `OneGame.UI.Kit`). Components are *controlled*: they read the game's value through a lambda and report changes
through another lambda, so your own fields stay the single source of truth.
- **Looks** come from **style sheets** written in the CSS model: rules with selectors, a cascade, and theme **tokens** (`--kit-accent`).
Layout is **flexbox**. Every element is a flex container.
- Each frame, `ui.Frame()` runs the **bindings** (lambdas that copy game state into the tree), resolves styles, lays out, and returns a
`DisplayList`: draw commands in pixels that the 2D renderer replays. A frame in which nothing changed does no work and allocates nothing.
- **Input** goes in as `UiEvent`s through `ui.Dispatch(...)`: pointer, keys, text, d-pad, confirm and back. Focus moves with the keyboard and
the gamepad by itself.
- The harness and your tests see the same tree: `ui.Find("id")`, `ui.Query(".class")`, `ui.DumpJson()`.
## 2. Setting up
You need a text measurer (fonts), an art atlas for SVG art, a `UiTree`, and a `KitHost` that installs the kit's theme.
```csharp setup
var fonts = new FontLibrary();
fonts.Add(File.ReadAllBytes("fonts/Regular.ttf"));
fonts.Add(File.ReadAllBytes("fonts/Bold.ttf"));
var measurer = new FontTextMeasurer(fonts);
var atlas = new ArtAtlas();
foreach (var art in KitArt.Assets)
{
atlas.AddSvg(art.Id, art.Svg, new NineSlice(art.Slice, art.Slice, art.Slice, art.Slice));
}
var ui = new UiTree(measurer);
ui.SetViewport(1920, 1080, scale: 1.5f); // pixels, and pixels per UI unit
var host = new KitHost(ui); // adds the default theme and the tooltip layer
```
Tests and headless runs use `new UiTree()`, whose `MonospaceTextMeasurer` needs no font: every character is 0.6 em wide and a line is 1.2 em
tall, so expected boxes are easy to compute by hand.
**Units.** `Length` is pixels (`12` converts implicitly), `Length.Ui(12)` (UI units: pixels times the viewport's scale, so the layout follows
the player's UI-scale setting), `Length.Percent(50)` or `Length.Auto`. Write sizes in UI units, hairlines in pixels.
**The per-frame loop** belongs to the game host. Order matters: advance the kit's clock, send input, build the frame, hand the list to the
renderer.
```csharp type
public static class GameLoop
{
public static DisplayList OneFrame(KitHost host, float seconds, IEnumerable input)
{
host.Advance(seconds); // tooltips wait for a delay; the UI never reads a clock itself
foreach (var e in input)
{
host.Dispatch(e); // like ui.Dispatch, and it tells input glyphs which device is in use
}
return host.Tree.Frame(); // cheap and allocation-free when nothing changed
}
}
```
The renderer draws the list with `renderer.Ui.Draw(list, textQuadBuilder, glyphAtlas, artAtlas)` (see `docs/interfaces/render2d.md`).
Nothing is drawn unless `list.Version` changed; you can skip re-recording when it did not.
### Setting up inside a game (the normal way)
A game made with `onegame new` does not write that loop. Its `GameDefinition` declares screens, and the host owns the `UiTree`, translates platform input
(mouse, keys, text, and the gamepad: South = confirm, East = back, d-pad and shoulder buttons = navigate) into `UiEvent`s, and runs the frames. You plug the kit in
in four places:
```csharp type
public sealed class MarketGame : GameDefinition
{
private KitHost? _kit;
private bool _clockRunning;
public override string Name => "Market";
// A game with entities calls the generated EntityTypes.Register(registry) here.
public override void RegisterEntities(EntityRegistry registry)
{
}
public override void Setup(World world)
{
}
// 1. Art: the kit's SVGs (sort arrows, chevrons, frame, glyphs) go into the atlas the renderer draws from.
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));
}
}
// 2. Theme: the host calls this once, before the first screen is built.
public override void Style(UiTree ui) => _kit = new KitHost(ui);
public override void DefineScreens(ScreenSet screens) =>
screens.Add("main", game =>
{
// 3. Clock: the kit's tooltip delay counts game ticks, so it is deterministic and also runs in headless tests.
if (!_clockRunning)
{
_clockRunning = true;
game.TickCompleted += _ => _kit!.Advance(1f / GameInstance.TickRate);
}
var screen = Ui.Column(
UiKit.Button("Buy", () => { }, "buy"),
UiKit.Slider(() => 1, _ => { }, 1, 99, 1, "qty")).Padding(16).Gap(8);
// 4. The host replaces everything under the root when a screen opens, so the tooltip layer must live inside the screen.
return _kit!.AttachTo(screen);
});
}
```
Fonts are the `fonts/` folder next to the game (see `docs/driving-a-game.md`). Inside a screen, `game.Ui` is the tree (`game.Ui.Find("buy")`), and game state is the
`World`: bind labels to `game.World` entities and send commands from button lambdas; never write synced state from a view. Styles for the whole game: add more sheets in `Style`
after the `KitHost` line, so yours win ties. After a screen opens, call `FocusHelpers.FocusDefault(game.Ui, screen)` if a controller player needs a starting focus.
## 3. The state your screens read
The guide's examples read and write one small game object. Your game has its own; the point is that **components never own game state**.
```csharp type
public sealed record Good(string Name, int Price, int Stock);
public sealed class GuideGame
{
public int Gold = 500;
public int Quantity = 1;
public int Volume = 70;
public bool Sound = true;
public string Filter = "";
public string Difficulty = "normal";
public int Tab;
public bool ConfirmOpen;
public Good? Selected;
public List Goods { get; } = [new("Grain", 12, 40), new("Iron", 30, 8), new("Silk", 95, 3), new("Salt", 7, 120)];
}
```
The snippets below run with `ui` (a `UiTree`), `host` (a `KitHost` for it) and `game` (a `GuideGame`) already defined.
## 4. The builder
### Elements
`Ui` has the basic factories. They return `Element`, and `Element`'s methods return the element, so calls chain.
```csharp
var panel = Ui.Column(
Ui.Label("Port of Aldeen"),
Ui.Row(Ui.Label("Gold"), Ui.Spacer(), Ui.Label("500")).Gap(8),
Ui.Box().Height(1).Background(Color.Hex("#444"))) // a divider
.Named("port") // the id: unique, and what tests find it by
.Class("panel dark") // classes: what style rules select
.Padding(12).Gap(6);
ui.Root.Add(panel);
```
- `Ui.Box`, `Ui.Row`, `Ui.Column` hold children; `Ui.Label` shows text; `Ui.Image("asset-id", w, h)` shows an image or SVG by atlas id;
`Ui.Spacer()` grows to fill; `Ui.Each(items, build)` builds children from a list.
- An element has **one parent**. Adding it twice throws `UiTreeException`; `Remove` it first.
- The first child of `ui.Root` is your screen; a screen fills the viewport with `.Style(s => s.Anchor(AnchorPoint.Fill))`.
- Quick style helpers (`Width`, `Padding`, `Gap`, `Grow`, `Background`, `Radius`, `Border`, ...) are shorthand for `Style(s => ...)`. Anything
else goes through `Style` (section 6).
### Bindings: copying game state into the tree
A binding is a lambda the UI runs **every frame** before styles are resolved. It must be cheap and must not allocate when nothing changed.
```csharp
var gold = Ui.Label("")
.Named("gold")
.BindText(() => game.Gold, g => "Gold " + g); // g => string runs only when the number changes
var sound = Ui.Label("Sound on").BindVisible(() => game.Sound);
var buy = UiKit.Button("Buy", () => game.Gold -= 10).BindEnabled(() => game.Gold >= 10);
var low = Ui.Label("Low on gold").BindClass("warning", () => game.Gold < 50);
ui.Root.Add(Ui.Column(gold, sound, buy, low));
```
| Binding | Does |
|---|---|
| `BindText(Func)` | sets the text; return a cached string, or the frame allocates |
| `BindText(Func, Func)` | rebuilds the text only when the value changes (`T` implements `IEquatable`: `int`, `bool`, `string`...) |
| `BindClass(name, Func)`, `BindVisible`, `BindEnabled` | toggle a class, show or hide, enable or disable |
| `BindStyle(Action)` | re-apply inline style from game values (unchanged values cost nothing) |
| `BindChildren(Func>, Func)` / `Ui.Each` | children follow a list; an item keeps its element while it stays in the list |
| `OnUpdate(Action)` | anything else: runs against the element every frame |
**Allocation rule.** A frame where nothing changed must allocate zero bytes (the acceptance test measures it). In your own bindings: do not
build strings, closures or lists per frame. One C# trap: a lambda that *captures a local variable* makes the compiler allocate its closure at
the start of the enclosing block, even if an early `return` comes first. Keep capturing lambdas in a separate method that only runs on change.
### Events
```csharp
var save = Ui.Box(Ui.Label("Save"))
.Focusable()
.OnClick(() => game.Gold++) // pointer click, Enter, Space or the confirm button
.OnPointerEnter(() => game.Sound = true)
.OnFocus(() => game.Sound = false)
.OnCancel(() => game.Gold = 0); // Escape or the back button, while focus is inside
ui.Root.Add(save);
```
`On(UiEventKind, handler)` is the general form: the handler gets the element and the event and returns `true` when it handled it, which stops the
event bubbling to parents. Events go to the element under the pointer, or to the focused element, and **bubble upward**.
### Replacing and updating the tree
Prefer bindings over rebuilding. When a whole screen changes, `Remove` the old one and `Add` the new one; elements are cheap. Never keep an
element in two places.
## 5. Looking things up: ids, roles, queries
```csharp
var found = ui.Find("port"); // by id; null when absent
var labels = ui.Query("Label"); // by selector, in document order
var warnings = ui.Query(".warning");
var rows = ui.Query(".kit-row.selected");
string json = ui.DumpJson(); // the whole tree; DumpJson(element) for one subtree
Console.WriteLine($"{found?.Id} {labels.Count} {warnings.Count} {rows.Count} {json.Length}");
```
Every kit component sets `Element.Role` (`button`, `slider`, `table`, `row`, `tab`, `dialog`...) and `Element.Value` (the number a slider
shows, the title of the selected tab, `true` on a checked box), and gives its parts ids derived from the id you pass (`qty/track`, `goods/row/3`).
The JSON dump prints `kind`, `id`, `role`, `value`, `classes`, `states` (`hover`, `focus`, `disabled`...), `text`, local and screen boxes,
scroll extents and non-default computed style, so a scenario can locate and check anything. **Give every component you will test an id.**
## 6. Styles: the CSS model
### Style sheets and selectors
```csharp
var sheet = new StyleSheet()
.Rule(":root", s => s.FontSize(Length.Ui(14)).TextColor(Color.Hex("#e8e8e8")))
.Rule(".panel", s => s.Background(Color.Hex("#23262d")).Radius(Length.Ui(6)))
.Rule(".panel.dark", s => s.Background(Color.Hex("#14161a")))
.Rule("Button:hover, .clickable:hover", s => s.Opacity(0.9f))
.Rule(".panel > Label", s => s.FontWeight(700));
ui.AddStyleSheet(sheet);
```
Selectors: a type (the element's kind, `Button`), `#id`, `.class`, `*`, and the pseudo-classes `:hover :focus :active :disabled :root :first-child
:last-child :nth-child(odd|even|N)`, with the descendant (space) and child (`>`) combinators and comma lists. Specificity follows CSS (ids, then classes
and pseudo-classes, then types); ties go to the later rule, and later sheets win over earlier ones. **Inline style** (`Style(...)`, `.Width(...)`)
beats every sheet rule.
Selectors with combinators make state changes restyle subtrees, so prefer single classes on hot paths such as rows.
### Properties
`StyleBuilder` has a method per property. The ones you will use most:
| Group | Methods |
|---|---|
| Box | `Width Height MinWidth MaxWidth MinHeight MaxHeight Margin Padding BorderWidth Border BoxSizing` (border-box by default) |
| Flex | `Direction Wrap JustifyContent AlignItems AlignSelf AlignContent Grow Shrink Basis Flex Gap RowGap ColumnGap Order` |
| Position | `Position(Relative/Absolute/Fixed) Left Top Right Bottom Inset Anchor ZIndex Overflow Translate Scale Rotate` |
| Paint | `Background BackgroundImage BackgroundSlice BackgroundTint BackgroundRepeat BackgroundSize BackgroundPosition BorderColor Radius Opacity Shadow Outline Visibility` |
| Text | `TextColor FontFamily FontSize FontWeight FontStyle LineHeight LetterSpacing TextAlign WhiteSpace` |
| Input | `PointerEvents Cursor` |
`Set("css-name", value)` sets any property by name. `Display(DisplayMode.None)` or `IsVisible = false` removes an element from layout and input.
Text properties are inherited; everything else is not.
### Background images: stretch, tile, place
`BackgroundImage("id")` draws a raster image or SVG registered under that id (`DefineArt`). By default it is stretched over the whole border box, as an image element is. Repeat, size and position follow the CSS model
(Backgrounds 3, sections 3.3 to 3.10), with these differences: the positioning area is the border box, and the default size is `fill`.
```csharp
// A scanline pattern from one small tile, instead of dozens of stacked bands: the tile is 2x4 UI units and repeats over the whole panel.
ui.Root.Add(Ui.Box().Style(s => s
.Size(Length.Percent(100), Length.Percent(100))
.BackgroundImage("scanlines").BackgroundRepeat(BackgroundRepeat.Repeat).BackgroundSize(Length.Ui(2), Length.Ui(4))));
// A poster that covers its box, centred; and a badge placed in the bottom-right corner, drawn once.
ui.Root.Add(Ui.Box().Style(s => s.Size(Length.Ui(300), Length.Ui(160))
.BackgroundImage("poster").BackgroundSize(BackgroundSizing.Cover).BackgroundPosition(Length.Percent(50), Length.Percent(50))));
ui.Root.Add(Ui.Box().Style(s => s.Size(Length.Ui(300), Length.Ui(160))
.BackgroundImage("badge").BackgroundSize(Length.Ui(32), Length.Ui(32)).BackgroundPosition(Length.Percent(100), Length.Percent(100))));
```
| Property | Values |
|---|---|
| `background-repeat` | `no-repeat` (default), `repeat`, `repeat-x`, `repeat-y` |
| `background-size` | `fill` (default: the box), `auto` (natural size times the UI scale), `cover`, `contain`; or `BackgroundSize(width, height)` with lengths (an `auto` side keeps the aspect ratio; `%` is of the box) |
| `background-position-x`, `-y` | a length from the box's top-left corner, or `%`: that fraction of the free space, so 50% centres and 100% right-aligns |
- A repeated tile is rounded to whole pixels, so the pattern stays crisp and seamless at any UI scale. An SVG tile is rasterized at exactly that size; a raster tile is scaled by the sampler (use a size equal to the image's pixels times the scale for one-to-one).
- Tiling costs one draw instruction however many tiles it covers: the display list holds a tile size and an origin, and the renderer repeats the tile in its shader. `background-tint`, rounded corners and clips apply to the pattern.
- Sizing keywords and tiling need the art's natural size, which the game host supplies (`UiTree.ArtSizes`; `GameInstance` sets it). In a bare `UiTree` set `ui.ArtSizes = new ArtAtlasSizes(art)`, or every background stays stretched.
- `BackgroundSlice` (nine-slice) and tiling do not combine: a tiled or sized image ignores the slice.
### Large images
A raster or SVG whose side is over 512 pixels gets a texture of its own instead of a place on the shared 1024-pixel atlas pages, so a painted full-panel backdrop is drawn at its full resolution at any UI scale.
An SVG in a box bigger than 512 pixels is rasterized at the box's own size, not at its natural size times the scale and then stretched, so a vector backdrop stays sharp at 1080p and UI scale 1.5.
Limits (`ArtLimits`): 8192 pixels and 16 megapixels per raster, and 256 MiB of such textures at once; past that the least recently drawn ones are released and rasterized again when needed (`docs/interfaces/atlas.md`).
Stay under them by registering art at the size you draw it: a 2048x1152 backdrop is 9 MiB on the GPU.
### Tokens: values the theme can change
A token is a named value defined on an element (usually `:root`) and read by that element's descendants:
```csharp
var theme = new StyleSheet()
.Rule(":root", s => s.Token("--gold", Color.Hex("#d9a441")).Token("--gap", Length.Ui(8)))
.Rule(".price", s => s.Var("color", "--gold"))
.Rule(".stack", s => s.Var("row-gap", "--gap").Var("column-gap", "--gap"));
ui.AddStyleSheet(theme);
ui.Root.Add(Ui.Column(Ui.Label("12 gold").Class("price")).Class("stack"));
```
`Var(property, token, fallback)` uses the nearest definition up the tree and the fallback when none exists. A token of the wrong type for the
property is ignored. Redefining a token (a later rule, or on a subtree) restyles everything that reads it **without rebuilding any element**; this is
how the kit's themes work.
## 7. Layout recipes
Every element is a flex container (row by default, `Column()` for a column). There is no block or inline flow, no floats and no grid.
```csharp
// Centered on the screen, whatever the size.
var modal = Ui.Box(Ui.Label("Hello")).Padding(20).Anchor(AnchorPoint.Center, toScreen: true);
// Header left, buttons right, a gap between: Row + Spacer.
var bar = Ui.Row(Ui.Label("Title"), Ui.Spacer(), UiKit.Button("Close", () => { })).Align(Align.Center).Gap(8).Padding(8);
// Two columns: a flexible list and a fixed detail panel.
var split = Ui.Row(Ui.Column().Grow(1).MinWidth(0), Ui.Column().Width(Length.Ui(300))).Gap(12);
// Wrapping tiles with equal gaps.
var tiles = Ui.Box(Ui.Label("a"), Ui.Label("b"), Ui.Label("c")).Wrap().Gap(Length.Ui(6));
// Pinned to a corner of the screen, outside the flow.
var hud = Ui.Label("FPS 60").Anchor(AnchorPoint.TopRight, Length.Ui(8), Length.Ui(8), toScreen: true);
// A scrolling region: give it a bounded height and an overflow.
var scroller = Ui.Column(Ui.Label("one"), Ui.Label("two")).Height(Length.Ui(120)).Overflow(OverflowMode.Auto);
ui.Root.With(Ui.Column(modal, bar, split, tiles, hud, scroller));
```
Rules of thumb:
- **Fill the rest:** `Grow(1)` on the one child that should take the free space; on a flex child that must be allowed to shrink below its content
(anything that holds a table, a list or text that can clip), add `MinWidth(0)` or `MinHeight(0)`.
- **Equal columns:** `Style(s => s.Flex(1, 1, Length.Px(0)))` on each child.
- **Alignment:** `Justify` is along the main axis, `Align` across it (`Align.Center`, `Start`, `End`, `Stretch`, `SpaceBetween`...).
- **Overlays** (tooltips, dialogs, menus): `Position(Fixed)` or `Anchor(..., toScreen: true)` plus a `ZIndex`. Absolute children sit against the
parent's padding box.
- **Order of painting and hit testing** is document order within a parent, then `z-index`.
- Text wraps at the width the parent gives it; `WhiteSpace(WhiteSpace.NoWrap)` stops that, and `Overflow(Hidden)` clips it.
## 8. The component kit
All components are in namespace `OneGame.UI.Kit` and created by `UiKit.*`. Each takes an optional `id`, which also prefixes the ids of its parts.
Every component is themeable (section 11), reachable by keyboard and gamepad (section 9), and allocates nothing on a frame without change.
### Button
```csharp
var save = UiKit.Button("Save game", () => game.Gold += 1, id: "save");
var danger = UiKit.Button("Sell everything", () => game.Gold = 0, "sell", ButtonKind.Danger);
var price = UiKit.Button(() => "Buy for " + game.Quantity, () => game.Gold -= game.Quantity); // a label that follows a lambda
ui.Root.Add(Ui.Row(save, danger, price).Gap(8));
```
Kinds: `Normal`, `Primary` (accent), `Danger`, `Flat`. Disable with `button.IsEnabled = false` or `.BindEnabled(...)`; a disabled button never calls back.
Click, Enter, Space and the confirm button all call the lambda.
A toggle button is a button that is switched on or off, like a button with `aria-pressed`: a speed switch, a bold-text toggle, a filter chip. The kit draws the switched-on state, so the game has no rule of its own for it:
```csharp
var fastOn = false;
var fast = UiKit.ToggleButton("FAST", () => fastOn, v => fastOn = v, id: "fast"); // clicking, Enter, Space and confirm flip it
ui.Root.Add(Ui.Row(fast, UiKit.Button("Pause", () => { }, "pause")).Gap(8));
```
While on, the button has `Element.Pressed == true`, the class `pressed`, an accent-tinted fill and an accent outline (in every kind, so a primary toggle that is on does not look like the main action). The inspect JSON has `"pressed": true` (a plain button has no `pressed` field), and scenarios check it with `ui: { id: fast, pressed: true }`.
The states are told apart by more than color (section 11, "States and legibility"): hover and pressed change the fill, focus adds a ring outside the button, and a disabled button of any kind is flat and dim, with no accent or danger fill, so a disabled primary never looks like the main action.
### Checkbox, radio group, slider, stepper, progress bar
```csharp
var sound = UiKit.Toggle("Sound", () => game.Sound, v => game.Sound = v, "sound");
var level = UiKit.RadioGroup(
[new Choice("easy", "Easy"), new Choice("normal", "Normal"), new Choice("hard", "Hard")],
() => game.Difficulty,
v => game.Difficulty = v,
"difficulty");
var volume = UiKit.Slider(() => game.Volume, v => game.Volume = v, 0, 100, 5, "volume"); // int, or float with a float range
var quantity = UiKit.NumberStepper(() => game.Quantity, v => game.Quantity = v, 1, 99, 1, "quantity");
var loading = UiKit.Progress(() => game.Gold, max: 1000, id: "gold-bar");
ui.Root.Add(Ui.Column(sound, level, volume, quantity, loading).Gap(8).Padding(12));
```
- **Toggle**: class `checked`, `Value` `true`/`false`.
- **RadioGroup**: options are `Choice(value, label)`. Only the selected option takes Tab focus; arrow keys and the d-pad select the
neighbor; `horizontal: true` lays it out in a row. Parts: `id/0`, `id/1`...
- **Slider**: drag, click the track, or with focus: Left/Right (arrow keys and d-pad) one step, PageUp/PageDown ten, Home/End the ends. Up and
Down move focus away. Parts `id/track`, `id/fill`, `id/thumb`, `id/value`. `Value` is the formatted number; pass `format:` to change it.
- **NumberStepper**: `-` and `+` buttons (parts `id/minus`, `id/plus`) and keys like the slider. It clamps and disables a button at its limit.
- **Progress**: `Value` is the fraction (`0.25`); part `id/fill`.
### Card and nine-slice frame
```csharp
var detail = UiKit.Frame(
KitArt.Frame, // an SVG registered in the art atlas, with nine-slice insets
KitArt.FrameSlice, // the unstretched border, in UI units
UiKit.Card(
"Selected",
Ui.Label("").BindText(() => game.Selected?.Name ?? "Nothing selected"),
Ui.Label("").BindText(() => game.Selected?.Price ?? 0, p => "Price " + p)));
ui.Root.Add(detail.Width(Length.Ui(260)));
```
`Card(title, content...)` is a raised panel with an optional heading. `Frame(artId, slice, content...)` stretches the art so its corners keep their size and
pads the content by the slice. Register your own art with `atlas.AddSvg(id, svgBytes, new NineSlice(l, t, r, b))` (insets in the SVG's own pixels at scale 1).
`KitArt.Assets` also holds the sort arrows, the drop-down chevron, the key cap and the input glyphs; register all of it once (section 2).
### Tabs
```csharp
var tabs = UiKit.Tabs(
[new TabPage("Market", Ui.Label("Prices go here")), new TabPage("Cargo", Ui.Label("Your hold")), new TabPage("Ships", Ui.Label("Your fleet"))],
() => game.Tab,
i => game.Tab = i,
"tabs");
ui.Root.Add(tabs);
```
Pages stay in the tree while hidden, so their state survives. Parts: `tabs/tab/0`..., `tabs/panel/0`...; `Value` of `tabs` is the selected title. Only the selected tab takes
Tab focus; Left/Right switch tabs (and move focus); **Ctrl+PageUp/PageDown** switch from anywhere inside.
### Scroll view
```csharp
var entries = Ui.Column(Enumerable.Range(0, 50).Select(i => Ui.Label("Entry " + i)).ToArray());
var view = UiKit.ScrollView(entries, id: "log").Height(Length.Ui(200));
ui.Root.Add(view);
```
The wheel, the thumb (drag it), a click on the bar's track and focus moving to a hidden child all scroll. Bars overlay the content, so they take no layout space and only
appear when needed. `horizontal: true` adds a horizontal bar. Give the view a bounded height (a fixed one, or `Grow(1)` inside a column with a bounded height).
### Lists and tables: thousands of rows
`UiKit.VirtualList` and `UiKit.Table` create row elements only for the rows in view and recycle them while scrolling, so 5,000 or 500,000 items cost the same per frame.
**All rows have the same height.** The lists read the items through a lambda every frame: return the **same list instance** while nothing changed, and a **new
instance** (or bump the optional `version:` counter) when the content changed.
```csharp
var table = UiKit.Table(
[
new TableColumn("Name", g => g.Name, ColumnWidth.Flex(2)),
new TableColumn("Price", g => g.Price.ToString(), ColumnWidth.Fixed(Length.Ui(90)), alignEnd: true),
new TableColumn("Stock", g => g.Stock.ToString(), ColumnWidth.Fixed(Length.Ui(80)), alignEnd: true),
],
() => game.Goods,
rowHeight: Length.Ui(28),
onSort: (column, descending) =>
{
// The table shows the sort arrow and asks you to order your list; it then rebinds the rows and keeps the same item selected.
game.Goods.Sort((a, b) => (column == 1 ? a.Price.CompareTo(b.Price) : column == 2 ? a.Stock.CompareTo(b.Stock) : string.CompareOrdinal(a.Name, b.Name)) * (descending ? -1 : 1));
},
onSelect: (good, index) => game.Selected = good,
onActivate: (good, index) => game.Gold -= good.Price,
id: "goods");
ui.Root.Add(Ui.Column(table).Height(Length.Ui(300)));
```
- **Sorting.** Header cells of sortable columns are focusable; click or Enter calls `onSort(column, descending)`: first ascending, then flipping. The header gets
`sorted` and `asc`/`desc`. Sort *in place*, or build a new list; both work because the table refreshes after the callback.
- **Selection.** Click a row, or focus the table body and use Up/Down/PageUp/PageDown/Home/End (arrow keys or d-pad). `table.Body.SelectedIndex`, `SelectedItem` and
`Select(index)` read and set it. Enter, Space or the confirm button calls `onActivate`. At the first or last row Up or Down is not used, so focus moves on to the
neighbor: that is how a gamepad player gets out of a table.
- **Changing items.** After replacing the list (a filter), the selection follows its item to the new index, or clears if the item is gone. If you change an item's fields in
place, call `table.Body.Invalidate()` or bump `version:`.
- **Custom cells** (icons, bars, buttons): `new TableColumn(title, createCell: () => ..., bindCell: (cell, good) => ..., width)`. `createCell` runs once per recycled row,
`bindCell` runs when a row gets a different item and never on a quiet frame.
- **Ids:** `goods` (the table), `goods/header/0`..., `goods/body` (focusable, role `listbox`), `goods/row/N` (the item index, role `row`, class `selected`, `odd`).
`table.Body.RealizedRowCount` is how many row elements exist.
`UiKit.TextList(items, text, rowHeight, onSelect, onActivate, id)` is the one-column version; `UiKit.VirtualList(items, createRow, bindRow, rowHeight, ...)` takes any row element.
### Drop-down
```csharp
var sort = UiKit.Dropdown(
[new Choice("easy", "Easy"), new Choice("normal", "Normal"), new Choice("hard", "Hard")],
() => game.Difficulty,
v => game.Difficulty = v,
"difficulty-select",
placeholder: "Choose");
ui.Root.Add(Ui.Column(sort, Ui.Label("below")).Padding(20));
```
Enter, Space, confirm or a click opens the list and focuses the chosen option; Up/Down move; the open list **traps focus**; Enter or a click chooses; Escape or the
back button closes without choosing; focus returns to the button. The list opens upward when there is no room below. Parts: `id/head`, `id/popup`, `id/option/N`; the root
has the class `open` while open.
### Dialog
```csharp
var confirm = UiKit.ConfirmDialog(
"Sell everything?",
"This cannot be undone.",
onConfirm: () => game.Gold += 100,
onCancel: () => { },
confirmText: "Sell",
cancelText: "Keep",
id: "confirm",
danger: true); // focus starts on Cancel
var open = UiKit.Button("Sell all", () => confirm.Open(), "sell-all");
ui.Root.With(Ui.Column(open), confirm); // add the dialog last: it paints over everything
```
While a dialog is open the pointer cannot reach what is behind it, **focus, Tab and the d-pad stay inside it**, Escape and the back button close it (and call `onCancel`),
and focus returns to what had it before. `dialog.Open()` and `Close()` from code; `dialog.BindOpen(() => game.ConfirmOpen)` to follow a flag. For your own content
use `UiKit.Dialog(title, body, id, onCancel)` and `dialog.AddButton(text, onClick, kind, isDefault)`. Mark the element to focus first with `.AsDefaultFocus()`.
### Text input
```csharp
var filter = UiKit.TextInput(
get: () => game.Filter,
set: v => game.Filter = v,
placeholder: "Filter goods",
maxLength: 40,
onSubmit: text => game.Gold += text.Length,
id: "filter");
var password = UiKit.TextInput(placeholder: "Password", password: true, id: "pw");
var digits = UiKit.TextInput(() => game.Quantity.ToString(), v => game.Quantity = int.Parse(v), validate: t => t.All(char.IsAsciiDigit) && t.Length is > 0 and < 4, id: "digits");
ui.Root.Add(Ui.Column(filter, password, digits).Gap(8).Padding(12));
```
- The field reads the text through `get` every frame and reports each edit through `set`. If your `set` refuses the edit by not storing it, the field shows the old text on the
next frame. Without `get` the field keeps its own text (`field.FieldText`, `field.SetText(...)`).
- Editing works on **grapheme clusters**: a letter with its accents or an emoji sequence is one step for the arrow keys, Backspace and Delete. Arrow keys move through the
text **as it looks on screen**, also where left-to-right and right-to-left text mix. Shift extends the selection; Home and End jump; Ctrl or Cmd with A, C, X and V select all,
copy, cut and paste. Enter calls `onSubmit`. Up, Down, Tab and the d-pad leave the field.
- `password: true` shows bullets, never copies, and keeps the text out of `Value` and the dump.
- Text comes in as `UiEvent.TextInput` (committed text, input-method results included). An input method's uncommitted text arrives as `UiEvent.Composition`;
the field shows it at the caret (`field.Preedit`) until the commit or a clear. A field sets `AcceptsText`, so the window system's input method is on while it has focus, and keeps
`CaretArea` (`field.CaretScreenRect`) current so the candidate window follows the caret.
- **Clipboard:** in a windowed game the game instance sets `UiKit.DefaultClipboard` to the system clipboard, so copy, cut and paste reach other programs. Headless runs, and windowed runs on a
compositor without a seat, keep it in memory. Pass `clipboard:` to give one field its own `IClipboard`.
- `validate` receives the text an edit would produce; return false to reject it. `maxLength` counts grapheme clusters.
### Tooltips
```csharp
var buy = host.Tooltip(UiKit.Button("Buy", () => game.Gold -= 5, "buy"), "Buy one unit at the market price");
var qty = host.Tooltip(UiKit.NumberStepper(() => game.Quantity, v => game.Quantity = v, 1, 99, 1, "qty"), () => "Quantity: " + game.Quantity);
ui.Root.Add(Ui.Row(buy, qty).Gap(8).Padding(40));
```
After `host.TooltipDelay` (half a second) of pointer hover or keyboard focus, a box appears below the element (above it when there is no room), clamped to the screen, and follows
it if it moves. Time comes from `host.Advance(seconds)` in your loop; tests advance it by hand, which keeps them deterministic. Any element can have one; the text may be a lambda.
### Input glyphs: "press (A)"
```csharp
host.Glyphs.Bind("confirm", keyboard: "Enter", mouse: "left", gamepad: "a")
.Bind("back", keyboard: "Escape", gamepad: "b")
.Bind("next-tab", keyboard: "PageDown", gamepad: "rb");
var prompt = Ui.Row(UiKit.InputGlyph(host.Glyphs, "confirm", "confirm-glyph"), Ui.Label("Buy"), UiKit.InputGlyph(host.Glyphs, "back"), Ui.Label("Back")).Gap(6);
ui.Root.Add(prompt);
```
The glyph shows the control of the device used last: a key cap with the key's name, a mouse with the button lit, or a gamepad button picture. Send input through `host.Dispatch`
(or call `host.Glyphs.Observe(e)`) and the glyphs change by themselves; `host.Glyphs.SetDevice(...)` sets it directly. Names: keyboard = DOM key names; mouse = `left right middle move`;
gamepad = `a b x y lb rb lt rt ls rs dpad dpad-up dpad-down dpad-left dpad-right start select`. An unbound action hides the glyph (class `unbound`). The pictures are in `KitArt.Assets`.
### Focus helpers
```csharp
var row = FocusHelpers.FocusGroup(Ui.Row(UiKit.Button("A", () => { }, "a"), UiKit.Button("B", () => { }, "b"), UiKit.Button("C", () => { }, "c")), horizontal: true, wrap: true);
var start = UiKit.Button("Start", () => { }, "start").AsDefaultFocus();
ui.Root.With(Ui.Column(start, row));
FocusHelpers.FocusDefault(ui, ui.Root); // call when a screen opens, so a controller player never starts with nothing focused
```
- `FocusGroup` keeps the arrow keys and d-pad inside a row or column of controls until its ends (or wraps).
- `.AsDefaultFocus()` marks what `FocusDefault` focuses first; otherwise it is the first focusable element.
- Every focusable component has the class `kit-focusable`, and the theme draws the focus ring (an outline, so it takes no space) on `.kit-focusable:focus`.
## 9. Focus, keyboard and controller navigation
The UI owns **one focused element**. Input arrives as `UiEvent`s; the platform layer or your test creates them with factories:
| You send | Means |
|---|---|
| `UiEvent.PointerMove/PointerDown/PointerUp(x, y)` | mouse or touch, in screen pixels; a press focuses the nearest focusable element |
| `UiEvent.Wheel(x, y, dx, dy)` | scroll |
| `UiEvent.KeyDown("Enter")`, `KeyUp`, with `KeyModifiers` | keyboard, DOM key names |
| `UiEvent.TextInput("é")` | committed text |
| `UiEvent.Navigate(NavDirection.Up)` | d-pad or stick step; bumpers send `Next`/`Previous` |
| `UiEvent.Activate()` | the confirm button (clicks the focused element) |
| `UiEvent.Cancel()` | the back button (delivered up from the focused element) |
What happens by default: **Tab / Shift+Tab** move through focusable elements in document order (`TabIndex` > 0 first). **Arrow keys and the d-pad** move focus to the nearest
focusable element in that direction, preferring one wholly beyond the current element and lined up with it. Enter and Space click the focused element. Escape is a Cancel.
A component may use a key itself first (a slider takes Left/Right, a text field takes the arrows) and returns `true`; unused keys fall back to moving focus. So a
gamepad player (the host sends South as confirm, East as back, the d-pad and the shoulder buttons as navigate) moves with the d-pad across the screen, adjusts sliders with Left/Right, and leaves a table with Up at the first row.
- `ui.Focus(element)` focuses directly (false when the element cannot take focus); `ui.Focus(null)` clears it. `ui.RequestFocus(element)` does it at the end of the next update,
which is what a binding must use. `ui.Focused` is the current element.
- Focus **traps**: `element.TrapFocus()` keeps Tab and the d-pad inside it while it is shown (dialogs and drop-down lists do this).
- A disabled or hidden element is skipped. Focusing an element scrolls scroll views so it is visible.
- Dragging (sliders, scroll thumbs, text selection) uses **pointer capture**: `ui.CapturePointer(element)` from a `PointerDown` handler sends moves and the release to that
element until the button is released.
- A `Click` carries `Button` 0 for the pointer and -1 when it came from Enter, Space or the confirm button.
Design for the controller first: every action reachable by d-pad and confirm, back always leaves, and a default focus on every screen.
## 10. Writing your own component
A component is a function that returns an `Element`. Follow the kit's pattern so it behaves like the rest:
```csharp type
public static class MyComponents
{
/// A price tag: shows an amount, turns red when it exceeds the player's gold.
public static Element PriceTag(Func amount, Func gold, string? id = null)
{
var tag = new Element("PriceTag") { Role = "price", Id = id };
tag.Class("price-tag");
var label = Ui.Label("");
tag.Add(label);
tag.OnUpdate(e =>
{
var price = amount();
e.SetClass("unaffordable", price > gold());
e.Value = label.Text;
});
label.BindText(amount, a => a + " gold"); // rebuilds the string only when the amount changes
return tag;
}
}
```
Give it a `Kind` (the type selector), a `Role` and `Value`, classes for its states, ids for its parts, take game values as lambdas, and put its look in a style sheet that reads tokens. Ship
the sheet with the component (`public static StyleSheet Styles(...)`) so a game can add it, then add its own rules after it. That is all a **shared UI library** is: a package of such
functions plus a sheet, themed by tokens and never forked.
## 11. Theming a game
The kit's look is made of tokens; `KitTokens` lists them with their names (`--kit-bg`, `--kit-panel`, `--kit-accent`, `--kit-text`, `--kit-radius`, `--kit-font-size`...).
```csharp
// 1. Start from a ready-made theme with a few changes.
host.SetTheme(KitTheme.Create(KitTokens.Dark with { Accent = Color.Hex("#4fb3a5"), AccentHover = Color.Hex("#6fd0c2"), Radius = Length.Ui(8) }));
// 2. Change a token for the whole UI later (a colorblind mode, a day/night switch). No element is rebuilt.
ui.AddStyleSheet(new StyleSheet().Rule(":root", s => s.Token("--kit-accent", Color.Hex("#ffffff"))));
// 3. Restyle one component or screen with your own rules; they win ties because they come later.
ui.AddStyleSheet(new StyleSheet()
.Rule(".kit-button.primary", s => s.Radius(Length.Ui(2)).FontWeight(700))
.Rule(".market .kit-row.selected", s => s.Var("background-color", "--kit-accent")));
```
- `host.SetTheme(sheet)` swaps the kit's sheet in place, so the sheets you added after it still win.
- Scope a different look to a screen by putting tokens on a class and giving that class to the screen's root: `new StyleSheet().Rule(".night", s => s.Token("--kit-panel", Color.Hex("#0b0c10")))`.
- The structure of every component is stable: kind, role, classes and part ids are API. Style them through those, never through child positions.
- SVG art (`KitArt`): the small icons (sort arrows, chevron, check) are white and take their color from the theme with `background-tint`, so they follow a light or dark theme. The frame, key caps and gamepad glyphs have their own colors (dark) baked in; replace them by registering your own SVG under the same ids when your theme is light.
### States and legibility
Every kit control looks different in each state, and the differences do not rely on color alone:
| State | Look |
|---|---|
| hover | the fill goes lighter (accent and hover tokens), the pointer is a hand |
| pressed | the fill goes darker (`--kit-active`, `--kit-accent-active`) |
| focus | a ring of `--kit-focus-width` outside the control, 2 px away from it, so it reads against the surface behind and not against a primary fill; inside clipped parts (list rows, table headers, drop-down options, tabs) the ring is drawn inside |
| selected | row and option: fill, text color and an accent frame inside the row; tab: accent underline; checkbox and radio: accent fill |
| switched on (toggle button) | a tinted fill (`--kit-pressed-fill`, `-hover`, `-active`) and an accent outline (`--kit-pressed-border`), so it is not a hover fill, a focus ring or a disabled fill; disabled and switched on keeps a faint tint and an outline in `--kit-disabled-text` |
| disabled | the fill is flat (`--kit-disabled-fill`), the outline dim (`--kit-disabled-border`), the text `--kit-disabled-text`; no hover or pressed change, no opacity fade, the same for every variant |
`:disabled` matches a disabled element and everything inside it, so `card.IsEnabled = false` dims the whole card.
The theme derives state colors from your tokens and makes the ones that carry text or an indicator legible by the WCAG 2.x AA ratios (text 4.5:1, including the text on a switched-on toggle; focus ring, control outlines, the switched-on outline, accent fills and scroll thumbs 3:1,
disabled text 3:1). `KitTheme.Create` moves a token that would fall short just far enough toward white or black: `Text` and `TextDim` on every surface, `AccentText` on the accent in all its states,
`SelectionText`, `Focus`, `Border` for control outlines (`--kit-control-border`), `Scrollbar`. It never touches one that already passes. Colors that are not text, such as your `Accent` against the panel, are only checked.
Check a theme, including any token your game overrides by hand after the kit's sheet (which skips the repair), from a test or from the command line:
```csharp
var report = ThemeLint.Check(ui); // the tokens in effect on the root; ThemeLint.Check(KitTokens) checks a token set before you use it
if (!report.Passed)
{
foreach (var f in report.Failures) // worst first, e.g. "primary button text on accent: --kit-accent-text #d9a441 on --kit-accent, 1.0:1, needs 4.5"
{
Console.WriteLine($"{f.Name}: {f.Foreground} {f.ForegroundColor} on {f.Background} {f.BackgroundColor} = {f.Ratio}:1, needs {f.Required}");
}
}
```
`onegame lint-theme --json` runs the game headless and prints the same report (exit code 1 when a pair fails); the control operation `theme_lint` returns it from a running game over MCP.
Put a `ThemeLint.Check` in your game's tests so a theme edit cannot silently break legibility.
## 12. Testing a screen
Test through the same events and frames as the harness; do not call handlers. A test screen is a `UiTree` with the monospace measurer, a `KitHost`, and a helper that runs frames.
```csharp type
public sealed class ScreenTest
{
public UiTree Tree { get; } = new();
public KitHost Host { get; }
public ScreenTest()
{
Tree.SetViewport(1280, 720);
Host = new KitHost(Tree);
}
// Components finish a few things on the frame after an input (layout feeds the scroll bars, focus requests apply after the update), so settle for a few frames.
public void Settle()
{
for (var i = 0; i < 4; i++)
{
Host.Advance(0);
Tree.Frame();
}
}
public void Click(string id)
{
var r = Tree.Find(id)!.AbsoluteBounds;
Host.Dispatch(UiEvent.PointerMove(r.X + (r.Width / 2), r.Y + (r.Height / 2)));
Host.Dispatch(UiEvent.PointerDown(r.X + (r.Width / 2), r.Y + (r.Height / 2)));
Host.Dispatch(UiEvent.PointerUp(r.X + (r.Width / 2), r.Y + (r.Height / 2)));
Settle();
}
public void Press(string key)
{
Host.Dispatch(UiEvent.KeyDown(key));
Host.Dispatch(UiEvent.KeyUp(key));
Settle();
}
}
```
What to assert, from cheap to strong:
```csharp
var t = new ScreenTest();
var state = new GuideGame();
t.Tree.Root.Add(Ui.Column(
UiKit.Slider(() => state.Quantity, v => state.Quantity = v, 1, 99, 1, "qty"),
UiKit.Button("Buy", () => state.Gold -= 10, "buy")).Width(400));
t.Settle();
t.Click("buy"); // 1. behavior: input in, game state out
t.Tree.Focus(t.Tree.Find("qty"));
t.Press("ArrowRight");
Console.WriteLine($"{state.Gold} {state.Quantity}"); // 490 and 2
Console.WriteLine(t.Tree.Find("qty")!.Value); // 2. the dump: roles, values, ids, states
var box = t.Tree.Find("buy")!.AbsoluteBounds; // 3. boxes: expected sizes, hand-computed with the monospace measurer
Console.WriteLine($"{box.Width} x {box.Height}");
Console.WriteLine(t.Tree.DisplayList.ToText().Length); // 4. the display list: compare with a golden file
Console.WriteLine(t.Tree.DumpJson(t.Tree.Find("buy")).Length); // or the JSON of a subtree
```
- **Find and invoke:** `ui.Find(id)`, `ui.Query(selector)`, `ui.Invoke(element)` (a click without hit testing), `ui.Dispatch(event)` for everything else.
- **Allocation:** `GC.GetAllocatedBytesForCurrentThread()` around 100 `Frame()` calls on a quiet screen must be 0.
- **Golden files:** compare `DisplayList.ToText()` and an outline of roles and boxes with committed text files; rewrite them with an env var when a change is intended
(`TradingScreenTests` shows the pattern). The display list is plain text, one command per line, with colors and boxes.
- **Pictures:** the 2D renderer draws a display list offscreen on any device (`CaptureTests` does it on lavapipe and compares with PNG references at a stated tolerance).
Look at the capture at two UI scales.
### Test your screen with the harness
The unit-style test above builds a tree by hand. A game also gets tests as text: a scenario file drives the real game (the host, the screens, the same input path as a player) and
asserts on the UI, the state and a captured frame. `docs/driving-a-game.md` has the whole story; for a screen it looks like this, using the ids you gave your components:
```yaml
name: buy-with-the-pad
window: 1280x720
steps:
- nav: down # the d-pad (up down left right next previous); focus starts on the default element
- nav: activate # the confirm button; or: press: Enter, or: click: buy
- click: filter
- type: "sil"
- step: 2 # frames, so the table rebinds
expect:
- ui: { id: buy, enabled: true }
- ui: { selector: .kit-row, count: 1 } # the filter left one row; rows are recycled, so this counts the rows that exist
- ui: { id: confirm, visible: false }
- ui: { id: fast, pressed: true } # a toggle button is switched on
- state: { entity: Shop, property: Gold, equals: 10 }
- capture: { reference: refs/market.png, size: 1280x720, tolerance: 2 }
```
Run it with `onegame test --json`. The expectation keys for the UI are `id` or `selector` plus `text`, `contains`, `count`, `visible`, `enabled`, `focused`; to check a role
or a value, run `ui_tree` over the live connection or `DumpJson` in a unit test. A scenario is the right tool for flows (open the dialog, press Escape, check focus is back); the unit test is
the right tool for boxes and display-list goldens.
## 13. Pitfalls
- **One parent per element.** Build each element once; use `Ui.Each`/`BindChildren` to repeat.
- **Ids must be unique** within what you query; give components ids so their parts get stable ids too.
- **Rows have one fixed height** in lists and tables. Variable-height rows are not supported.
- **Frames after input.** Layout feeds back into scroll bars, focus requests and tooltips for a frame or two: tests should `Settle()`; a real game never notices.
- **Do not write game state in a binding** and do not call `ui.Focus` inside one (use `RequestFocus`). Bindings read.
- **Do not allocate per frame** in bindings (section 4). A capturing lambda inside a hot method is an allocation even on the path that returns early.
- **Percent lengths** are of the containing box; `Length.Ui` follows the UI scale and `Length.Px` does not. Mixing them is how a layout breaks at 4K.
- **Selectors with combinators** restyle subtrees when state changes: fine for screens, avoid on thousands of rows.
- **A hidden element is gone:** `IsVisible = false` removes it from layout, input and focus; `Visibility(Hidden)` keeps its space.
# Exporting a game
`onegame export` turns a game project into what ships: a folder and an archive holding the program, its content, the platform libraries it needs and a licence-notices file. Nothing else goes in.
```
onegame export --target linux --json # ./export/--linux-x64/ and .tar.gz
onegame export --target win --steam --json # ./export/--win-x64/ and .zip, with steam_api64.dll
onegame export --target osx --json # ./export/--osx-arm64/.app and .tar.gz (on an Apple-silicon Mac)
```
Options: `--target linux|win|osx` (default: the host's; `linux-x64` and `win-x64` also work), `--steam`, `--steam-redist `, `--out ` (default `/export`), `--project `, `--no-archive`, `--no-aot`. The version in the names is the project's `` (1.0.0 when unset). The JSON result is in `docs/interfaces/control.md`; read its `warnings`.
Desktop exports work for a game created with `onegame new` from the installed tool, with no engine checkout: the `RecursiveAI.OneGame.Sdk` package carries the export targets. The web export, Play Billing in an Android build and `--server --plugins` read engine sources and still need a game created with `--engine `; the Android and iOS entry files ship in the SDK package, but those two exports have only been run from a checkout.
Branching on the platform: ask `OneGame.Platform.RunningOn` (`Android`, `IOS`, `Browser`, `Phone`, `Mobile`, `Desktop`), not `OperatingSystem.IsAndroid()`, which is false in a NativeAOT Android build (it runs on the Linux runtime). A scenario capture compares against `name-mobile.png` beside `name.png` on a phone or in a page when that file exists.
Android: `onegame export --target android [--abi arm64-v8a,x86_64] [--dev]` makes a debug-signed APK (NativeAOT shared library, no Java). A game that connects to servers sets `true` in its project file to get the INTERNET permission in release builds too. Setup, device commands and what is verified are in [android](https://github.com/recursive-ai-oss/onegame/blob/25626fb470087a9be70c3b1a3da88e91a7d0af59/docs/interfaces/android.md).
iOS (on a Mac with Xcode): `onegame export --target ios-simulator [--dev]` makes an ad-hoc signed `.app` for the Simulator, `onegame export --target ios` an unsigned arm64 `.app` for a device, which the owner of an Apple Developer account signs (the steps are printed as `signingNeeds` and listed in [ios](https://github.com/recursive-ai-oss/onegame/blob/25626fb470087a9be70c3b1a3da88e91a7d0af59/docs/interfaces/ios.md)). Both are NativeAOT executables; no workload is installed.
## What the build is
Always the Release configuration, with the development tools left out of the program: no live-control listener, no scenario parser, no capture or replay options (`--live` exits 5 with `no_dev_tools`). The export fails with `dev_tools_present` if the program mentions the live-control assembly anyway, and `devTools` in the result is `false`.
| Target | Host | Result |
|---|---|---|
| linux-x64 | Linux | NativeAOT: one executable, about 6 MB |
| win-x64 | Windows | NativeAOT, same command |
| win-x64 | Linux or macOS | self-contained, trimmed, ReadyToRun CoreCLR: `.exe` with the runtime beside it (about 25 MB). NativeAOT links with the target OS's toolchain, so it needs a matching host |
| linux-x64 | Windows or macOS, or `--no-aot` | the same self-contained CoreCLR build |
`runtime` in the result says which one was made, and a `not_native_aot` warning says why. Crash-dump and debugger helpers, debug symbols and the engine's XML documentation are removed from the output.
## Layout
```
demo-1.0.0-linux-x64/
Demo the program (Demo.exe on Windows; the name is the project's AssemblyName)
run.sh Linux only: starts the program from its own folder, from any current directory
libsteam_api.so with --steam (steam_api64.dll on Windows)
THIRD-PARTY-NOTICES.txt always
fonts/ art/ data/ the game's content folders, same names as in the project
```
Content folders are copied as they are. `fonts/` takes `.ttf .otf` and their licence texts (`.txt .md`); `art/` takes `.svg .png .jpg .jpeg .psd .webp`; `data/` takes anything. Files of other types in `fonts/` or `art/` are not copied and are listed in `skipped`. Other project folders are left out and listed in `skipped` too, except build output, `scenarios/`, `captures/`, `export/` and dot-folders. A file over 64 MiB is copied with a `large_file` warning. The game finds `fonts/` next to the program (`GameDefinition.DefineFonts`), so run it by its path or through `run.sh`; the working directory does not matter.
The Linux archive is a `.tar.gz` with one top folder, owner root, a fixed timestamp and sorted entries (the same folder gives the same archive); the program and `run.sh` are executable. Windows gets a `.zip` the same way. Unpack anywhere and run `run.sh` or the program.
## Dedicated servers
`onegame export --server [--project ] [--out ]` packages a game's server project (a project importing `build/Server.targets`, made by `onegame new --server`): NativeAOT for linux-x64, the same folder, archive and JSON report as a game export, with `renderer: "none"`, no fonts or Steam, and a `run.sh`. The server keeps its live-control socket (an operator interface on a local Unix socket, mode 0600). `--plugins` packages the engine's plugin-capable `onegame-server` instead: framework-dependent CoreCLR (`runtime: coreclr-framework-dependent`), started by `run.sh` through the installed `dotnet`. Servers export for linux-x64 only.
## Steam
`--steam` copies Valve's library next to the program, from a copy you supply (it is never in this repository and never downloaded):
1. `--steam-redist `, else the `ONEGAME_STEAM_REDIST` variable: a folder holding `linux-x64/libsteam_api.so` and `win-x64/steam_api64.dll` (or the file itself);
2. else `ONEGAME_STEAM_SDK`: the unpacked Steamworks SDK (`redistributable_bin/linux64`, `redistributable_bin/win64`).
None found: `steam_redist_missing`, nothing is built. The game must itself use `OneGame.Steam` for the library to be loaded (`docs/interfaces/steam.md`).
`steam_appid.txt` is never shipped: the export deletes any in the output and does not copy the project's. With it present Steam's relaunch check always passes and the game would run outside the Steam client. A shipped game starts Steam with `RelaunchThroughSteam = true` and its app id; started directly, it asks Steam to launch it and exits (`RelaunchRequested`). To run an exported build by hand for testing, put `steam_appid.txt` with the id (480 is Valve's test app) in the current directory with the Steam client signed in, as for `dotnet run`.
In Steam's depot, set the Linux launch option to `run.sh` (or the program) and the Windows one to `.exe`. Uploading depots (SteamPipe) is not part of the export.
## Steam Deck and the Linux runtime
Steam runs Linux games against its Steam Linux Runtime 3 ("sniper"), which has **glibc 2.31**. A program linked on a newer system (Ubuntu 24.04 has 2.39) will not start there: `version 'GLIBC_2.38' not found`. The export reads the version names in the program and warns `glibc_too_new` when it needs more than 2.31. Build the Linux export in Valve's SDK image or on a system with an old enough glibc.
`tools/steamrt.sh` does that with bubblewrap and Valve's public runtime images (no Steam account or client):
```
tools/steamrt.sh fetch # pinned images, checked by SHA-256, into ~/.cache/onegame/steamrt
mkdir -p work && cp -r mygame work/ # the project's OneGameRoot must be /engine (the checkout, mounted read-only)
env DOTNET_ROOT=$HOME/.dotnet ONEGAME_ARTIFACTS=/work/artifacts ONEGAME_STEAM_REDIST=/work/redist \
tools/steamrt.sh build work dotnet /work/cli/onegame.dll export --project /work/mygame --target linux-x64 --steam --out /work/export --json
tools/steamrt.sh run /game/run.sh --headless --frames 60 --json
```
`run` is the check for the Steam Deck: the same runtime image, the game folder read-only at `/game`, no network. A passing run prints the same state hash as `onegame run --headless --frames 60 --json` on the development machine. It does not replace a run on the device (controller, display, Vulkan driver).
A windowed run needs a display and a graphics driver, and the sandbox has neither by default (no network, no `/tmp/.X11-unix`, and the image holds a Vulkan loader but no driver; Steam supplies the driver on a real system). Both are opt-in:
```
tools/steamrt.sh run --x11 --nvidia /game/run.sh --seed 7 --frames 200 --json # DISPLAY=:N must name a local X server, e.g. Xvfb
```
- `--x11` binds the socket named by `$DISPLAY` (and `$XAUTHORITY`) and the host's keyboard layout data (`/usr/share/X11/xkb`, which the image lacks and which opening any window needs). `--wayland` does the same for `$WAYLAND_DISPLAY` in `$XDG_RUNTIME_DIR`.
- `--nvidia` binds the NVIDIA device nodes and driver libraries and points the Vulkan loader at them. Only this driver works: its libraries are built for an old glibc, while a Mesa (lavapipe, RADV) from a current host needs a newer glibc than the runtime's 2.31 and cannot load. Without `--nvidia` a windowed run opens its window but has no device, so it draws nothing.
- Time-dependent state differs between a windowed run (paced by the clock) and a headless one, so compare the hash at the same tick with the same `--seed`: a windowed run that ends at tick N prints the hash `--headless --frames N` prints.
## Shaders
The 2D renderer's shaders are HLSL compiled into the engine during its build, so they travel inside the program: an export draws with the real renderer (`"renderer":"2d"`) and needs no shader files. Shaders of your own game are not part of the export layout today; a `shaders/` folder in the project is listed in `skipped` like any other folder that is not content.
## Licences
Every export carries `THIRD-PARTY-NOTICES.txt`: the engine's list (`THIRD-PARTY-NOTICES.md` in the repository: the Wayland protocol files and the Vulkan registry the generated code derives from, and the .NET runtime), then every text file found in the game's `fonts/` (put each font's licence there; `onegame new` starts a game with Noto Sans Regular and its SIL Open Font License text), then a line for the Steam library when it ships. Fonts without a licence text give a `font_licence_missing` warning. If you add other non-code content with licence terms (art, sounds), add its notice to the file yourself, for example by shipping it under `data/` and in your own list; the export only knows fonts.
## What a Windows run must prove
Not run from the Linux host that builds the Windows export, so still open:
1. `Demo.exe --headless --frames 60 --json` from another directory prints the same hash as on Linux, and `--live` exits 5.
2. The folder runs on a machine without .NET installed.
3. With `--steam`: the game started from Steam loads `steam_api64.dll`; started directly it relaunches through Steam.
4. The NativeAOT export (`onegame export --target win-x64` on Windows) builds without warnings, starts, and opens a window.
## Web
`onegame export --target web [--dev] [--out ]` builds the game for the browser: .NET on WebAssembly (the interpreter; no workload needed), WebGPU, the page and its script modules in one folder that any static file server can host (serve `.wasm` as `application/wasm`; Brotli and gzip copies sit beside the files). The export writes a small wrapper project under `obj/onegame-web/` that compiles the game's own files, resources and references with the WebAssembly SDK. `--dev` keeps the development tools (a Debug build). The Release export is trimmed and builds with zero trim warnings.
`onegame web test [--scenario ] [--tolerance N] [--max-diff F] [--dir ]` exports with development tools, serves the folder, opens it in headless Chrome (`ONEGAME_CHROME` names the browser) and runs the scenarios in the page. The page dials back over a WebSocket (a page cannot listen), the tool sends the scenario files and reads the result, which has the same shape as `onegame test --json`; actual and diff images of failed checks are saved to `/artifacts/web/`. `--tolerance` and `--max-diff` loosen capture checks for a rasterizer that differs from the one the references came from. Sound plays through Web Audio and starts at the first click, tap or key press, as browsers require; until then the `audio` operation reports `awaiting_gesture` (see `audio.md`). A page opened with `?control=page` has no socket and exposes `__control(message)` (one MCP message in, the answer out) for scripts that inject real input.
## macOS
`--target osx` makes `.app` for Apple silicon (`osx-arm64`): `Contents/MacOS/` is the NativeAOT program (about 7.5 MB for Solitaire), `Contents/Resources` holds `fonts/`, `art/`, `data/` and the notices file (`fonts/` is found there by `GameDefinition.DefineFonts`), and `Contents/Info.plist` names the bundle. NativeAOT needs a Mac with Apple silicon as the host; anywhere else the bundle is a self-contained CoreCLR build and carries a `not_signed` warning. The bundle is signed **ad hoc** (`codesign --force --sign -`), which lets it run on the Mac that made it (`open Game.app`); the result carries an `ad_hoc_signature` warning. Giving it to other people needs a Developer ID signature and notarization from your Apple developer account, which the tool does not do. `--bundle-id` sets `CFBundleIdentifier` (default `com.example.`). `--steam` is not available for osx. The archive is a `.tar.gz` that keeps the program's executable bit.
# Running the samples on phones and in a browser
The two samples run on the Android emulator, the iOS Simulator and in Chrome with the same state hashes as on a computer. This page is what to do on real hardware, and which commands repeat the simulator checks. Interfaces: [android](https://github.com/recursive-ai-oss/onegame/blob/25626fb470087a9be70c3b1a3da88e91a7d0af59/docs/interfaces/android.md), [ios](https://github.com/recursive-ai-oss/onegame/blob/25626fb470087a9be70c3b1a3da88e91a7d0af59/docs/interfaces/ios.md), [audio](https://github.com/recursive-ai-oss/onegame/blob/25626fb470087a9be70c3b1a3da88e91a7d0af59/docs/interfaces/audio.md), [exporting](https://recursiveai.org/onegame/docs/exporting/).
## One command per device
`tools/devices/` holds a script per kind of device. Each finds the device, builds what it needs, installs and runs the samples (Bricks too, once it is in the tree), compares every scenario's state hash with a run on the computer, takes a frame, checks sound through the `audio` operation, exercises touch or pad input where a script can, prints one verdict line and cleans up. Details of each step are in the script's header; per-step JSON lands in `$ONEGAME_ARTIFACTS/device-/`.
| Device | Command | Needs |
|---|---|---|
| Android phone | `tools/devices/android.sh [--serial S]` | USB debugging on, `adb`, the Android SDK and NDK the export uses; the phone on the same network as the computer for Lanterns (`--lan-host` if the guess is wrong) |
| iPhone or iPad | `python3 tools/devices/ios.py` on the Mac the device is plugged into | the device trusted and in Developer Mode, unlocked; an App Store Connect API key in `ASC_KEY_FILE`, `ASC_KEY_ID`, `ASC_ISSUER_ID` (the script registers the device and makes the profile; `asc.py` needs `pip install cryptography`), or ready profiles in `IOS_PROFILE_DIR` |
| Steam Deck | `tools/devices/deck.sh [--host H]` | ssh on, this computer's key in the Deck's `authorized_keys`; the Deck found by `steamdeck.local` or a scan of the network (`find-host.py`); desktop mode logged in for the windowed run |
| Gamepad | `tools/devices/pad.sh [--expect NAME] [--rumble]` | the pad plugged in and `/dev/input/event*` readable; `--virtual xbox\|dualsense` runs the same check against a `/dev/uinput` stand-in |
The community controller mapping database is test data: `tools/devices/fetch-gamecontrollerdb.sh ` downloads a pinned copy and `pad.sh` does it on first use. What the pad check reports (device id, database entry, whether the layout was mapped, rumble, which buttons and axes moved) needs a person pressing things to cover the buttons; unattended it reports what the idle device says.
For a cluster whose servers run on the computer and whose client runs on a phone, set `ONEGAME_CLUSTER_BIND=0.0.0.0` ([cluster](https://github.com/recursive-ai-oss/onegame/blob/25626fb470087a9be70c3b1a3da88e91a7d0af59/docs/interfaces/cluster.md)); the scripts do. A game's live control on a device the computer can reach only over the network is `--live tcp:*:PORT` ([control](https://github.com/recursive-ai-oss/onegame/blob/25626fb470087a9be70c3b1a3da88e91a7d0af59/docs/interfaces/control.md)).
## A real Android phone
1. On the phone: Settings → About → tap *Build number* seven times; Developer options → *USB debugging*. Plug it in and accept the computer's key. `adb devices` lists it (`ANDROID_SERIAL` picks one when several are attached).
2. From `samples/solitaire`:
```
onegame export --target android --abi arm64-v8a --dev --json
onegame android install export/Solitaire-*-android-dev.apk
onegame android test --scenario scenarios --json # 14 of 14; state hashes equal the desktop's
onegame android live # prints tcp:127.0.0.1:N for `onegame mcp --socket`
```
Over MCP: `audio` must report `backend: "aaudio"`, `deviceState: "started"` and a growing `framesPlayed`; draw a card (`ui_tap {id: stock}`), then `peakPlayed` is above 0. Touch with a finger, rotate, press Home and come back: `hash` is the same before and after.
3. Lanterns as a client of servers on the computer: build the servers into `bin/` (README of the sample), start `onegame cluster up --spec` with the servers only, and run the client with the computer's LAN address, not `10.0.2.2`:
```
LANTERNS_HOST= onegame cluster test cluster/play-android.yaml --spec cluster-android.yaml --json
```
The phone and the computer must be on one network; the servers listen on all addresses only when their spec says `listen` with a non-loopback address.
What only a phone shows: a real GPU driver's output (compare a capture with the references at the tolerances in the sample READMEs), real touch and keyboard, notches, sound from the speaker, thermals.
## A real iPhone
`tools/devices/ios.py` does the steps below, including the account side, when an App Store Connect API key is set (see the table above). By hand it needs an Apple Developer account, a development certificate in a keychain the shell can unlock (a locked login keychain fails with `errSecInternalComponent` over ssh; `tools/devices/ios-signing.sh` makes a keychain of its own with a certificate the API issues), a provisioning profile that lists the device, and the device in Developer Mode (Settings → Privacy & Security).
1. `onegame export --target ios --dev --bundle-id --json` from the sample. The bundle is unsigned and the command prints `signingNeeds`.
2. Copy the profile to `.app/embedded.mobileprovision`, put the profile's team id into the `.entitlements` file beside the bundle, then
`codesign --force --sign "" --entitlements .entitlements --timestamp=none .app`
`xcrun devicectl device install app --device .app`
3. Launch it from the phone's home screen, or `xcrun devicectl device process launch --device --console -- --headless --scenario ...`. The `onegame ios` verbs address simulators only; on a device use `devicectl` and read `Documents/onegame-stdout.txt` with `devicectl device copy from`.
4. The same checks as on the simulator: the 14 scenarios give the desktop's hashes, the `audio` op reports `backend: "audiounit"` and `peakPlayed` above 0 after a draw. Lanterns connects to servers on a computer by that computer's LAN address (the first connection asks for the local-network permission).
What only a phone shows: finger touch, the real soft keyboard, the notch and home-indicator safe areas, the speaker and the silent switch (the audio session category is Ambient, so it follows the switch).
## A browser
```
onegame export --target web # a folder to host; any static server works
onegame web test --json --tolerance 2 --max-diff 0.02 # solitaire's 14 scenarios in headless Chrome
```
A page needs a browser with WebGPU (Chrome and Edge 113+; Safari and Firefox are not tested). Sound starts after the first click or key press, as browsers require; until then the `audio` op reports `awaiting_gesture`. To see the page present frames on a real GPU, start headed Chrome on a display with `--enable-unsafe-webgpu --ignore-gpu-blocklist --enable-features=Vulkan --ozone-platform=x11 --no-first-run --disable-extensions` (a virtual X server such as `Xvfb` is enough), open the exported page, and `navigator.gpu.requestAdapter()` names the GPU.
## Repeating the simulator checks
| Target | Check |
|---|---|
| Android emulator | `onegame android test --scenario scenarios --json` in `samples/solitaire`, then `onegame cluster test cluster/play-android.yaml --spec cluster-android.yaml --json` in `samples/lanterns` (README of each sample) |
| iOS Simulator | `onegame ios test --scenario scenarios --bundle dev.onegame.solitaire --json`; in `samples/lanterns`, `onegame ios test --scenario scenarios --json -- --capture-tolerance 4 --capture-max-diff 0.0025` and `onegame cluster test cluster/play-ios.yaml --spec cluster-ios.yaml --json` |
| Browser | `onegame web test --tolerance 2 --max-diff 0.02` in `samples/solitaire`; in `samples/lanterns`, `onegame web test --tolerance 4 --max-diff 0.01`; `tools/web/lanterns-test.mjs` for the WebSocket client |
## What the simulators showed
| | Solitaire (14 scenarios, hashes equal to the computer's) | Lanterns (8 scenarios, hashes equal) | Lanterns as a client of native island servers | Sound |
|---|---|---|---|---|
| Android emulator, arm64, API 35 | 14/14; captures within tolerance 48 | 8/8; 3D captures differ on at most 0.22 % of pixels by more than 4 | UDP: bot agrees with its island at tick 3600, zero drift | `aaudio`, `peakPlayed` 0.077 |
| iOS Simulator, Metal | 14/14; captures within the scenario files' tolerance | 8/8 with `--capture-max-diff 0.0025` (largest frame: 0.20 %) | UDP and WebSocket: agree at tick 3600, zero drift | `audiounit`, `peakPlayed` 0.077 |
| Chrome, WebGPU | 14/14 | 8/8; largest 3D frame differs on 0.4 % of pixels | WebSocket: replica view hash equals the server's at five ticks, zero drift | `webaudio`, silent until the first click |
On iOS and in a browser a capture compares against `name-mobile.png` beside `name.png` when that file exists (Bricks' title has no Quit button there, so `title-720p-mobile.png` and `title-1080p-mobile.png` are the same frames without it).
Frames are compared with references drawn on a software renderer; GPU drivers differ along edges, which the tolerances above cover. Headed Chrome on a desktop GPU (NVIDIA, through a virtual X server) presents both samples' frames to the canvas.
# Changelog
All notable changes to onegame are listed here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/) from 1.0.0.
## [Unreleased]
## [1.2.0] - 2026-10-27
Happy-path layers over the low-level API: a first game now has a moving sprite, action-mapped input and a hot-reloaded asset in about 90 lines.
### Added
- 2D drawing from view code: entities implementing `IView2D` are drawn by the default `Render2DStage` through `Draw2D` (sprites, sprite-sheet frames and clips, rectangles, lines, circles, text), batched onto the existing `Scene2D`. `SpriteSheet`, `SpriteClip` (frame animation on view time) and `Camera2DRig` (follow, smoothing, bounds, zoom, screen and world conversion). `UiCanvas.Text` and `TextQuadBuilder.BuildLine` draw text outside a UI list. Control operation `draw_view`.
- `Draw2D.Texture(path)` and `d.Sprite("art/ship.png", ...)` draw from an asset path; a grid `SpriteSheet` over an asset texture is cut again when a reload changes the image size.
- Input actions: `ActionMap` with contexts, bindings, composites (`Wasd`, `Arrows`, sticks, pairs) and queries, declared in `GameDefinition.DefineActions` and read from `GameInstance.Actions`; rebinding with a bindings file and `ActionUi`; `SteamActionSource`. Control operations `actions` and `input_pad`, scenario steps `action:` and `pad:`, `press:` with `state`, and the `action:` expectation.
- `Assets`: cached, reference-counted handles for textures, SVG, YAML data, sounds, models and fonts, background loads and size limits; `GameDefinition.CreateAssetSource`; development hot reload (textures, sounds and models swap in at once; YAML data is staged and applied at a boundary); the `assets` operation, `assets:` step and `asset:` expectation; `Renderer2D.ReplaceTexture`.
- `onegame new` writes a ship game: a `Ship` entity with fixed-point position drawn from `art/ship.png`, `DefineActions` for move and boost, a scenario using `action:`, and a capture scenario with its reference. `--server` keeps the counter scaffold.
- `tools/OneGame.Corpus` (`onegame-corpus`): a seeded, deterministic corpus of real frames, snapshots, saves, recordings and transfer bundles from Lanterns, Bricks and Solitaire, for compression work.
### Changed
- OneShader 1.1.0. `ShaderSource.Layout` replaces the `Bindings`, `ComputeGroup*`, `Msl*` and `Wgsl*` members (breaking for a hand-built `ShaderSource`); `ShaderLayouts.Bindings` and `ShaderLayouts.PushConstants` expose the generated layouts.
## [1.1.2] - 2026-10-27
### Added
- `OneGame.Platform.RunningOn` (`Android`, `IOS`, `Browser`, `Phone`, `Mobile`, `Desktop`): one way for a game to ask what it runs on.
### Fixed
- Android builds reported themselves as Linux to platform checks (`OperatingSystem.IsAndroid()` is false in a NativeAOT Android app); the engine now asks `RunningOn`, and Bricks hides Quit on Android. Scenario captures on Android use `name-mobile.png` references like iOS and the browser.
## [1.1.1] - 2026-10-26
### Fixed
- Linux audio output: the ALSA sink never reported its device side, so the `audio` report showed `framesWritten`, `framesPlayed` and `peakPlayed` as 0 on every ALSA device even while sound was playing, and a device that really took nothing could not be told apart. It now reports frames written, frames the device has consumed (from `snd_pcm_delay`), the peak of consumed frames, its PCM state and underrun count.
- The device checks in `tools/devices` now fail unless `framesWritten` and `framesPlayed` are above 0, not on a `Running` state alone.
## [1.1.0] - 2026-10-26
A game builds from nuget.org alone, with no engine checkout.
### Added
- `RecursiveAI.OneGame.Cli`: the `onegame` command as a framework-dependent dotnet tool (`dotnet tool install -g RecursiveAI.OneGame.Cli`).
- `RecursiveAI.OneGame.Sdk`: an MSBuild SDK for games. It sets the target framework and the rules, references the engine libraries at its own version, adds the development tools in Debug builds, and carries the export targets and entry files. `onegame new` writes a project on it (``), and with `--server` a dedicated-server project (`Server`).
- `onegame export --target linux|win` as short names for `linux-x64` and `win-x64`.
- `tools/ci/package-smoke.sh` and a CI job that pack the engine into a local feed, install the tool from it and run new, run, test and a NativeAOT export on Linux and Windows.
### Changed
- Games inside an engine checkout (`--engine`, `ONEGAME_ROOT`, the samples) build against project references as before.
- Not yet from packages: the web export, Play Billing in Android builds and plugin servers need a game created with `--engine`.
## [1.0.0] - 2026-10-25
The first release: a C# game engine with no editor, driven from a terminal.
### Programming model
- Entities are partial classes holding synced state, commands, deterministic simulation and `[View]` members. Source generators emit change tracking, binary codecs, deltas, replica types, versioned snapshots and command stubs.
- Analyzers make the rules compile errors: no floats, wall-clock reads, threads, I/O or hash-order iteration in simulation code; views read state and send commands but never write it.
- Fixed-point numbers, named random streams and `Timed` interpolation values.
### Sync and networking
- Nodes, scopes, authorities and subscriptions; per-connection visibility (everyone, owner, team, hidden) enforced at the byte level; drift hashes always on.
- Prediction with rollback, atomic authority transfer, relaying and escrow for changes that span scopes.
- Reliable UDP and WebSocket transports, reconnection, dedicated server hosts and hot-reloadable server plugins.
- Single-player is a one-node cluster: saves are snapshots and the action log is the command log, so replays are bit-exact.
### Rendering
- A GPU layer over Vulkan (Windows, Linux, Android), Metal (macOS, iOS) and WebGPU (browsers), with a render graph.
- A 2D renderer (sprites, tilemaps, parallax, custom materials) and a 3D renderer (glTF 2.0 metal/rough PBR, clustered forward lighting, image-based lighting, cascaded shadows, SSAO, bloom, tone mapping, instancing, a quality ladder).
- HLSL shaders compiled during the build with generated C# bindings.
- glTF 2.0 import with limits checked before allocating.
### UI
- A retained UI tree styled on the CSS model, flexbox and anchor layout, controller focus navigation, input glyphs, world-anchored UI and tweens.
- A component kit: table, slider, tabs, tooltip, list, card, nine-slice frame and text input.
- Text shaping through OneFont; SVG and images through OneImage.
### Platforms
- Windows (Win32, XInput, WASAPI), Linux (Wayland and X11, evdev pads, ALSA), macOS (Cocoa), iOS (UIKit), Android (native activity) and the browser (.NET on WebAssembly).
- Steamworks and Steam Input bindings, StoreKit and Google Play Billing bindings, and a scriptable fake store.
- `onegame export` for Windows, Linux, macOS, iOS, Android and the web: NativeAOT where the host allows it.
### Harness
- The `onegame` command (`new`, `build`, `run`, `test`, `capture`, `replay`, `export`, `cluster`, and more) with JSON output.
- A C# test API (`TestCluster`, scenarios, captures, recordings) and live control over MCP in development builds; release builds leave the listener out.
### Samples
- Solitaire: a UI-heavy 2D game with scenarios, replays and captures.
- Lanterns: two island servers and a master, entity hand-off, escrowed trades, predicted movement, owner-only inventories, in 2D and 3D.
# Automated game testing with scenarios
Write game tests as YAML scenarios that click, press keys, send commands and step frames, then check state, UI, sounds and pictures, and run them headless in CI with onegame test.
A scenario is a test written as text. `onegame test` runs every file in `scenarios/` in a headless game: no window, no display and no GPU needed. This one drives the apple tree from [Getting started](https://recursiveai.org/onegame/docs/getting-started/):
```yaml
name: pick-apples
description: Apples grow, picking takes them, and picking more than there are is refused.
seed: 7
steps:
- expect:
ui: { id: apples, text: "0 on the tree, 0 picked" }
- click: pick # nothing to pick yet: refused
- step: 300
- click: pick
- step: 1
- command: { entity: Tree, name: Pick, args: [99] } # more than there are: refused
- step: 1
expect:
- state: { entity: Tree, property: Picked, equals: 1 }
- state: { entity: Tree, property: Apples, equals: 9 }
- ui: { id: apples, text: "9 on the tree, 1 picked" }
```
```shell
onegame test
```
```text
PASS pick-apples (39 ms, tick 302, hash 9a447aae324c764b)
1 passed, 0 failed
```
## A failing test
With `equals: 0` on the `Apples` line instead, the same run reports the line, the expected value and the actual one, and exits 1:
```text
FAIL pick-apples (40 ms, tick 302, hash 9a447aae324c764b)
expect state (line 15): Tree.Apples is 9, expected 0
expected: 0
actual: 9
0 passed, 1 failed
```
`onegame test --json` prints the same as one JSON object, with every step and expectation and its line, for CI to read. The exit code is 0 when everything passed and 1 when a check failed.
## What a scenario can do
The top level takes `name`, `description`, `seed`, `window` (`640x360`), `fixture` and `snapshot`, then `steps` and `expect`. Unknown keys are errors with a line number.
| Steps | |
|---|---|
| `click: pick` | an element by id; also `{selector: "Button"}` or `{x: 10, y: 20}` |
| `step: 300` | run frames |
| `command: { entity: Tree, name: Pick, args: [2] }` | send a command as a player would |
| `set: { entity: Tree, property: Apples, value: 40 }` | write one scalar of state directly |
| `press: Tab`, `type: "text"` | keys and typed text; `press: { key: d, state: down }` holds a key until `state: up` |
| `action: { name: move, state: down, x: 1, y: 0 }` | an input action, as the keyboard would press it; `action: jump` taps a button. See [Input actions](https://recursiveai.org/onegame/cookbook/input-actions/) |
| `pad: { button: south }`, `pad: { axis: leftx, value: 1 }` | the harness’s virtual gamepad |
| `nav: down`, `nav: activate` | a controller: `up down left right next previous activate cancel`, or a button by name |
| `fixture: name`, `snapshot: file`, `reset: 77` | replace the world mid-scenario |
| `assets: { reload: art/ship.png }` | reload a file the game loaded; also `assets: check` |
| `expect: ...` | check something between steps |
| Expectations | |
|---|---|
| `state: { entity: Tree, property: Apples, equals: 9 }` | a synced property; also `min`, `max`; paths like `Stock[1]` or `Stock.count` |
| `ui: { id: apples, text: "..." }` | an element’s text; also `selector` with `count`, `contains`, `visible`, `enabled`, `focused`, `pressed` |
| `sound: { name: coin, count: 1, frames: 5 }` | the game played a clip, without a sound device |
| `action: { name: boost, held: true }` | an action’s state: `held`, `pressed`, `released`, `x`, `y` |
| `asset: { path: art/ship.png, state: ready }` | a file the game loaded: `state`, `version`, `error` |
| `tick: 3` | the world’s tick |
| `capture: { reference: refs/main.png, tolerance: 2, max-diff: 0.001 }` | a picture of the screen against a reference: see [Screenshot tests](https://recursiveai.org/onegame/cookbook/screenshot-tests/) |
`entity:` is a type name (the first entity of that type), a numeric id, or `Type#n` for the nth. A step that fails stops the scenario; the expectations under `expect` are checked once every step has run, in order. An `expect:` step takes one expectation; use one step per check.
## Running a subset
```shell
onegame test --scenario scenarios/pick-apples.yaml # one file
onegame test --no-unit # scenarios only, not the C# test projects
```
`onegame test` also runs any C# test project in the game’s folder whose name contains “Test”. See [Test a game from C#](https://recursiveai.org/onegame/cookbook/csharp-game-tests/).
## Good to know
- Scenarios run against the real game: the same host, screens and input path as a player, through the same operations live control uses.
- A scenario with a `seed` is repeatable to the bit: the `hash` in the result is the same on every run.
- Scenarios run in one process. To test a server and several clients over a real network, a cluster scenario adds `node:`, `wait`, `agree` and `drift`: see [Client/server sync with a dedicated server](https://recursiveai.org/onegame/cookbook/client-server-sync/).
- At 1.2.0, an action held with `state: down` stays held into the next scenario of the same `onegame test` run: the scaffold’s `steer-ship` leaves `move` and `boost` held. End a scenario by releasing what it holds (`state: up`), and start one that depends on input by releasing too.
- `onegame web test` runs scenarios in headless Chrome, but the web export needs an engine checkout at 1.2.0.
Every key is listed in [Driving a game](https://recursiveai.org/onegame/docs/driving-a-game/).
A longer guide with replays, screenshot diffs and CI: [Automated game testing](https://recursiveai.org/onegame/articles/automated-game-testing/).
# Record and replay a game
Record a run of a C# game to a file, replay it later and check every tick’s state hash, and find the first tick where a code change makes the game behave differently.
A recording holds the starting state and every command. Because the simulation is deterministic, replaying it rebuilds the same world tick by tick, and `onegame replay` compares the state hash at each one.
Record the [pick-apples scenario](https://recursiveai.org/onegame/cookbook/automated-game-testing/) while it runs:
```shell
onegame run --headless -- --scenario scenarios/pick-apples.yaml --record run.ogrp --artifacts .
```
```text
PASS pick-apples (39 ms, tick 302, hash 9a447aae324c764b)
1 passed, 0 failed
```
Replay it:
```shell
onegame replay run.ogrp
onegame replay run.ogrp --json
```
```text
replay identical: 302 ticks, 3 commands, final hash 9a447aae324c764b
{"ok":true,"identical":true,"ticks":302,"commands":3,"finalHash":"9a447aae324c764b","segments":1}
```
The three commands are the two clicks on Pick and the refused `Pick(99)`. Refused commands are recorded too.
## Find where a change diverges
Change the simulation, here apples growing every 20 ticks instead of every 30, and replay the same file:
```text
replay DIFFERS (segment 0 of 1): Checkpoint at tick 20 (last good 19): 2 part(s) of the state differ.
```
```text
{"ok":false,"identical":false,"ticks":302,"commands":3,"tick":20,"expected":"36918dc7c358bda6","actual":"7915daf0d5a403bb","finalHash":"7915daf0d5a403bb","segments":1,"segment":0}
```
The replay exits 1 and names the first tick that differs and both hashes. Put the change back and the replay is identical again. This makes a recording a regression test for the simulation: keep the file, replay it in CI, and any change that alters what a player saw fails at the exact tick.
## Good to know
- `--record` and `--scenario` are options of the game, so they go after `--`. `--artifacts .` puts output files in the current folder.
- A recording starts from a snapshot, so a bug caught in a long session can be replayed after the fix without replaying the whole session.
- A recording is a session of segments. Anything that replaces the world (a scenario `reset:`, `fixture:` or `snapshot:`, a `set:`) ends one segment and starts the next, and the replay reports `segments`.
- A recording with no ticks and no commands replays as `identical: false`, because nothing was compared.
- In C#: `game.StartSessionRecording()`, `RecordingFile.Save(session, path)` and `game.Replay(session)`. `Determinism.Replay` in [Test a game from C#](https://recursiveai.org/onegame/cookbook/csharp-game-tests/) records and replays a scripted run in one call.
More in [Driving a game](https://recursiveai.org/onegame/docs/driving-a-game/).
# Screenshot tests for a game
Capture frames of a C# game headless, compare them with reference images at a tolerance in a scenario, update the references when a change is intended, and capture a sequence of frames.
## Capture a frame
```shell
onegame capture --out shots/main.png --size 1080p --after 300 --json
```
```text
{"ok":true,"result":{"path":"/tmp/onegame/orchard/shots/main.png","width":1920,"height":1080,"scale":1,"sha256":"599056f9db1627201e3b1d49594b23751129a2496a99ae4fd32353404b3c5d3a","renderer":"NVIDIA GeForce RTX 4070","tick":300,"hash":"d0045829889cf126"}}
```
`--after 300` runs 300 ticks first. Sizes are `360p 720p 1080p 1440p 4k deck phone tablet` or `WxH`, and `deck` is 1280×800. `renderer` names the GPU that drew the frame, or `cpu` when the software renderer did. Without `--out` the picture goes to `captures/frame.png`.
Open the PNG and look at it. A matching state hash proves the run repeats, not that the picture is right.
## Compare with a reference in a scenario
```yaml
name: looks
description: The main screen after the first apples grow, compared with a reference picture.
seed: 7
window: 640x360
steps:
- step: 300
expect:
- capture: { reference: refs/main.png, size: 640x360, tolerance: 2, max-diff: 0.001 }
```
`tolerance: 2` lets each color channel of a pixel differ by up to 2. `max-diff: 0.001` lets up to 0.1% of the pixels differ by more than that. The reference path is relative to the scenario file, so this one is `scenarios/refs/main.png`.
The first run has nothing to compare with:
```text
FAIL looks (385 ms, tick 300, hash d5ef04c0af6a538b)
expect capture (line 8): No reference image at /tmp/onegame/orchard/scenarios/refs/main.png. Run with --update-references (or ONEGAME_UPDATE_REFERENCES=1) to create it from the current frame, then look at it.
expected: a reference image
actual: none
0 passed, 1 failed
```
```shell
onegame test --scenario scenarios/looks.yaml --update-references
```
```text
PASS looks (313 ms, tick 300, hash d5ef04c0af6a538b)
created reference /tmp/onegame/orchard/scenarios/refs/main.png
1 passed, 0 failed
```
Look at the new reference before you commit it. From then on `onegame test` compares against it. After the button’s color changes from green (`0x2F6B4F`) to red (`0xA03020`), the state hash is the same but the picture is not:
```text
FAIL looks (343 ms, tick 300, hash d5ef04c0af6a538b)
expect capture (line 8): The frame differs from refs/main.png: 6791 px (2.947 %) differ by more than 2, max channel delta 113; allowed 0.100 %. Wrote /tmp/onegame/orchard/artifacts/looks.0.actual.png and /tmp/onegame/orchard/artifacts/looks.0.diff.png.
expected: within 0.001 of reference
actual: 6791 px differ (max delta 113)
0 passed, 1 failed
```
`looks.0.actual.png` is the new frame. In `looks.0.diff.png` the pixels that differ are bright red over a darkened copy of the frame, here the whole button. When the change is intended, run with `--update-references` again. With `--json` the result lists the files under `references: {created, updated}`.
## Capture a sequence
```shell
onegame capture --sequence grow --count 4 --every 30 --size 360p --json
```
```text
{"ok":true,"result":{"directory":"/tmp/onegame/orchard/captures/grow","manifest":"/tmp/onegame/orchard/captures/grow/manifest.json","frames":4,"width":640,"height":360,"fps":2}}
```
That is four frames 30 ticks apart, `frame-0000.png` to `frame-0003.png`, and a `manifest.json` with each frame’s tick, state hash and SHA-256. The sequence folder goes under `captures/`, while `--out` for a single frame is relative to the current folder.
## Good to know
- Captures render offscreen at any size, so no window or display is needed, on a GPU when there is one and otherwise on the CPU.
- GPU drivers differ along edges, which is what `tolerance` and `max-diff` are for. Make references on the machine that runs the tests, or loosen them for another rasterizer.
- On a phone and in a browser a capture compares against `name-mobile.png` beside `name.png` when that file exists.
- From C#, `TestClient.Capture` renders the current state; see [Test a game from C#](https://recursiveai.org/onegame/cookbook/csharp-game-tests/).
# Deterministic simulation in C#
Keep a game’s simulation deterministic with fixed-point numbers and named random streams, and let the analyzers turn floats, clocks, System.Random and view writes into compile errors.
The same game, seed and commands give the same state on every machine and every run. Replays, drift detection between client and server, and repeatable tests all depend on that. onegame enforces it at build time: sim code (an entity’s `OnTick`, its commands, and anything marked `[Sim]`) may only use what is deterministic.
## Fixed-point numbers and random streams
A kite on a line, whose height moves with a gusting wind:
```csharp
using OneGame.Core;
using OneGame.Host;
using OneGame.UI;
namespace Kite;
/// A kite on a line: its height moves with a gusting wind.
[Entity]
public partial class Kite : Entity
{
[Synced] public partial Fixed Height { get; private set; }
[Synced] public partial Fixed Climb { get; private set; }
[Synced] public partial Fixed Line { get; private set; }
[Command]
public void LetOut(int metres) => Line = Fixed.Min(Line + metres, 100);
protected override void OnTick()
{
// Every 10 ticks a gust: a random push between -0.05 and 0.05 metres per tick, from the "wind" stream.
if (World!.Tick % 10 == 0)
{
Climb += World.Rng["wind"].NextFixed(Fixed.FromRatio(-1, 20), Fixed.FromRatio(1, 20));
}
Climb = Fixed.Clamp(Climb, -1, 1);
Height = Fixed.Clamp(Height + Climb, 0, Line);
}
// Views may use floats: they only draw.
[View]
public string Label() => $"Height {Height.ToSingle():0.0} m of {Line.ToInt()} m";
}
public sealed class KiteGame : GameDefinition
{
public override string Name => "kite";
public override void RegisterEntities(EntityRegistry registry) => OneGame.Generated.Kite.EntityTypes.Register(registry);
public override void Setup(World world) => world.Add(new Kite()).LetOut(30);
public override void DefineScreens(ScreenSet screens) =>
screens.Add("main", game => Ui.Column(
Ui.Label(() => KiteOf(game).Label()).Named("height"),
Ui.Button("Let out 10 m", () => KiteOf(game).Send.LetOut(10)).Named("let-out").Padding(Length.Px(8)))
.Padding(Length.Px(24)).Gap(Length.Px(12)));
private static Kite KiteOf(GameInstance game) => (Kite)game.World.Entities[0];
}
```
Run it twice with seed 1, then with seed 2:
```shell
onegame run --headless --frames 600 --seed 1 --json
onegame run --headless --frames 600 --seed 1 --json
onegame run --headless --frames 600 --seed 2 --json
```
```text
{"ok":true,"game":"kite","headless":true,"frames":600,"tick":600,"hash":"84346c26e71b43aa","width":1280,"height":720}
{"ok":true,"game":"kite","headless":true,"frames":600,"tick":600,"hash":"84346c26e71b43aa","width":1280,"height":720}
{"ok":true,"game":"kite","headless":true,"frames":600,"tick":600,"hash":"8927a8ee57bb75a7","width":1280,"height":720}
```
- `Fixed` is a 64-bit fixed-point number with 32 fractional bits. Every operation is integer arithmetic, so results are bit-identical on every platform and runtime. Arithmetic wraps on overflow, like `unchecked` integers. Whole numbers convert implicitly (`Line + metres`). `Fixed.FromRatio(-1, 20)` is −0.05 without a float in sight. `Sqrt`, `Sin`, `Cos`, `Atan2` and `Lerp` are there too, and `FVec2`, `FVec3`, `FQuat` and `Transform3` build on it.
- `World.Rng["wind"]` is a named stream seeded from the world’s seed and its name. Its draws are part of the world’s state, so a replay or a snapshot carries on with the same numbers, and adding a stream never disturbs the others.
- `World.Tick` is the sim’s clock. Sim code never reads the wall clock.
- Views convert for display with `ToSingle()` or `ToDouble()`. Those two are for views and rendering only.
A scenario can check `Fixed` values exactly, as decimal text:
```yaml
name: gusts
description: With seed 1 the kite's flight is the same on every machine, to the last bit.
seed: 1
steps:
- step: 100
expect:
- state: { entity: Kite, property: Height, equals: 2.92985297 }
- state: { entity: Kite, property: Climb, equals: 0.026807537 }
- ui: { id: height, text: "Height 2.9 m of 30 m" }
```
```text
PASS gusts (30 ms, tick 100, hash 00af5f4666d6effb)
1 passed, 0 failed
```
## What the analyzers refuse
The rules are compile errors, so a non-deterministic line can’t reach a test. With `using System;` added, here are four mistakes in the apple tree from [Getting started](https://recursiveai.org/onegame/docs/getting-started/): a `float` synced property, `System.Random` and the clock in `OnTick`, and a view that writes state.
```csharp
[Synced] public partial float Speed { get; private set; }
protected override void OnTick()
{
// ...
Apples += new Random().Next(3);
float rate = DateTime.Now.Second / 60f;
}
[View]
public string Label()
{
Picked = 0;
return $"{Apples} on the tree, {Picked} picked";
}
```
```shell
onegame build
```
```text
/tmp/onegame/broken/Game.cs(15,35): error OG0001: 'Speed' uses a floating-point type, which differs between platforms. Use OneGame.Core.Fixed (or FVec2, FVec3, FQuat, Transform3) for synced state.
/tmp/onegame/broken/Game.cs(41,19): error OG0007: Random.Next is not a deterministic named stream. Use world.Rng["name"] so draws are part of the world state.
/tmp/onegame/broken/Game.cs(41,19): error OG0007: new Random is not a deterministic named stream. Use world.Rng["name"] so draws are part of the world state.
/tmp/onegame/broken/Game.cs(48,9): error OG0008: View code cannot change simulation state: it assigns the synced property 'Picked'. Views read state and send commands; use entity.Send.(...) instead.
/tmp/onegame/broken/Game.cs(42,22): error OG0002: Floating-point value in sim code (DateTime.Now.Second / 60f). Sim code must use OneGame.Core.Fixed; floats belong in [View] code.
/tmp/onegame/broken/Game.cs(42,22): error OG0004: DateTime.Now reads the clock, but the sim advances only by ticks. Use World.Tick, or a Timed value.
build FAILED (6 errors, 0 warnings)
```
`onegame build` exits 3. With `--json` the same diagnostics come as objects with `code`, `message`, `file`, `line` and `column`. Threads, I/O and iterating a hash-ordered collection in sim code are refused the same way.
## Good to know
- Helper code outside an entity is checked when it is marked `[Sim]`. `[SimSafe]` vouches for an API you know is deterministic, and `[SimTrust]` trusts a whole namespace, type or assembly.
- `Timed` moves a value over a span of ticks. The authority writes it once and every node evaluates it for the tick it shows, so motion stays smooth without syncing every tick.
- To prove a game is deterministic, `Determinism.RunTwice`, `SnapshotRestore` and `Replay` compare the hash after every frame: see [Test a game from C#](https://recursiveai.org/onegame/cookbook/csharp-game-tests/).
- The state hash is also how client and server find drift: see [Client/server sync with a dedicated server](https://recursiveai.org/onegame/cookbook/client-server-sync/).
A longer guide: [Deterministic game simulation in C#](https://recursiveai.org/onegame/articles/deterministic-game-simulation-csharp/).
# Test a game from C#
Write xUnit tests for a C# game with onegame’s test API: drive a headless game by id and command, run scenario files, and prove determinism with RunTwice, SnapshotRestore and Replay.
`RecursiveAI.OneGame.Testing` runs the game headless inside the test process, through the same operations as scenarios and live control. `onegame test` runs these tests together with the scenarios.
## The test project
Put it in `tests/` inside the game’s folder. `onegame test` runs every project under the game folder whose name contains “Test”, with `dotnet test --project`.
**`tests/Orchard.Tests.csproj`**
```xml
net10.0
Exe
enable
enable
```
Two more files in the game’s folder. The game’s project must leave the tests out of its own compile:
```xml
Orchard
Orchard
```
And a `global.json` that selects the Microsoft.Testing.Platform runner, which `dotnet test --project` needs on the .NET 10 SDK:
```json
{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}
```
Without it, `onegame test` runs the scenarios, then the test project fails with `MSBUILD : error MSB1001: Unknown switch.` (`Switch: --project`), and the run exits 1.
## The tests
**`tests/TreeTests.cs`**, for the apple tree from [Getting started](https://recursiveai.org/onegame/docs/getting-started/):
```csharp
using OneGame.Testing;
namespace Orchard.Tests;
public class TreeTests
{
private static readonly TestOptions Options = new() { Width = 640, Height = 360, Seed = 7 };
// Pick one apple every 40 frames.
private static void PickNow(TestClient client, int frame)
{
if (frame % 40 == 39)
{
client.Send("Tree", "Pick", "1");
}
}
[Fact]
public void PickingTakesAnAppleFromTheTree()
{
using var cluster = TestCluster.Start(new OrchardGame(), Options);
var client = cluster.Client;
client.Step(300);
var before = int.Parse(client.Get("Tree", "Apples"));
client.Click("pick").Step(1);
Assert.Equal(before - 1, int.Parse(client.Get("Tree", "Apples")));
Assert.Equal("1", client.Get("Tree", "Picked"));
}
[Fact]
public void ThePickApplesScenarioPasses()
{
using var cluster = TestCluster.Start(new OrchardGame(), Options);
var result = cluster.Run("scenarios/pick-apples.yaml");
Assert.True(result.Passed, result.ToString());
}
[Fact]
public void TheSameRunTwiceGivesTheSameHashEveryFrame() =>
Determinism.RunTwice(() => new OrchardGame(), PickNow, 600, Options).ThrowIfDifferent();
[Fact]
public void ASnapshotRestoredHalfwayFinishesOnTheSameHashes() =>
Determinism.SnapshotRestore(() => new OrchardGame(), PickNow, 600, splitFrame: 300, Options).ThrowIfDifferent();
[Fact]
public void ARecordingReplaysToTheSameHashes() =>
Determinism.Replay(() => new OrchardGame(), PickNow, 600, Options).ThrowIfDifferent();
}
```
```shell
onegame test
```
```text
PASS pick-apples (41 ms, tick 302, hash 9a447aae324c764b)
1 passed, 0 failed
Running tests from /tmp/onegame/orchard/tests/bin/Debug/net10.0/Orchard.Tests.dll (net10.0|x64)
/tmp/onegame/orchard/tests/bin/Debug/net10.0/Orchard.Tests.dll (net10.0|x64) passed (334ms)
Test run summary: Passed!
total: 5
failed: 0
succeeded: 5
skipped: 0
duration: 457ms
unit tests: 5 passed, 0 failed, 0 skipped
```
- `TestCluster.Start(game, options)` starts one headless node. `cluster.Client` clicks by id (`Click`), presses keys (`Press`), types (`Type`), sends commands as a player would (`Send`, run on the next tick), steps frames (`Step`) and reads one synced property as text (`Get`). `Call(op, json)` runs any live-control operation, and `Capture` renders a frame.
- `cluster.Run(path)` runs a scenario file against the game and returns its result.
- `Determinism.RunTwice` runs the script in two fresh games and compares the state hash after every frame. `SnapshotRestore` takes a snapshot at `splitFrame`, restores it into a new game and checks the rest of the run lands on the same hashes. `Replay` records the run and replays it. `ThrowIfDifferent()` fails the test with the first frame that differs and both hashes.
- `TestOptions.Seed` fixes the world’s seed; `Width`, `Height` and `Scale` set the window, and `Gpu` set to off renders captures on the CPU.
## Good to know
- `RecursiveAI.OneGame.Live` is the development tools package (scenario runs, captures, replays) that a Debug build of the game carries and the test API drives.
- `onegame test --no-unit` skips the C# tests; `dotnet test --project tests/Orchard.Tests.csproj` runs only them.
- To test servers and clients as separate processes, `TestCluster.Launch(spec)` starts a cluster from the same spec as `onegame cluster`: see [Client/server sync with a dedicated server](https://recursiveai.org/onegame/cookbook/client-server-sync/).
# Start tests from fixtures and snapshots
Give tests a ready-made world with named fixtures, save a running game as a snapshot, and start a scenario from either, instead of playing up to the interesting moment every time.
## Fixtures: worlds built in code
A fixture is a named function that fills a new world. Add one to the apple tree from [Getting started](https://recursiveai.org/onegame/docs/getting-started/), with a sim method on `Tree` to build it:
```csharp
// Sim code a fixture calls to build a world; not a command, so no player can send it.
public void Ripen(int apples) => Apples = apples;
```
And in `OrchardGame`:
```csharp
// Ready-made worlds that scenarios, tests and live control load by name.
public override void DefineFixtures(FixtureSet fixtures) =>
fixtures
.Add("heavy-tree", world => world.Add(new Tree()).Ripen(40))
.Add("bare-tree", world => world.Add(new Tree()));
```
A scenario starts from it with `fixture:`:
```yaml
name: heavy-tree
description: Start from a tree with 40 apples and pick ten.
fixture: heavy-tree
steps:
- command: { entity: Tree, name: Pick, args: [10] }
- step: 1
expect:
- state: { entity: Tree, property: Apples, equals: 30 }
- ui: { id: apples, text: "30 on the tree, 10 picked" }
```
```text
PASS heavy-tree (32 ms, tick 1, hash 78e6a5eeffa093c0)
1 passed, 0 failed
```
`fixture:` also works as a step, to switch worlds mid-scenario, and live control loads one with `fixture_load`.
## Snapshots: a saved game
A snapshot is the whole world at one tick: every entity, the random streams and the tick, with its state hash. Save one from a running game over live control. This session uses the small MCP client from [Drive a running game over MCP](https://recursiveai.org/onegame/cookbook/live-control-mcp/), which prints each tool call and its result:
```text
fixture_load {"name": "heavy-tree"} -> {"frame":0,"tick":0,"hash":"5a1f6ada5d6e7e3e"}
command_send {"entity": "Tree", "command": "Pick", "args": ["5"]} -> {"entity":"Tree#1","command":"Pick","status":"Queued"}
step {"frames": 120} -> {"frame":120,"tick":120,"hash":"8984cf483a6e339b"}
state_query {"entity": "Tree"} -> {"id":1,"type":"Tree","Apples":43,"Picked":5,"Growth":0}
snapshot_save {"path": "snapshots/after-pick.ogs"} -> {"path":"/tmp/onegame/orchard/artifacts/snapshots/after-pick.ogs","bytes":103,"hash":"8984cf483a6e339b","tick":120}
reset {} -> {"frame":120,"tick":0,"hash":"6554bfe7b94da5ca"}
state_query {"entity": "Tree"} -> {"id":1,"type":"Tree","Apples":0,"Picked":0,"Growth":0}
snapshot_load {"path": "snapshots/after-pick.ogs"} -> {"frame":120,"tick":120,"hash":"8984cf483a6e339b"}
state_query {"entity": "Tree"} -> {"id":1,"type":"Tree","Apples":43,"Picked":5,"Growth":0}
quit {} -> {"frame":120,"tick":120,"hash":"8984cf483a6e339b"}
```
The loaded world has the hash it was saved with. File paths are relative to the game’s output folder (`artifacts/` unless the game was started with `--artifacts`), and a path that leaves it is refused.
Copy the file next to the scenarios and start from it with `snapshot:`:
```yaml
name: from-snapshot
description: Carry on from a saved game with 43 apples and 5 picked, at tick 120.
snapshot: snapshots/after-pick.ogs
steps:
- command: { entity: Tree, name: Pick, args: [3] }
- step: 1
expect:
- state: { entity: Tree, property: Picked, equals: 8 }
- tick: 121
```
```text
PASS from-snapshot (29 ms, tick 121, hash 4ad2aa9eebde135b)
1 passed, 0 failed
```
The scenario carries on from tick 120, so after one step it is at tick 121.
## Good to know
- A snapshot loads only into a game with the same seed. After a `reset` with another seed, `snapshot_load` fails with `bad_data`: “The save could not be loaded: SeedMismatch (None).”
- In a single-player game a save is a snapshot: `GameInstance.SaveSnapshot()` returns the bytes and `LoadSnapshot(bytes)` restores them. Snapshots are versioned. A `[Migrate]` method on an entity loads state saved by an older schema.
- `Determinism.SnapshotRestore` proves a game survives a save and load mid-run: see [Test a game from C#](https://recursiveai.org/onegame/cookbook/csharp-game-tests/).
- Loading a fixture or a snapshot replaces the world, so a running recording starts a new segment there and replays still match.
# Client/server sync with a dedicated server
Make a C# game with a dedicated server, export the server as a NativeAOT program, run it with two clients over UDP and WebSocket on a simulated network, and check that every replica agrees with the server.
Clients and servers run the same engine. The server holds the authority world. Each client holds a replica of it that the server keeps up to date with deltas, and sends its commands to the server. The state hash is always on, so a replica that drifts from the server is caught at the tick it happens. All of this works from the NuGet packages.
## A game with a server
```shell
onegame new harbor --server
```
```text
created /tmp/onegame/harbor
cd /tmp/onegame/harbor
onegame build
onegame run --headless --frames 60
onegame test
```
With `--server` the scaffold is a counter rather than the ship. The game’s simulation goes in `Sim.cs`, which both the game and the server compile:
```csharp
using OneGame.Core;
namespace Harbor;
/// The state of the game: one synced number and a command that changes it.
[Entity]
public partial class Counter : Entity
{
[Synced] public partial int Count { get; private set; }
[Command]
public void Increment(int by) => Count += by;
}
```
`server/` is a small project of its own. Its `Program.cs` is the whole server:
```csharp
using Harbor;
using OneGame.Server;
using OneGame.Server.Live;
// The whole server: listen, tick, serve live control. See server.yaml for the options (or pass them on the command line).
return ServerApp.Run(
new ServerSetup
{
RegisterEntities = OneGame.Generated.HarborServer.EntityTypes.Register,
SeedWorld = (world, _) => world.Add(new Counter()),
},
args);
```
And `server/server.yaml` configures it:
```yaml
name: harbor
tickRate: 60
scopes:
- { id: 1, seed: 1 }
listen:
- { transport: udp, port: 7777 }
- { transport: ws, port: 7778 }
```
`server/harbor-server.csproj` compiles only `Program.cs` and `../Sim.cs`, so view code must stay out of `Sim.cs`. The game’s project leaves `server/` out of its own compile.
## Export the server
```shell
onegame build
onegame export --server --project server --out ./srv
```
```text
build succeeded (Debug, 0 warnings, 0.98s)
exported server harbor-server 1.0.0 for linux-x64 (nativeaot): 3 files, 4558 KiB
folder: /tmp/onegame/harbor/srv/harbor-server-1.0.0-linux-x64
archive: /tmp/onegame/harbor/srv/harbor-server-1.0.0-linux-x64.tar.gz (2128 KiB)
warning glibc_too_new: HarborServer needs glibc 2.34; a host with an older glibc cannot run it. Build on the oldest system you deploy to.
```
The folder holds `HarborServer`, `run.sh` and the notices. `server.yaml` isn’t copied, so pass it with `--config`. Without it the server listens on no port.
```shell
srv/harbor-server-1.0.0-linux-x64/run.sh --config "$PWD/server/server.yaml" --ticks 120
```
```text
{"ready":true,"pid":2659788,"listen":[{"transport":"udp","address":"0.0.0.0","port":7777},{"transport":"ws","address":"0.0.0.0","port":7778}]}
{"stopped":true,"tick":120,"scopes":[{"id":1,"tick":120,"hash":"d2e4b63e7645dec1","entities":1,"subscribers":0}]}
```
Servers export for linux-x64 only. A client joins a running server with `onegame run -- --connect udp://127.0.0.1:7777`.
## A cluster: one server, two clients
A cluster file names the processes. `p1` connects over UDP with 100 ms of latency and 2% loss; `p2` connects over WebSocket.
**`cluster.yaml`**
```yaml
name: harbor
nodes:
- { name: dock, role: server, exe: srv/harbor-server-1.0.0-linux-x64/HarborServer, listen: udp+ws, scopes: [{ id: 1, seed: 7 }] }
- { name: p1, role: client, exe: bin/Debug/net10.0/Harbor, connect: dock, transport: udp, net: { latencyMs: 100, lossRate: 0.02 } }
- { name: p2, role: client, exe: bin/Debug/net10.0/Harbor, connect: dock, transport: ws }
```
```shell
onegame cluster up --spec cluster.yaml
```
```text
cluster 'harbor' is up (3 nodes); state in /tmp/onegame/harbor/.onegame-cluster/cluster.json
dock server pid 2660610 udp://127.0.0.1:48936 ws://127.0.0.1:48936
p1 client pid 2660616 -> dock (udp)
p2 client pid 2660648 -> dock (ws)
```
Each node is its own process with its own live-control socket. Send a command from `p1`, read the state on `p2`, and compare every replica with the server:
```shell
onegame cluster call p1 command_send '{"entity":"Counter","command":"Increment","args":["5"]}'
onegame cluster call p2 state_query '{"entity":"Counter"}'
onegame cluster hashes
```
```text
{"entity":"Counter#1","command":"Increment","status":"Forwarded"}
{"id":1,"type":"Counter","Count":5}
p1 client scope 1 tick 343 hash 14c501a52ef117df
p2 client scope 1 tick 343 hash 14c501a52ef117df
dock server scope 1 tick 343 hash 14c501a52ef117df
ok p1 vs dock at tick 343: 14c501a52ef117df / 14c501a52ef117df
ok p2 vs dock at tick 343: 14c501a52ef117df / 14c501a52ef117df
```
`hashes` exits 1 on a mismatch or a drift report. `onegame cluster call p1 net_status` shows the client’s connection: round-trip time, retransmits, loss, resyncs and drift. `onegame cluster down` stops every node.
## A cluster scenario
Steps and expectations take a `node:`, and `wait`, `agree` and `drift` check the cluster as a whole:
**`cluster/two-players.yaml`**
```yaml
name: two-players
description: Both players add to the counter; the server and both replicas agree, with no drift.
steps:
- wait: { node: p1, tick: 60, timeout: 20000 }
- command: { entity: Counter, name: Increment, args: [2] }
node: p1
- command: { entity: Counter, name: Increment, args: [3] }
node: p2
- wait: { node: p1, tick: 300, timeout: 20000 }
- wait: { node: p2, tick: 300, timeout: 20000 }
expect:
- state: { entity: Counter, property: Count, equals: 5 }
node: p1
- state: { entity: Counter, property: Count, equals: 5 }
node: p2
- agree: { server: dock, clients: [p1, p2] }
- drift: { nodes: [p1, p2], equals: 0 }
```
```shell
onegame cluster test cluster/two-players.yaml --spec cluster.yaml
```
```text
PASS two-players (4487 ms)
1 passed, 0 failed
```
`cluster test` starts the cluster, runs the scenario and stops it. UI expectations aren’t available in a cluster scenario; check state there and the UI with `cluster call ui_query`.
## Good to know
- `[Synced(Visibility = ...)]` and `[OwnerKey]`/`[TeamKey]` decide who receives an entity or a property: everyone, its owner, its team, or no one. Hidden values are left out of the bytes sent, not just hidden in the client.
- A client that loses its connection keeps reconnecting for 60 seconds and rebuilds its replica from a fresh snapshot.
- Nothing authenticates a player connection at 1.2.0. The cluster binds to loopback; use other machines only on a network you trust.
- `TestCluster.Launch(spec)` starts the same cluster from a C# test.
- For a command to show on the client before the server answers, see [Client-side prediction](https://recursiveai.org/onegame/cookbook/client-side-prediction/).
More in [Driving a game](https://recursiveai.org/onegame/docs/driving-a-game/), section 7.
Choosing between LiteNetLib, Riptide, Photon Fusion and built-in sync: [C# multiplayer networking compared](https://recursiveai.org/onegame/articles/csharp-multiplayer-networking/).
# Client-side prediction
Show a player’s command on their own screen at once while the server decides, with predicted commands, a predicted world beside the replica, and rollback when the server disagrees.
A client’s replica is exactly the server’s state, so a command’s effect shows only after a round trip. Prediction adds a second, writable world beside the replica. The client runs predicted commands on it at once and draws it instead. When confirmed state arrives, the predicted world is rebuilt from it and the commands still in flight are run again. If the server refused one, its effect disappears: that is the rollback.
This builds on the game from [Client/server sync with a dedicated server](https://recursiveai.org/onegame/cookbook/client-server-sync/).
## Mark the command
In `Sim.cs`:
```csharp
using OneGame.Core;
namespace Harbor;
/// The state of the game: one synced number and a command that changes it.
[Entity]
public partial class Counter : Entity
{
[Synced] public partial int Count { get; private set; }
// Predicted: a client runs it on its predicted world at once; the server still decides.
[Command(Predicted = true)]
public void Increment(int by) => Count += by;
}
```
## Turn prediction on and draw the predicted world
In `Game.cs`:
```csharp
using OneGame.Core;
using OneGame.Host;
using OneGame.Sync;
using OneGame.UI;
namespace Harbor;
public sealed class HarborGame : GameDefinition
{
private Prediction? _prediction;
public override string Name => "harbor";
public override void RegisterEntities(EntityRegistry registry) => OneGame.Generated.Harbor.EntityTypes.Register(registry);
public override void Setup(World world) => world.Add(new Counter());
// Runs every frame of a connected game, after the network was pumped: turn prediction on once the replica exists.
public override void OnFrame(GameInstance game)
{
if (_prediction is null && game.Replicas.Count > 0)
{
var registry = new EntityRegistry();
RegisterEntities(registry);
var predicted = new World(game.Seed, registry, WorldRole.Predicted);
_prediction = game.Replicas[0].EnablePrediction(predicted, new PredictionOptions());
}
}
public override void DefineScreens(ScreenSet screens) =>
screens.Add("main", game => Ui.Column(
Ui.Label(() => Shown(game).Entities.Count > 0 ? "Count: " + CounterIn(Shown(game)).Count : "connecting").Named("count"),
Ui.Button("Add one", () => CounterIn(Shown(game)).Send.Increment(1)).Named("add").Padding(Length.Px(8)).Background(Color.Rgb(0x3060A0)))
.Padding(Length.Px(24)).Gap(Length.Px(12)).Background(Color.Rgb(0x203040)).TextColor(Color.White));
// Draw the predicted world when there is one, else the game's own (single player, or the confirmed replica).
private World Shown(GameInstance game) => _prediction?.World ?? game.World;
private static Counter CounterIn(World world) => (Counter)world.Entities[0];
}
```
- `OnFrame` runs once per frame of a game connected to a server. The predicted world needs the same entity types as the replica, so it gets its own registry filled by `RegisterEntities`.
- `EnablePrediction` returns a `Prediction`. Reading `Prediction.World` is what rebuilds it when confirmed state has changed, so read it from the frame, as the view does here.
- `Send.Increment` works from an entity of either world. The command goes to the server exactly as before, and a predicted command also runs on the predicted world at once.
- Played alone (no server), `game.Replicas` is empty, so the game draws its own world and nothing changes.
## See it
To make the gap easy to see, give `p1` a 1500 ms link in `cluster-lag.yaml`:
```yaml
name: harbor
nodes:
- { name: dock, role: server, exe: srv/harbor-server-1.0.0-linux-x64/HarborServer, listen: udp+ws, scopes: [{ id: 1, seed: 7 }] }
- { name: p1, role: client, exe: bin/Debug/net10.0/Harbor, connect: dock, transport: udp, net: { latencyMs: 1500 } }
- { name: p2, role: client, exe: bin/Debug/net10.0/Harbor, connect: dock, transport: ws }
```
Rebuild the game and the server, start the cluster, click on `p1`, then read what it shows and what it has confirmed:
```shell
onegame build
onegame export --server --project server --out ./srv
onegame cluster up --spec cluster-lag.yaml
onegame cluster call p1 ui_click '{"id":"add"}'
onegame cluster call p1 ui_query '{"selector":"#count"}'
onegame cluster call p1 state_query '{"entity":"Counter"}'
```
```text
{"focused":"Button#add","hovered":"Label","tick":527}
[{
"kind": "Label",
"id": "count",
"text": "Count: 1",
...
}]
{"id":1,"type":"Counter","Count":0}
```
The label already says 1, drawn from the predicted world, while the confirmed replica still holds 0. Three seconds later the server has run the command and both clients and the server agree:
```text
{"id":1,"type":"Counter","Count":1}
{"id":1,"type":"Counter","Count":1}
p1 client scope 1 tick 727 hash 3047118b10da5c0c
p2 client scope 1 tick 727 hash 3047118b10da5c0c
dock server scope 1 tick 727 hash 3047118b10da5c0c
ok p1 vs dock at tick 727: 3047118b10da5c0c / 3047118b10da5c0c
ok p2 vs dock at tick 727: 3047118b10da5c0c / 3047118b10da5c0c
```
The first two lines are `state_query` on `p1` and on `p2`; the rest is `onegame cluster hashes`.
## Good to know
- The server stays the authority. The command log, recordings, replays and drift hashes come from the server and never see the predicted world.
- `PredictionOptions.LeadTicks` sets how far the predicted clock runs ahead of the confirmed one, and `AutoLead` (on by default) measures it from the round trip. The `Prediction` counts `CommandsPredicted`, `Reconciliations`, `Rollbacks` and `ResimulatedTicks`.
- A predicted command that needs values this client doesn’t receive (hidden properties) is a compile error. One that draws random numbers is predicted only when the scope sends its random streams.
- Sim state is never blended. To smooth a correction, a view can use the `Reconciling` and `Reconciled` events to ease what it draws.
- This is client-side prediction against a server, with reconciliation. It isn’t peer-to-peer input rollback of the kind fighting games use.
A longer guide: [Client-side prediction in C#](https://recursiveai.org/onegame/articles/client-side-prediction-csharp/).
How this compares with LiteNetLib, LiteEntitySystem and Photon Fusion: [C# multiplayer networking](https://recursiveai.org/onegame/articles/csharp-multiplayer-networking/).
# Drive a running game over MCP
Control a running Debug build of a C# game from an MCP client or a script: read state, click by id, step time, send commands, load fixtures and capture frames over the onegame live-control protocol.
Debug builds of a game listen for live control on a local socket. `onegame mcp` bridges that socket to an MCP server on stdio, so any MCP client, or a script that speaks JSON-RPC, can drive the game with the same operations scenarios use. Release builds and exports contain none of this.
## From an MCP client
Point the client at the bridge. With `--project` it builds the game, starts it in a window and bridges to it:
```json
{ "mcpServers": { "orchard": { "command": "onegame", "args": ["mcp", "--project", "."] } } }
```
`--socket ` attaches to a game that is already running instead. With neither, the bridge attaches to the only live game.
## From a script
Start the game headless with a live socket. It waits for `step` instead of running on the clock (add `--realtime` to let it run):
```shell
onegame run --headless -- --live /tmp/onegame/orchard.sock
```
```text
listening on /tmp/onegame/orchard.sock (MCP, newline-delimited JSON-RPC); connect with: onegame mcp --socket /tmp/onegame/orchard.sock
```
A small MCP client in Python, for the apple tree from [Getting started](https://recursiveai.org/onegame/docs/getting-started/):
```python
import json, subprocess, sys
# Start the MCP bridge with this script's arguments (here --socket ); it speaks JSON-RPC on stdio.
bridge = subprocess.Popen(["onegame", "mcp", *sys.argv[1:]], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
next_id = 0
def rpc(method, params=None):
global next_id
next_id += 1
bridge.stdin.write(json.dumps({"jsonrpc": "2.0", "id": next_id, "method": method, "params": params or {}}) + "\n")
bridge.stdin.flush()
return json.loads(bridge.stdout.readline())
def call(name, args=None):
result = rpc("tools/call", {"name": name, "arguments": args or {}})
print(f"{name} {json.dumps(args or {})} -> {result['result']['content'][0]['text'][:200]}")
rpc("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "probe", "version": "1"}})
bridge.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n")
bridge.stdin.flush()
tools = rpc("tools/list")["result"]["tools"]
print(len(tools), "tools")
call("info")
call("step", {"frames": 300})
call("state_query", {"entity": "Tree"})
call("ui_click", {"id": "pick"})
call("step", {"frames": 1})
call("state_query", {"entity": "Tree"})
call("capture_frame", {"path": "shots/live.png", "size": "360p"})
call("quit")
```
```shell
python3 mcp_probe.py --socket /tmp/onegame/orchard.sock
```
```text
51 tools
info {} -> {"game":"orchard","tick":0,"frame":0,"hash":"6554bfe7b94da5ca","width":1280,"height":720,"scale":1,"headless":true,"screen":"main","entities":1,"screens":["main"],"fixtures":["heavy-tree","bare-tree"]
step {"frames": 300} -> {"frame":300,"tick":300,"hash":"d0045829889cf126"}
state_query {"entity": "Tree"} -> {"id":1,"type":"Tree","Apples":13,"Picked":0,"Growth":0}
ui_click {"id": "pick"} -> {"focused":"Button#pick","hovered":"Label","tick":300}
step {"frames": 1} -> {"frame":301,"tick":301,"hash":"80894b787970351f"}
state_query {"entity": "Tree"} -> {"id":1,"type":"Tree","Apples":12,"Picked":1,"Growth":1}
capture_frame {"path": "shots/live.png", "size": "360p"} -> {"path":"/tmp/onegame/orchard/artifacts/shots/live.png","width":640,"height":360,"scale":1,"sha256":"c944c46957490070d825687d2346bb511c55d0ae15387a4938a5bd100dc03e16","renderer":"NVIDIA GeForce RTX 40
quit {} -> {"frame":301,"tick":301,"hash":"80894b787970351f"}
```
The script cuts each result at 200 characters. The hash after 300 steps, `d0045829889cf126`, is the one `onegame run --headless --frames 300 --json` prints: a live session is the same deterministic game.
## The tools
The 51 tools at 1.2.0:
| Area | Tools |
|---|---|
| Read | `info`, `hash`, `state_query`, `commands_list`, `ui_tree`, `ui_query`, `ui_hit`, `net_status`, `actions` |
| Change state | `command_send`, `state_patch`, `step`, `reset`, `fixture_load`, `snapshot_load`, `screen_open` |
| Input | `ui_click`, `ui_tap`, `ui_invoke`, `input_pointer`, `input_key`, `input_text`, `input_composition`, `input_nav`, `input_pad`, `input_touch`, `clipboard_get`, `clipboard_set` |
| Output | `capture_frame`, `capture_sequence`, `snapshot_save`, `audio_capture` |
| Checks | `theme_lint`, `audio`, `audio_events`, `shaders`, `render_graph`, `assets`, `draw_view` |
| Network | `net_wait`, `net_diverge` (corrupts a replica on purpose, to prove drift detection) |
| 3D | `camera`, `pick`, `project` |
| Platform | `ui_scale`, `quality`, `app_lifecycle`, `platform_native`, `store`, `steam_input` |
| Session | `quit` |
New in 1.2.0: `actions` reports the input actions and presses one by name, `input_pad` holds gamepad buttons and sticks, `assets` lists loaded files and reloads them, and `draw_view` reports what the 2D views drew. See [Input actions](https://recursiveai.org/onegame/cookbook/input-actions/) and [Draw sprites, shapes and text](https://recursiveai.org/onegame/cookbook/draw-sprites-2d/).
Errors come back as `{"error":{"code","message"}}` with a stable code such as `bad_args`, `not_found` or `path_outside`. File paths are relative to the game’s output folder (`artifacts/` by default).
## Good to know
- `--live` needs a Debug build: a Release build exits 5 with `no_dev_tools`.
- A windowed game can be driven the same way while it presents (`onegame run -- --live --realtime`). `capture_frame` with `"presented": true` returns the frame the window shows.
- Each node of a cluster has its own socket. `onegame cluster call ` runs one tool, and `onegame mcp --cluster .onegame-cluster` serves every node, with a `node` argument on each tool. See [Client/server sync with a dedicated server](https://recursiveai.org/onegame/cookbook/client-server-sync/).
- The socket is local. Live control over the network (`--live tcp:*:PORT`) is for devices on a network you trust.
More in [Driving a game](https://recursiveai.org/onegame/docs/driving-a-game/), sections 5 and 6.
# Draw sprites, shapes and text with Draw2D
Draw a C# game’s entities in 2D from view code with onegame’s Draw2D: an animated sprite sheet loaded from a PNG, rectangles, lines, circles and text, a camera that follows, a screenshot test, and hot reload of the picture while the game runs.
New in 1.2.0. An entity that implements `IView2D` gets a `[View]` method that draws it every frame through `Draw2D`. The default 2D stage collects what every entity drew, batches it into one scene and shows it under the UI, so a game needs no render stage of its own. The scaffold’s ship from [Getting started](https://recursiveai.org/onegame/docs/getting-started/) is drawn this way with a single `d.Sprite("art/ship.png", ...)`; this recipe uses more of the API.
## The game
A rover drives along a road. `art/rover.png` is a 64×16 PNG strip of four 16×16 frames, the wheels turning a little more in each. Any picture laid out that way works. Create the game with `onegame new rover`, delete the ship’s scenarios and `art/ship.png`, and replace `Game.cs`:
```csharp
using System.Numerics;
using OneGame.Core;
using OneGame.Host;
using OneGame.Render2D;
using OneGame.UI;
namespace Rover;
/// A rover on a long road. The sim moves it in fixed point; the view draws it from a sprite sheet.
[Entity]
public partial class Rover : Entity, IView2D
{
[Synced] public partial Fixed X { get; private set; }
[Synced] public partial int Speed { get; private set; }
[Command]
public void Drive(int speed) => Speed = speed;
protected override void OnTick() => X += Speed;
// A view field, not synced state: GPU objects never go in the world.
private SpriteSheet? _sheet;
[View]
public void Draw(Draw2D d)
{
if (_sheet is null)
{
// art/rover.png is a strip of four 16x16 frames. Nearest keeps pixel art sharp when scaled.
_sheet = SpriteSheet.Grid(d.Texture("art/rover.png", TextureFilter.Nearest), 16, 16);
_sheet.AddClip("idle", 0, 1, fps: 1);
_sheet.AddClip("roll", 0, 4, fps: 12);
}
var at = new Vector2(X.ToSingle(), 0);
d.Follow(at); // the camera keeps the rover in the middle of the screen
// The road and a post every 100 units, so the world scrolls past.
var first = (int)(at.X / 100) * 100 - 800;
d.FillRect(new Rect(first, 32, 1700, 6), Color.Rgb(0x6B5B45));
for (var x = first; x < first + 1700; x += 100)
{
d.Line(new Vector2(x, 32), new Vector2(x, 8), Color.Rgb(0x8A7A62), thickness: 3);
}
d.FillCircle(new Vector2(at.X + 220, -120), 40, Color.Rgb(0xF2D16B), z: -1); // a sun, behind
d.Sprite(_sheet, Speed == 0 ? "idle" : "roll", at, scale: 4, flip: Speed < 0 ? SpriteFlip.Horizontal : SpriteFlip.None);
d.Text($"{X.ToInt()} m", at - new Vector2(0, 40), 16, Color.White, anchor: new Vector2(0.5f, 1));
}
}
public sealed class RoverGame : GameDefinition
{
public override string Name => "rover";
public override void RegisterEntities(EntityRegistry registry) => OneGame.Generated.Rover.EntityTypes.Register(registry);
public override void Setup(World world) => world.Add(new Rover()).Drive(2);
public override void DefineScreens(ScreenSet screens) => screens.Add("main", game => Ui.Column());
}
```
- **The sim stays fixed-point.** `X` is a `Fixed` that only `OnTick` writes. `Draw` converts it with `ToSingle()` to place the sprite. Floats are fine in view code, and assigning `X` there is a compile error.
- **`d.Texture(path, filter)`** loads a file through the game’s assets, cached by path, so calling it every frame costs nothing. `TextureFilter.Nearest` keeps pixel art sharp at `scale: 4`.
- **`SpriteSheet.Grid(texture, 16, 16)`** cuts the strip into frames, and `AddClip` names runs of them. `d.Sprite(sheet, "roll", ...)` picks the clip’s frame from view time (`d.Time`, ticks / 60), so the animation is the same in every capture and replay. The sheet is a view field. GPU objects never go in synced state.
- **Shapes** (`FillRect`, `StrokeRect`, `Line`, `FillCircle`, `StrokeCircle`) are sprites of textures the stage makes, so they batch with everything else. Draw order is `z`, then call order: the sun’s `z: -1` puts it behind. Text is drawn after all sprites.
- **`d.Follow(at)`** moves the camera to the rover. World `(0, 0)` starts in the middle of the screen, with y pointing down. `d.Camera` also has `Zoom`, `Rotation`, `Bounds` and `Smoothing`.
- `d.Text` takes a world position and a size in world units, and `d.ScreenText` pixels.
```shell
onegame capture --out shots/road.png --size 720p --after 90 --json
```
```text
{"ok":true,"result":{"path":"/tmp/onegame/rover/shots/road.png","width":1280,"height":720,"scale":1,"sha256":"d05161bc9379d51a7f77e46f33bab75a4542343e1bdf59becfd085be4220c989","renderer":"NVIDIA GeForce RTX 4070","tick":90,"hash":"828d338664c42a74"}}
```
The picture shows the rover in the middle of a dark screen, on a brown road with a post every 100 pixels, “180 m” above it and a yellow sun up and to the right. After 90 ticks at 2 per tick the rover is at 180, and the camera has kept it centered.
## Test it with a screenshot
**`scenarios/road.yaml`**
```yaml
name: road
description: After 1.5 seconds the rover is 180 m down the road, drawn from art/rover.png.
window: 640x360
steps:
- step: 90
expect:
- state: { entity: Rover, property: X, equals: 180 }
- capture: { reference: refs/road.png, tolerance: 2, max-diff: 0.001 }
- asset: { path: art/rover.png, state: ready, version: 1 }
```
```shell
onegame test
onegame test --update-references
onegame test
```
```text
FAIL road (335 ms, tick 90, hash 828d338664c42a74)
expect capture (line 8): No reference image at /tmp/onegame/rover/scenarios/refs/road.png. Run with --update-references (or ONEGAME_UPDATE_REFERENCES=1) to create it from the current frame, then look at it.
expected: a reference image
actual: none
0 passed, 1 failed
PASS road (360 ms, tick 90, hash 828d338664c42a74)
created reference /tmp/onegame/rover/scenarios/refs/road.png
1 passed, 0 failed
PASS road (360 ms, tick 90, hash 828d338664c42a74)
1 passed, 0 failed
```
Order matters in `expect`. A headless scenario draws nothing while it steps, so the picture is loaded by the first drawn frame, the capture. With the `asset:` line above the `capture:` line the run fails with “art/rover.png is not loaded”. The scaffold’s `ship-looks-right` scenario has them in this order too. [Screenshot tests](https://recursiveai.org/onegame/cookbook/screenshot-tests/) covers references and tolerances.
## What the view drew
The `draw_view` tool reports the last drawn frame: sprite and text counts, view time and the camera. It also converts a `screen` pixel to a world point and a `world` point to a pixel. This uses the MCP client from [Drive a running game over MCP](https://recursiveai.org/onegame/cookbook/live-control-mcp/), with the 200-character cut taken out:
```text
step {"frames": 90} -> {"frame":90,"tick":90,"hash":"828d338664c42a74"}
capture_frame {"path": "shots/live.png", "size": "720p"} -> {"path":"/tmp/onegame/rover/artifacts/shots/live.png","width":1280,"height":720,"scale":1,"sha256":"d05161bc9379d51a7f77e46f33bab75a4542343e1bdf59becfd085be4220c989","renderer":"NVIDIA GeForce RTX 4070","tick":90,"hash":"828d338664c42a74"}
draw_view {"screen": [640, 360], "world": [400, -120]} -> {"stage":"render2d","sprites":20,"texts":1,"tick":90,"time":1.5,"width":1280,"height":720,"position":[180,0],"zoom":1,"rotation":0,"world":[180,0],"screen":[860,240]}
assets {} -> {"assets":[{"path":"art/rover.png","kind":"texture","version":1,"references":1,"state":"ready","digest":"6b11f86d558ed3ae","file":"/tmp/onegame/rover/art/rover.png"}],"events":[{"sequence":1,"type":"loaded","path":"art/rover.png","kind":"texture","version":1}],"lastSequence":1,"dataDigest":"cbf29ce484222325","changed":[],"applied":0}
quit {} -> {"frame":90,"tick":90,"hash":"828d338664c42a74"}
```
Twenty sprites: the road, 17 posts, the sun and the rover. The camera is at the rover, `(180, 0)`, so the screen’s center pixel is that world point, and the sun’s center `(400, -120)` lands at pixel `(860, 240)`. Before the `capture_frame`, `draw_view` reports zeros, because a headless `step` doesn’t draw.
## Change the picture while it runs
In a Debug build, a file loaded through the assets is swapped in place when it changes on disk. In a window that happens in the next frame. Here the same session paints the rover’s body red and saves over `art/rover.png` between two captures. The `assets` call checks the files:
```text
step {"frames": 90} -> {"frame":90,"tick":90,"hash":"828d338664c42a74"}
capture_frame {"path": "shots/before.png", "size": "360p"} -> {"path":"/tmp/onegame/rover/artifacts/shots/before.png","width":640,"height":360,"scale":1,"sha256":"3e2f08ba19a34492e8fec2bd62056a26fd5e874c328efaf39169bfd31a8caae7","renderer":"NVIDIA GeForce RTX 4070","tick":90,"hash":"828d338664c42a74"}
assets {} -> {"assets":[{"path":"art/rover.png","kind":"texture","version":2,"references":1,"state":"ready","digest":"2bf3a90871dbb893","file":"/tmp/onegame/rover/art/rover.png"}],"events":[{"sequence":1,"type":"loaded","path":"art/rover.png","kind":"texture","version":1},{"sequence":2,"type":"reloaded","path":"art/rover.png","kind":"texture","version":2}],"lastSequence":2,"dataDigest":"cbf29ce484222325","changed":["art/rover.png"],"applied":0}
capture_frame {"path": "shots/after.png", "size": "360p"} -> {"path":"/tmp/onegame/rover/artifacts/shots/after.png","width":640,"height":360,"scale":1,"sha256":"68092dcffee767453cac68442a3e017fae6516c83f27011c5721bdcd721ea0d5","renderer":"NVIDIA GeForce RTX 4070","tick":90,"hash":"828d338664c42a74"}
quit {} -> {"frame":90,"tick":90,"hash":"828d338664c42a74"}
```
The texture is now version 2 and `after.png` shows a red rover. The tick and the state hash didn’t move: a picture is view state, so reloading it never changes the world. In a scenario, `assets: { reload: art/rover.png }` reloads the file, and `asset: { path: art/rover.png, version: 2 }` then passes.
## Good to know
- A damaged file keeps the old picture and reports the error in `assets`. A file missing at the first load throws.
- `SpriteSheet.Frames(texture, rects)` takes frames of any size. A grid sheet over an asset is cut again when a reload changes the image size.
- Keep `d.Camera.Smoothing` at 0 (the default) where captures must repeat: smoothing depends on how many frames were drawn.
- Exports read `art/` beside the program and don’t watch it. Web, Android and iOS builds don’t read files from disk at 1.2.0: see [Export for the web, Android and iOS](https://recursiveai.org/onegame/cookbook/export-web-android-ios/).
- For layers, tilemaps or your own materials, `d.Scene` is the frame’s `Scene2D`, and `d.Sprite(in Sprite)` takes a hand-built sprite.
Every call is in the engine’s [2D rendering notes](https://github.com/recursive-ai-oss/onegame/blob/v1.2.0/docs/interfaces/render2d.md) and [assets notes](https://github.com/recursive-ai-oss/onegame/blob/v1.2.0/docs/interfaces/assets.md).
See both in a complete game: [C# game development without an editor](https://recursiveai.org/onegame/articles/csharp-game-development-without-editor/).
# Input actions for keyboard, gamepad and tests
Declare a C# game’s input as named actions with default bindings for the keyboard and a gamepad, read them in view code, test them with scenario steps for keys, sticks and actions, inspect them live, and load a player’s saved bindings.
New in 1.2.0. A game declares actions such as `move` and `boost` once, each with its default bindings, and reads the actions rather than keys. The same declaration answers the keyboard, any connected gamepad, Steam Input and the test harness. The scaffold that `onegame new` writes already works this way. This recipe starts from it, as `onegame new pilot`.
## Declare and read
In `Game.cs`, the scaffold’s two methods on the game’s `GameDefinition`, with loading of saved bindings added at the end of `DefineActions`:
```csharp
// Input is declared as actions with default bindings. One declaration answers the keyboard, a gamepad and the test harness.
public override void DefineActions(ActionMap actions)
{
actions.Context("play", c => c
.Vector("move", Bind.Wasd, Bind.Arrows, Bind.LeftStick)
.Button("boost", Bind.Key(Key.Space), Bind.Pad(GamepadButton.South)));
// The player's saved bindings, on top of the defaults. A missing file leaves the defaults.
var loaded = actions.Load(ActionMap.DefaultPath(Name));
foreach (var problem in loaded.Problems)
{
Console.Error.WriteLine("bindings: " + problem);
}
}
// View code reads the actions and sends the ship a command when they change.
public override void OnEachFrame(GameInstance game)
{
var ship = ShipOf(game);
var move = game.Actions.Vector("move");
int dx = (int)MathF.Round(move.X), dy = (int)MathF.Round(move.Y);
var boost = game.Actions.Held("boost");
if (dx != ship.Dx || dy != ship.Dy || boost != ship.Boost)
{
ship.Send.Steer(dx, dy, boost);
}
}
```
- **Kinds.** A `Button` is held, pressed or released. An `Axis` runs from -1 to 1. A `Vector` has `X` and `Y`, with y pointing down and length at most 1. Axes and vectors are also `Held` past a threshold (0.5 by default), so a stick can work as a button.
- **Bindings.** `Bind.Key`, `Bind.Pad`, `Bind.PadAxis`, `Bind.Mouse`, `Bind.Wheel`, `Bind.Touch`, and composites: `Bind.Wasd`, `Bind.Arrows`, `Bind.DPad`, `Bind.LeftStick`, `Bind.RightStick`, `Bind.Pair(negative, positive)`. An action takes several bindings, and the strongest wins. A binding of the wrong shape throws when it is declared.
- **Commands, not state.** Actions are view state. `OnEachFrame` reads them and sends the ship a `Steer` command when they change, so recordings, replays and the network see commands, never keys.
- **Contexts.** `actions.Context("menu", ...)` groups actions, and `.OnScreens("menu")` turns a context on only while that screen is open. `c.Tune("move", deadZone: 0.25f)` changes a dead zone or threshold.
## Test it three ways
One scenario steers the ship with a held key, then the left stick, then the actions themselves:
**`scenarios/three-ways.yaml`**
```yaml
name: three-ways
description: The keyboard, a gamepad and the move action steer the ship through the same declaration.
steps:
- action: { name: move, state: up } # start clean: in 1.2.0 a held action carries over from the scenario before
- action: { name: boost, state: up }
- press: { key: d, state: down } # hold D
- step: 10
- press: { key: d, state: up }
- step: 2
- expect:
state: { entity: Ship, property: X, equals: 15 }
- pad: { axis: leftx, value: 1 } # push the left stick right
- step: 10
- pad: { axis: leftx, value: 0 }
- step: 2
- expect:
state: { entity: Ship, property: X, equals: 30 }
- pad: { button: south, state: down } # hold boost on the pad
- action: { name: move, state: down, x: 0, y: 1 }
- step: 10
- expect:
action: { name: boost, held: true }
- expect:
action: { name: move, held: true, y: 1 }
- pad: { button: south, state: up } # let go of everything
- action: { name: move, state: up }
- step: 2
expect:
- state: { entity: Ship, property: Boost, equals: false }
- state: { entity: Ship, property: Y, equals: 30 }
```
```shell
onegame test --no-unit
```
```text
PASS ship-looks-right (375 ms, tick 40, hash a5a533b83e128e35)
PASS steer-ship (4 ms, tick 22, hash 19dbaa512f757607)
PASS three-ways (7 ms, tick 36, hash 1619491e22571156)
3 passed, 0 failed
```
Holding D for ten frames and pushing the stick for ten move the ship the same 15 units at the normal speed of 1.5 a tick. With boost held, ten frames down make 30. Run alone, `three-ways` gives the same hash.
- `press: { key: d, state: down }` holds a key until `state: up`. A bare `press: d` taps it.
- `pad:` drives the harness’s virtual gamepad: `button` takes the names `nav:` uses (`south`, `east`, `start`...), and `axis` takes `leftx`, `lefty`, `rightx`, `righty`, `lefttrigger` or `righttrigger`.
- `action:` drives the action itself, whatever it is bound to: `action: boost` taps a button, and `state: down` holds it until `state: up`. The `action:` expectation checks `held`, `pressed`, `released`, `x` and `y`.
- An `expect:` step takes one expectation.
**At 1.2.0 a held action carries over** into the next scenario of the same `onegame test` run. The scaffold’s `steer-ship` ends with `move` and `boost` still held, so a scenario after it starts boosting. That is why `three-ways` releases both first and lets go of everything at the end.
## Look at them live
The `actions` tool lists every context and action with its state and bindings. Given a `name`, it drives that action as the harness does. With the MCP client from [Drive a running game over MCP](https://recursiveai.org/onegame/cookbook/live-control-mcp/), with the 200-character cut taken out:
```text
step {"frames": 1} -> {"frame":1,"tick":1,"hash":"a7f6119f33887e04"}
actions {} -> {"contexts":[{"name":"play","active":true,"screens":[]}],"actions":[{"name":"move","kind":"vector","context":"play","active":true,"held":false,"pressed":false,"released":false,"x":0,"y":0,"default":true,"bindings":["quad(key:A,key:D,key:W,key:S)","quad(key:ArrowLeft,key:ArrowRight,key:ArrowUp,key:ArrowDown)","stick:left"]},{"name":"boost","kind":"button","context":"play","active":true,"held":false,"pressed":false,"released":false,"x":0,"y":0,"default":true,"bindings":["key:Space","pad:South"]}]}
actions {"name": "boost", "state": "down"} -> {"contexts":[{"name":"play","active":true,"screens":[]}],"actions":[{"name":"move","kind":"vector","context":"play","active":true,"held":false,"pressed":false,"released":false,"x":0,"y":0,"default":true,"bindings":["quad(key:A,key:D,key:W,key:S)","quad(key:ArrowLeft,key:ArrowRight,key:ArrowUp,key:ArrowDown)","stick:left"]},{"name":"boost","kind":"button","context":"play","active":true,"held":false,"pressed":false,"released":false,"x":0,"y":0,"default":true,"bindings":["key:Space","pad:South"]}]}
step {"frames": 1} -> {"frame":2,"tick":2,"hash":"b7cdad13ee4ffa71"}
state_query {"entity": "Ship"} -> {"id":1,"type":"Ship","X":0,"Y":0,"Dx":0,"Dy":0,"Boost":true}
quit {} -> {"frame":2,"tick":2,"hash":"b7cdad13ee4ffa71"}
```
A change shows after the next step: the second `actions` call still reports `boost` up, and one frame later the ship is boosting. Query after a step, too. Before the first frame every action reads `active: false`. `input_pad` holds pad buttons and sticks the same way.
## Saved bindings
A bindings file lists only the actions that differ from their defaults, one line each, in the spelling the `actions` tool shows. `ActionMap.DefaultPath("pilot")` is `~/.local/share/pilot/bindings.txt` on Linux:
```text
onegame-bindings 1
boost = key:B | pad:South
move = quad(key:J,key:L,key:I,key:K) | stick:left
jump = key:Space
```
With that file in place, the game reports the line it skipped on stderr, and `actions` shows the player’s bindings:
```text
bindings: line 4: unknown action 'jump'.
```
```text
step {"frames": 1} -> {"frame":1,"tick":1,"hash":"a7f6119f33887e04"}
actions {} -> {"contexts":[{"name":"play","active":true,"screens":[]}],"actions":[{"name":"move","kind":"vector","context":"play","active":true,"held":false,"pressed":false,"released":false,"x":0,"y":0,"default":false,"bindings":["quad(key:J,key:L,key:I,key:K)","stick:left"]},{"name":"boost","kind":"button","context":"play","active":true,"held":false,"pressed":false,"released":false,"x":0,"y":0,"default":false,"bindings":["key:B","pad:South"]}]}
quit {} -> {"frame":1,"tick":1,"hash":"a7f6119f33887e04"}
```
The test run reads the same file, because `DefineActions` runs there too. D no longer moves the ship, so the key part of `three-ways` fails:
```text
PASS ship-looks-right (389 ms, tick 40, hash a5a533b83e128e35)
PASS steer-ship (4 ms, tick 22, hash 19dbaa512f757607)
FAIL three-ways (3 ms, tick 12, hash b04b131b59df8baa)
step 6 (expect, line 10): expectation_failed: Ship.X is 0, expected 15
expect state (line 11): Ship.X is 0, expected 15
expected: 15
actual: 0
2 passed, 1 failed
```
`action:` and `pad:` steps don’t depend on key bindings, so prefer them in tests. Test key bindings on a machine without a saved file.
- `actions.Save(path)` writes the file through a temporary file. `ToText()` and `LoadText(text)` do the same with a string. A bad line is skipped and listed in `Problems`.
- `actions.SetBinding`, `ResetBindings` and `BeginRebind` (which waits for the next key, button or stick push) change bindings in a running game. `ActionUi.BindingList(game)` builds a list of the actions with their bindings and Rebind and Reset buttons.
- These weren’t run for this page: Steam Input feeding the same actions (`SteamActionSource`), touch regions and the rebinding screen.
Every call is in the engine’s [input notes](https://github.com/recursive-ai-oss/onegame/blob/v1.2.0/docs/interfaces/input.md).
See both in a complete game: [C# game development without an editor](https://recursiveai.org/onegame/articles/csharp-game-development-without-editor/).
# 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;
/// A market stall: gold and the stock of three goods.
[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 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(
[
new TableColumn("Good", g => g.Name, ColumnWidth.Flex(2)),
new TableColumn("Price", g => g.Price.ToString(), ColumnWidth.Fixed(Length.Ui(80)), alignEnd: true),
new TableColumn("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/).
# Controller and keyboard navigation
Make a game screen playable with a gamepad and a keyboard: a default focus, d-pad movement, confirm and back, focus that stays inside a dialog, and scenarios that play the screen with a controller.
The UI owns one focused element. The host turns the gamepad into UI input by itself: South is confirm, East is back, and the d-pad and shoulder buttons navigate. Tab and Shift+Tab move through focusable elements in order, the arrow keys and the d-pad move to the nearest one in that direction, and Enter, Space and confirm click the focused element. Every kit component works this way, so a screen built from the kit needs only a starting focus.
This uses the market stall from [Build a game screen with the UI kit](https://recursiveai.org/onegame/cookbook/game-ui-screen/).
## Give the screen a starting focus
Without one, a controller player’s first press has to find something. The stall starts in the table:
```csharp
// 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);
```
`RequestFocus` waits for the end of the next update, which is what code inside a screen builder or a binding must use. `ui.Focus(element)` focuses at once, and `FocusHelpers.FocusDefault(game.Ui, screen)` focuses the element marked `.AsDefaultFocus()`.
## Play it with a controller
```yaml
name: buy-with-pad
description: A controller player starts in the table, moves to pears and buys two with the confirm button.
steps:
- step: 1
- expect:
ui: { id: goods/body, focused: true }
- nav: down # from apples to pears
- nav: activate # the confirm button: onActivate buys the selected row
- step: 1
- nav: activate
- step: 1
expect:
- state: { entity: Shop, property: "Stock[1]", equals: 6 }
- state: { entity: Shop, property: Gold, equals: 42 }
```
```shell
onegame test --scenario scenarios/buy-with-pad.yaml
```
```text
PASS buy-with-pad (33 ms, tick 3, hash f8fcbc6f2c91948b)
1 passed, 0 failed
```
`nav:` takes `up down left right next previous activate cancel`, or a button by name (`start`, `back`, `west`, `north`, `left_stick`...). Pears cost 4, so two of them take the gold from 50 to 42 and the stock from 8 to 6. In the table, Up and Down move the selection and confirm calls the table’s `onActivate`. At the first or last row the key isn’t used, so focus moves on to the neighbor: that is how a pad player leaves a table.
Without the starting focus, the shoulder button (`nav: next`) walks the focusable elements in order. In this screen the first three are the table’s header cells (`goods/header/0` to `goods/header/2`) and the fourth is `goods/body`.
## Dialogs keep focus inside
The sell-back confirm dialog traps focus: while it is open, Tab and the d-pad stay inside it, the pointer can’t reach what is behind it, and Escape or the back button closes it and returns focus to what had it before. A dialog made with `danger: true` starts on its cancel button, so a stray confirm press doesn’t destroy anything. The [sell-back scenario](https://recursiveai.org/onegame/cookbook/game-ui-screen/) checks this with `press: Escape`, then `press: Tab` and `press: Enter`.
## Good to know
- A component that uses a key itself takes it first: a slider takes Left and Right, a text field the arrows. Unused keys fall back to moving focus.
- Disabled and hidden elements are skipped, and focusing an element scrolls a scroll view to show it.
- The UI guide’s rule of thumb: every action reachable by d-pad and confirm, back always leaves, and a default focus on every screen.
- Input glyphs (“press (A)”) follow the device in use. `KitHost.Dispatch` tells them which one that is.
- Steam Input action sets can be scripted in scenarios with `steam-input:` against a stand-in controller.
Focus, keys and the full input table are in [the UI guide](https://recursiveai.org/onegame/docs/ui-guide/), section 9.
# Check UI contrast
Check a game UI theme against WCAG 2.x AA contrast from the command line or a test, read which token pairs fail, and pick an accent that passes.
The component kit is made of tokens: `--kit-bg`, `--kit-panel`, `--kit-text`, `--kit-accent` and so on. `KitTheme.Create` builds the kit’s sheet from them and moves any text color that would fall short of AA just far enough toward white or black. Colors that aren’t text, such as your accent against the panels, it only checks. `onegame lint-theme` runs the game headless and reports every pair.
## A failing accent
The market stall from [Build a game screen with the UI kit](https://recursiveai.org/onegame/cookbook/game-ui-screen/), with a darker green accent:
```csharp
_kit.SetTheme(KitTheme.Create(KitTokens.Dark with { Accent = Color.Hex("#2F6B4F"), Radius = Length.Ui(6) }));
```
```shell
onegame lint-theme
```
The output is one JSON object, worst failure first (cut here after the failures):
```text
{"passed":false,"checked":45,"failures":[{"name":"accent fill on panel","foreground":"--kit-accent","background":"--kit-panel","foregroundColor":"#2f6b4f","backgroundColor":"#1e2128","ratio":2.56,"required":3,"passed":false,"kind":"indicator"},{"name":"accent fill on field","foreground":"--kit-accent","background":"--kit-field","foregroundColor":"#2f6b4f","backgroundColor":"#171a20","ratio":2.77,"required":3,"passed":false,"kind":"indicator"},{"name":"accent fill on bg","foreground":"--kit-accent","background":"--kit-bg","foregroundColor":"#2f6b4f","backgroundColor":"#14161a","ratio":2.88,"required":3,"passed":false,"kind":"indicator"}],"checks":[...]}
```
It exits 1. The white text on the button passes (6.29:1), but the accent fill itself is a control indicator, which needs 3:1 against the surfaces around it, and this green reaches only 2.56:1 on the panel.
## An accent that passes
A lighter green fixes the fill, but too light and the white button text fails instead. With `#3F8F69` the lint reports `primary button text on accent` at 3.93:1, under the 4.5:1 that text needs. `#367D5B` passes both:
```csharp
_kit.SetTheme(KitTheme.Create(KitTokens.Dark with { Accent = Color.Hex("#367D5B"), Radius = Length.Ui(6) }));
```
```text
{"passed":true,"checked":45,"failures":[],"checks":[...]}
```
The pairs that moved: white text on the accent 4.95:1 (needs 4.5), the accent fill on the panel 3.25:1 and on the background 3.66:1 (need 3). `onegame lint-theme` exits 0, so it can gate CI.
## The same check from C#
```csharp
var report = ThemeLint.Check(ui); // the tokens in effect on the root; ThemeLint.Check(KitTokens) checks a token set before you use it
if (!report.Passed)
{
foreach (var f in report.Failures) // worst first, e.g. "primary button text on accent: --kit-accent-text #d9a441 on --kit-accent, 1.0:1, needs 4.5"
{
Console.WriteLine($"{f.Name}: {f.Foreground} {f.ForegroundColor} on {f.Background} {f.BackgroundColor} = {f.Ratio}:1, needs {f.Required}");
}
}
```
This snippet is from [the UI guide](https://recursiveai.org/onegame/docs/ui-guide/). Live control has the same check as the `theme_lint` tool.
## Good to know
- The ratios checked: text 4.5:1, including text on a switched-on toggle; focus ring, control outlines, accent fills and scroll thumbs 3:1; disabled text 3:1.
- A token you override by hand after the kit’s sheet skips the repair, which is exactly what the lint is for.
- States differ by more than color: hover and pressed change the fill, focus adds a ring outside the control, and disabled controls are flat and dim in every variant.
- The lint checks the theme’s tokens, not every pixel of your own art. Look at a capture as well: see [Screenshot tests](https://recursiveai.org/onegame/cookbook/screenshot-tests/).
# Export for Linux, Windows and macOS
Package a C# game for Linux, Windows and macOS with onegame export: NativeAOT where the host allows it, a folder and an archive per target, and a JSON report of every file to check in CI.
Desktop exports work for a game made from the NuGet packages, with no engine checkout. An export is always the Release build without the development tools: no live control, no scenario runner, no capture or replay options.
## Linux
```shell
onegame export --target linux
```
```text
exported orchard 1.0.0 for linux-x64 (nativeaot): 6 files, 7614 KiB
folder: /tmp/onegame/orchard/export/orchard-1.0.0-linux-x64
archive: /tmp/onegame/orchard/export/orchard-1.0.0-linux-x64.tar.gz (3684 KiB)
skipped TestResults/: not a content folder (shipped folders: fonts, art, data)
skipped shots/: not a content folder (shipped folders: fonts, art, data)
warning glibc_too_new: Orchard needs glibc 2.38, newer than 2.31 in Steam's Linux runtime (and so on the Steam Deck). Build on an older system, or inside the Steam Linux Runtime SDK: see docs/exporting.md.
```
```text
orchard-1.0.0-linux-x64/
Orchard the NativeAOT program
run.sh starts the program from its own folder, from any current directory
THIRD-PARTY-NOTICES.txt always
art/ the game's pictures (here the scaffold's ship.png)
fonts/ the game's fonts and their licences
```
The program gives the same state hash as the Debug build:
```shell
export/orchard-1.0.0-linux-x64/run.sh --headless --frames 60 --json
```
```text
{"ok":true,"game":"orchard","headless":true,"frames":60,"tick":60,"hash":"2408d99a0aa89d5d","width":1280,"height":720}
```
The archive is a `.tar.gz` with one top folder, sorted entries and a fixed timestamp, so the same folder gives the same archive. The `glibc_too_new` warning only matters for Steam: see [Ship a game for Steam and the Steam Deck](https://recursiveai.org/onegame/cookbook/steam-and-steam-deck/).
## Windows and macOS from a Linux host
NativeAOT links with the target system’s toolchain, so from Linux the Windows and macOS exports are self-contained, trimmed CoreCLR builds, and the export says so:
```shell
onegame export --target win
```
```text
exported orchard 1.0.0 for win-x64 (coreclr-self-contained): 59 files, 25408 KiB
folder: /tmp/onegame/orchard/export/orchard-1.0.0-win-x64
archive: /tmp/onegame/orchard/export/orchard-1.0.0-win-x64.zip (11800 KiB)
skipped TestResults/: not a content folder (shipped folders: fonts, art, data)
skipped shots/: not a content folder (shipped folders: fonts, art, data)
warning not_native_aot: NativeAOT for win-x64 needs a Windows host; this is a self-contained, trimmed CoreCLR build. Run the same command on a matching host for the NativeAOT build.
```
```shell
onegame export --target osx
```
```text
exported orchard 1.0.0 for osx-arm64 (coreclr-self-contained): 61 files, 27000 KiB
folder: /tmp/onegame/orchard/export/orchard-1.0.0-osx-arm64
archive: /tmp/onegame/orchard/export/orchard-1.0.0-osx-arm64.tar.gz (11013 KiB)
skipped TestResults/: not a content folder (shipped folders: fonts, art, data)
skipped shots/: not a content folder (shipped folders: fonts, art, data)
warning not_native_aot: NativeAOT for osx-arm64 needs a Mac with Apple silicon host; this is a self-contained, trimmed CoreCLR build. Run the same command on a matching host for the NativeAOT build.
warning not_signed: The bundle was made on a host that is not a Mac, so it is not signed. Apple silicon refuses unsigned programs: sign it on a Mac (codesign --force --sign - ).
```
Run the same commands on Windows, or on an Apple-silicon Mac, for NativeAOT builds. On a Mac the bundle `Orchard.app` is signed ad hoc, which lets it run on the Mac that made it; giving it to other people needs a Developer ID signature and notarization from your Apple developer account, which the tool doesn’t do.
`TestResults/` was left by the C# tests and `shots/` by the screenshot tests. Only `fonts/`, `art/` and `data/` are shipped; other folders are listed under `skipped`.
## Options and the JSON report
`--target linux|win|osx` (default: the host’s), `--out ` (default `export/`), `--project `, `--no-archive`, `--no-aot`, `--steam`. The version in the names is the project’s ``, 1.0.0 when unset.
With `--json` the result lists every file with its size and SHA-256, the `runtime` (`nativeaot` or `coreclr-self-contained`), `devTools: false`, what was `skipped` and the `warnings`. Read the warnings in CI: they say why a build isn’t NativeAOT, a font without a licence text (`font_licence_missing`), or a file over 64 MiB (`large_file`).
## Good to know
- Content folders are copied as they are: `fonts/` takes `.ttf .otf` and their licence texts, `art/` takes `.svg .png .jpg .jpeg .psd .webp`, and `data/` takes anything.
- The game finds `fonts/` and `art/` next to the program, so the working directory doesn’t matter. A picture drawn by path (`d.Sprite("art/ship.png", ...)`) is read from there; an export doesn’t watch it for changes.
- The repository’s docs list the C++ toolchain NativeAOT needs: `clang` and `zlib1g-dev` on Linux, the Visual Studio C++ tools on Windows.
- `--live` on an exported program exits 5 with `no_dev_tools`.
- Dedicated servers export with `--server`, for linux-x64 only: see [Client/server sync with a dedicated server](https://recursiveai.org/onegame/cookbook/client-server-sync/).
- Web, Android and iOS: see [Export for the web, Android and iOS](https://recursiveai.org/onegame/cookbook/export-web-android-ios/).
Every detail is in [Exporting](https://recursiveai.org/onegame/docs/exporting/).
# Ship a game for Steam and the Steam Deck
Export a C# game for Steam on Linux and Windows with the Steamworks library beside it, and build a Linux program that starts on the Steam Deck’s runtime.
## Export with Steam
`--steam` copies Valve’s Steamworks library next to the program. The library is never downloaded and isn’t in the engine’s repository: you supply it from your copy of the Steamworks SDK. Without it the export stops before building:
```shell
onegame export --target linux --steam
```
```text
error: --steam needs libsteam_api.so from your Steamworks SDK. Pass --steam-redist , or set ONEGAME_STEAM_REDIST (folder with linux-x64/libsteam_api.so) or ONEGAME_STEAM_SDK (the SDK folder).
```
The command exits 4 (with `--json`, the code is `steam_redist_missing`). Point it at the SDK:
```shell
ONEGAME_STEAM_SDK=~/steamworks/sdk onegame export --target linux --steam --json
ONEGAME_STEAM_SDK=~/steamworks/sdk onegame export --target win --steam --json
```
`ONEGAME_STEAM_SDK` is the unpacked Steamworks SDK (it reads `redistributable_bin/linux64` and `redistributable_bin/win64`). `--steam-redist ` or `ONEGAME_STEAM_REDIST` instead names a folder holding `linux-x64/libsteam_api.so` and `win-x64/steam_api64.dll`. The Linux folder gets `libsteam_api.so`, the Windows one `steam_api64.dll`. These two commands weren’t run for this page, because the Steamworks SDK is Valve’s and needs your own account; the error above was.
- The game itself must use `OneGame.Steam` for the library to be loaded. That package has the Steamworks bindings, Steam Input, and achievements, stats, cloud saves and presence behind store-neutral services.
- `steam_appid.txt` is never shipped: the export deletes it from the output. With it present Steam’s relaunch check always passes, so the game would run outside the Steam client. To try an exported build by hand, put `steam_appid.txt` in the current directory with the Steam client signed in.
- In the depot, set the Linux launch option to `run.sh` and the Windows one to `.exe`. Uploading depots isn’t part of the export.
- `--steam` isn’t available for macOS.
## The Steam Deck and the Steam Linux runtime
Steam runs Linux games in its Steam Linux Runtime 3 (“sniper”), which has glibc 2.31. A program linked on a newer system won’t start there: `version 'GLIBC_2.38' not found`. The export reads the program and warns when it needs more:
```text
warning glibc_too_new: Orchard needs glibc 2.38, newer than 2.31 in Steam's Linux runtime (and so on the Steam Deck). Build on an older system, or inside the Steam Linux Runtime SDK: see docs/exporting.md.
```
That warning is from a build on a current Linux system. For a build that runs on the Deck, export on a system with glibc 2.31 or older, or inside Valve’s Steam Linux Runtime SDK image.
The engine repository has a script for the second way, `tools/steamrt.sh`, which runs the export and a headless check inside Valve’s public runtime images with bubblewrap (no Steam account needed). It is a repository tool, not part of the NuGet packages, and it mounts an engine checkout as `/engine`, so it needs a checkout at 1.2.0 and a game built against it. Its `run` step is the check: the exported folder read-only in the same image, and a passing run prints the same state hash as `onegame run --headless --frames 60 --json` on the development machine. It doesn’t replace a run on the device for the controller, display and graphics driver.
## Good to know
- On the Deck, a game is played with the gamepad: give every screen a starting focus and make every action reachable with the d-pad and confirm. See [Controller and keyboard navigation](https://recursiveai.org/onegame/cookbook/controller-navigation/).
- `onegame capture --size deck` draws a frame at the Deck’s 1280×800.
- The repository’s `tools/devices/deck.sh` runs a build on a Deck over ssh and checks it; see [Checking on devices](https://recursiveai.org/onegame/docs/devices/).
- Exports for Windows made on Linux aren’t NativeAOT: see [Export for Linux, Windows and macOS](https://recursiveai.org/onegame/cookbook/export-desktop/).
Every Steam detail is in [Exporting](https://recursiveai.org/onegame/docs/exporting/).
# Export for the web, Android and iOS
What the web, Android and iOS exports need at onegame 1.2.0: an engine checkout for all three, plus the Android SDK or a Mac with Xcode, and what each export produces.
**At 1.2.0, the web, Android and iOS exports need an engine checkout.** A game made from the NuGet packages exports for Linux, Windows and macOS, and its dedicated server for Linux. The web export reads engine sources (its page and script files), so it needs a game created against a checkout; Play Billing in an Android build and plugin servers do too. The Android and iOS entry files ship in the SDK package, but those two exports have only been run from a checkout.
## What a game from the packages gets
From the apple tree in [Getting started](https://recursiveai.org/onegame/docs/getting-started/), made with `onegame new` and the packages only:
```shell
onegame export --target web
```
```text
error: The web export needs the engine sources (its page and script files), so it runs for a game that sets OneGameRoot to an engine checkout (onegame new --engine ). A game on the RecursiveAI.OneGame.Sdk package cannot export for the web yet.
```
That exits 3. Android and iOS stop earlier on this Linux machine, at their toolchains, each with exit 4:
```shell
onegame export --target android
```
```text
error: The Android SDK was not found. Set ANDROID_HOME (or pass --sdk ) to a folder with build-tools, platforms, platform-tools and an ndk folder; the command-line tools' sdkmanager installs them.
```
```shell
onegame export --target ios-simulator
```
```text
error: Exporting for iOS links with Xcode's toolchain and the iOS SDK, so it needs a Mac with Xcode (xcode-select -p must point at it).
```
## A game against an engine checkout
Clone the engine at the release tag, build it, and create the game against it:
```shell
git clone --branch v1.2.0 https://github.com/recursive-ai-oss/onegame
cd onegame
dotnet build OneGame.slnx
dotnet run --project src/OneGame.Cli -- new mygame --dir ../mygame --engine .
```
Such a game references the engine’s projects instead of packages. Setting `ONEGAME_ROOT` to the checkout does the same for `onegame new`. These steps are from the engine’s README and weren’t run for this page.
## What each export makes
| Target | Command | Needs | Result |
|---|---|---|---|
| Web | `onegame export --target web` | a checkout | a folder any static server can host: .NET on WebAssembly, WebGPU, the page and its scripts. Serve `.wasm` as `application/wasm`. |
| Android | `onegame export --target android [--abi arm64-v8a,x86_64] [--dev]` | a checkout, the Android SDK and NDK | a debug-signed APK holding a NativeAOT shared library, no Java |
| iOS Simulator | `onegame export --target ios-simulator [--dev]` | a checkout, a Mac with Xcode | an ad-hoc signed `.app` for the Simulator |
| iOS device | `onegame export --target ios` | a checkout, a Mac with Xcode | an unsigned arm64 `.app`, which the owner of an Apple Developer account signs; the steps are printed as `signingNeeds` |
## Good to know
- Drawing from files is desktop only at 1.2.0. The engine’s [assets notes](https://github.com/recursive-ai-oss/onegame/blob/v1.2.0/docs/interfaces/assets.md) say web, Android and iOS builds keep art inside the program, where the default file source finds nothing, and a missing file throws. So the scaffold’s ship, drawn from `art/ship.png`, needs an asset source of your own on those targets. This wasn’t run for this page.
- A page needs a browser with WebGPU: Chrome and Edge 113 and later. Safari and Firefox aren’t tested. Sound starts after the first click or key press.
- `onegame web test` runs a game’s scenarios in headless Chrome (`ONEGAME_CHROME` names the browser), and `onegame android test` and `onegame ios test` run them on an emulator, a device or the Simulator. The engine’s own runs are in [Checking on devices](https://recursiveai.org/onegame/docs/devices/).
- A game that connects to servers from Android sets `true` in its project file.
- To branch on the platform, ask `OneGame.Platform.RunningOn` (`Android`, `IOS`, `Browser`, `Phone`, `Mobile`, `Desktop`), new in 1.1.2. `OperatingSystem.IsAndroid()` is false in a NativeAOT Android build.
- A scenario capture compares against `name-mobile.png` beside `name.png` on a phone or in a page, when that file exists.
Every target is in [Exporting](https://recursiveai.org/onegame/docs/exporting/).