Recursive AI

Getting started with onegame

All onegame docs

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.1.2 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:

Terminal
dotnet tool install -g RecursiveAI.OneGame.Cli --version 1.1.2
Text
You can invoke the tool using the following command: onegame
Tool 'recursiveai.onegame.cli' (version '1.1.2') 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

Terminal
onegame new orchard
cd orchard
Text
created /tmp/games/orchard
  cd /tmp/games/orchard
  onegame build
  onegame run --headless --frames 60
  onegame test

The folder holds orchard.csproj, Program.cs, Game.cs, scenarios/click-button.yaml, a README.md and fonts/ with Noto Sans Regular and its license. The project file is the whole build setup:

XML
<Project Sdk="RecursiveAI.OneGame.Sdk/1.1.2">
 
  <PropertyGroup>
    <AssemblyName>Orchard</AssemblyName>
    <RootNamespace>Orchard</RootNamespace>
  </PropertyGroup>
 
</Project>

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 walks through the scaffold’s Game.cs line by line.

Run it

Terminal
onegame run --headless --frames 60 --json
Text
{"ok":true,"game":"orchard","headless":true,"frames":60,"tick":60,"hash":"3245c24a6e2d4459","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.

Test it

Terminal
onegame test
Text
PASS click-button (30 ms, tick 2, hash 78c58ac33e3bb232)
1 passed, 0 failed

test runs every file in scenarios/ in a headless game, plus any C# test projects in the folder. 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":1,"failed":0,"scenarios":[{"name":"click-button","source":"/tmp/games/orchard/scenarios/click-button.yaml","passed":true,"tick":2,"hash":"78c58ac33e3bb232","elapsedMs":30,"steps":[{"index":0,"kind":"expect","line":4,"ok":true},{"index":1,"kind":"click","line":6,"ok":true},{"index":2,"kind":"step","line":7,"ok":true},{"index":3,"kind":"click","line":8,"ok":true},{"index":4,"kind":"step","line":9,"ok":true}],"expectations":[{"index":0,"kind":"ui","line":5,"passed":true,"target":"#count","actual":"Count: 0"},{"index":0,"kind":"state","line":11,"passed":true,"target":"Counter.Count","expected":"2","actual":"2"},{"index":1,"kind":"ui","line":12,"passed":true,"target":"#count","actual":"Count: 2"}]}]},"unitTests":{"ran":false,"ok":true,"total":0,"passed":0,"failed":0,"skipped":0,"projects":[]}}

Export it

Terminal
onegame export --target linux
Text
exported orchard 1.0.0 for linux-x64 (nativeaot): 5 files, 7408 KiB
  folder:  /tmp/games/orchard/export/orchard-1.0.0-linux-x64
  archive: /tmp/games/orchard/export/orchard-1.0.0-linux-x64.tar.gz (3583 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, fonts/ and THIRD-PARTY-NOTICES.txt. The development tools are left out. The exported program gives the same hash as onegame run:

Terminal
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":"3245c24a6e2d4459","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.
  • --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.
  • 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.1.2. A game made from the packages gets no_engine_root from --target web. See Export for the web, Android and 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.

C#
using OneGame.Core;
using OneGame.Host;
using OneGame.UI;
 
namespace Orchard;
 
/// <summary>An apple tree. Apples grow on their own; players pick them.</summary>
[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.
  • 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:

Terminal
onegame build
onegame run --headless --frames 300 --json
onegame run --headless --frames 300 --seed 7 --json
Text
build succeeded (Debug, 0 warnings, 1.01s)
{"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/click-button.yaml, which tests the old counter, 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" }
Terminal
onegame test
Text
PASS pick-apples (36 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 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.1.2, MIT or Apache-2.0.

PackageWhat it is
RecursiveAI.OneGame.CliThe onegame command, a .NET tool: create, build, run, test, capture, replay, export.
RecursiveAI.OneGame.SdkThe MSBuild SDK for games: references, rules and export targets, so a game builds from nuget.org without an engine checkout.
RecursiveAI.OneGame.CoreThe entity runtime: fixed-point numbers, random streams and the attributes that declare entities. It carries the source generators and analyzers.
RecursiveAI.OneGame.SyncNodes, scopes, saves, the command log, replay and drift hashes.
RecursiveAI.OneGame.NetReliable UDP and WebSocket transports, and a network conditioner for tests.
RecursiveAI.OneGame.Server, .Server.LiveThe headless server host (fixed-step scopes, YAML configuration, the plugin API) and its live control.
RecursiveAI.OneGame.HostThe game host: window, input, fixed-step simulation, UI and render stage, windowed or headless.
RecursiveAI.OneGame.PlatformWindows, input, controllers and the app lifecycle per operating system.
RecursiveAI.OneGame.UI, .UI.KitThe retained UI tree with CSS-model styling and flex layout, and the component kit: buttons, tables, lists, tabs, dialogs, text input.
RecursiveAI.OneGame.Text, .AtlasFonts, shaping and the glyph atlas; the SVG and image atlas for the 2D renderer.
RecursiveAI.OneGame.Render2DSprites, tilemaps and text.
RecursiveAI.OneGame.Render3D, .GltfThe 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.BackendsThe graphics layer and render graph, its three backends, and the package that picks one for the machine.
RecursiveAI.OneGame.AudioAudio device output and the audio service.
RecursiveAI.OneGame.SteamSteamworks bindings, Steam Input, and store-neutral achievements, stats, cloud saves and presence.
RecursiveAI.OneGame.PurchasesIn-app purchases over StoreKit, Google Play Billing and a scriptable fake store.
RecursiveAI.OneGame.Control, .LiveThe control layer the command line, tests and live control share; the development tools of a Debug build.
RecursiveAI.OneGame.TestingThe 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.1.2, 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.1.2 packages:

The guides:

Coming from another engine: onegame for MonoGame developers and onegame for Unity developers.