---
title: "C# game development without an editor"
url: https://recursiveai.org/onegame/articles/csharp-game-development-without-editor/
description: "Build a complete 2D game in C# with no editor: sprites, keyboard and gamepad input, scoring, headless tests and a native Linux build, all from code."
date: 2026-10-27
author: onegame team
---

# C# game development without an editor

Build a complete 2D game in C# with no editor: sprites, keyboard and gamepad input, scoring, headless tests and a native Linux build, all from code.

October 27, 2026 · onegame team

You can do C# game development without an editor: the whole game is C# source plus a few image files, and you build, run, test and ship it from a terminal and the code editor you already use. There are two ways to work like that. A framework such as MonoGame, FNA or raylib-cs gives you a window, input and drawing, and you write the rest of the engine. An engine without an editor, such as [onegame](https://recursiveai.org/onegame/), gives you the engine too, just with no scene view around it. This tutorial builds a small, complete game the second way: a basket that catches falling stars, with sprites, keyboard and gamepad controls, a score, three tests and a native Linux build. Every command and output below was run with onegame 1.2.0 on .NET 10.

## What “without an editor” means

An editor-first engine keeps part of your game in its own files: scenes, prefabs, inspector values, animation graphs. Without an editor, all of that is code you can read, diff and review. Noel Berry, who works this way, describes using [SDL3](https://www.libsdl.org/) under his own small C# layer and argues that making games “from scratch” isn’t the impossible task people assume ([Making Video Games in 2025](https://noelberry.ca/posts/making_games_in_2025/)). The cost of the framework route is that you write the loop, the asset loading, the input mapping, the saving and the testing yourself.

onegame keeps the code-only workflow and builds those parts in. A game is entity classes with synced state, a fixed 60-tick simulation that the compiler keeps deterministic, and view code that draws. The `onegame` command builds it, runs it with or without a window, runs its tests and exports it. What replaces the editor’s scene view and inspector is code, YAML test scenarios, screenshots and live control from a terminal. For the wider choice between engines and frameworks, see [C# game engines compared](https://recursiveai.org/onegame/articles/csharp-game-engines/).

## Set up a project

You need the .NET 10 SDK. Install the tool and create the game:

*Terminal · .NET 10 SDK*

```
dotnet tool install -g RecursiveAI.OneGame.Cli --version 1.2.0
onegame new starcatch
cd starcatch
```

*Output · onegame new*

```
created /tmp/games/starcatch
  cd /tmp/games/starcatch
  onegame build
  onegame run --headless --frames 60
  onegame test
```

`onegame new` writes a working starter game, a ship you can fly (it’s walked through in [Your first game](https://recursiveai.org/onegame/docs/first-game/)). The project file is the whole build setup: the `RecursiveAI.OneGame.Sdk` package brings the engine, the analyzers and the export targets from nuget.org.

*starcatch.csproj · onegame 1.2.0*

```
<Project Sdk="RecursiveAI.OneGame.Sdk/1.2.0">
 
  <PropertyGroup>
    <AssemblyName>Starcatch</AssemblyName>
    <RootNamespace>Starcatch</RootNamespace>
  </PropertyGroup>
 
</Project>
```

This tutorial replaces the ship. Delete `art/` and everything in `scenarios/`, then add two pictures: `art/basket.png`, 48 × 20 pixels, and `art/star.png`, a strip of four 16 × 16 frames of a star getting smaller and larger again. Draw your own, or download the ones used here: [basket.png](https://recursiveai.org/onegame/articles/csharp-game-development-without-editor/basket.png) and [star.png](https://recursiveai.org/onegame/articles/csharp-game-development-without-editor/star.png).

## The whole game in one file

Replace `Game.cs` with this. The scaffold’s `Program.cs`, which starts the game, stays as it is.

*Game.cs · onegame 1.2.0 · .NET 10*

```
using System;
using System.Linq;
using System.Numerics;
using OneGame.Core;
using OneGame.Host;
using OneGame.Platform;
using OneGame.Render2D;
using OneGame.UI;
 
namespace Starcatch;
 
/// <summary>The basket the player slides along the bottom of the screen.</summary>
[Entity]
public partial class Basket : Entity, IView2D
{
    public const int Row = 140;    // world units below the center of the screen
    public const int Edge = 280;   // how far it may go left or right
    public const int Speed = 6;    // world units per tick
 
    [Synced] public partial int X { get; private set; }
    [Synced] public partial int Dir { get; private set; }
 
    [Command]
    public void Move(int dir) => Dir = Math.Sign(dir);
 
    protected override void OnTick() => X = Math.Clamp(X + Dir * Speed, -Edge, Edge);
 
    [View]
    public void Draw(Draw2D d) => d.Sprite("art/basket.png", new Vector2(X, Row), scale: 2);
}
 
/// <summary>A falling star. It is caught if it crosses the basket's row close enough to the basket.</summary>
[Entity]
public partial class Star : Entity, IView2D
{
    public const int Reach = 40;
 
    [Synced] public partial Fixed X { get; private set; }
    [Synced] public partial Fixed Y { get; private set; }
    [Synced] public partial Fixed Speed { get; private set; }
 
    public void Drop(int x, Fixed speed) => (X, Y, Speed) = (x, -190, speed);
 
    protected override void OnTick()
    {
        var before = Y;
        Y += Speed;
        var sky = World!.Entities.OfType<Sky>().First();
        if (before < Basket.Row && Y >= Basket.Row)
        {
            var basket = World.Entities.OfType<Basket>().First();
            if (Fixed.Abs(X - basket.X) <= Reach)
            {
                sky.Caught();
                World.Remove(Id);
            }
        }
        else if (Y > 190)
        {
            sky.Missed();
            World.Remove(Id);
        }
    }
 
    // View state: the sheet is cut once from art/star.png (four 16 x 16 frames) and never synced.
    private SpriteSheet? _sheet;
 
    [View]
    public void Draw(Draw2D d)
    {
        if (_sheet is null)
        {
            _sheet = SpriteSheet.Grid(d.Texture("art/star.png"), 16, 16);
            _sheet.AddClip("twinkle", 0, 4, 8, true);
        }
 
        d.Sprite(_sheet, "twinkle", new Vector2(X.ToSingle(), Y.ToSingle()), scale: 2);
    }
}
 
/// <summary>The sky drops a star every DropEvery ticks, keeps the score and ends the game after three misses.</summary>
[Entity]
public partial class Sky : Entity, IView2D
{
    public const int DropEvery = 45;
 
    [Synced] public partial int Cooldown { get; private set; }
    [Synced] public partial int Score { get; private set; }
    [Synced] public partial int Lives { get; private set; }
 
    public void Start() => (Cooldown, Score, Lives) = (0, 0, 3);
 
    protected override void OnTick()
    {
        if (Lives == 0 || ++Cooldown < DropEvery)
        {
            return;
        }
 
        Cooldown = 0;
        var rng = World!.Rng["stars"];
        World.Add(new Star()).Drop(rng.NextInt(-Basket.Edge, Basket.Edge + 1), rng.NextFixed(2, 4));
    }
 
    public void Caught() => Score++;
 
    public void Missed() => Lives = Math.Max(0, Lives - 1);
 
    [Command]
    public bool Restart()
    {
        if (Lives > 0)
        {
            return false;
        }
 
        foreach (var star in World!.Entities.OfType<Star>().ToList())
        {
            World.Remove(star.Id);
        }
 
        Start();
        return true;
    }
 
    [View]
    public void Draw(Draw2D d)
    {
        d.FillRect(new Rect(-320, 164, 640, 16), Color.Rgb(0x2E4A3A));
        if (Lives == 0)
        {
            d.Text("Game over. Press Enter to play again.", Vector2.Zero, 20, anchor: new Vector2(0.5f, 0.5f));
        }
    }
 
    [View]
    public string Label() => $"Score {Score}   Lives {Lives}";
}
 
public sealed class StarcatchGame : GameDefinition
{
    public override string Name => "starcatch";
 
    public override void RegisterEntities(EntityRegistry registry) => OneGame.Generated.Starcatch.EntityTypes.Register(registry);
 
    public override void Setup(World world)
    {
        world.Add(new Sky()).Start();
        world.Add(new Basket());
    }
 
    // A known starting world for tests: one star dropping over the right half of the screen at 4 units a tick.
    public override void DefineFixtures(FixtureSet fixtures) =>
        fixtures.Add("falling-star", world =>
        {
            world.Add(new Sky()).Start();
            world.Add(new Basket());
            world.Add(new Star()).Drop(120, 4);
        });
 
    public override void DefineActions(ActionMap actions) =>
        actions.Context("play", c => c
            .Vector("move", Bind.Arrows, Bind.Wasd, Bind.LeftStick)
            .Button("restart", Bind.Key(Key.Enter), Bind.Pad(GamepadButton.Start)));
 
    // View code: read the actions every frame, send a command when something changes.
    public override void OnEachFrame(GameInstance game)
    {
        var basket = game.World.Entities.OfType<Basket>().First();
        var dir = (int)MathF.Round(game.Actions.Vector("move").X);
        if (dir != basket.Dir)
        {
            basket.Send.Move(dir);
        }
 
        if (game.Actions.Pressed("restart"))
        {
            SkyOf(game).Send.Restart();
        }
    }
 
    public override void DefineScreens(ScreenSet screens) =>
        screens.Add("main", game => Ui.Label(() => SkyOf(game).Label()).Named("score").Padding(Length.Px(8)).TextColor(Color.White));
 
    private static Sky SkyOf(GameInstance game) => game.World.Entities.OfType<Sky>().First();
}
```

### State and the simulation

Each class marked `[Entity]` is a game object. Its `[Synced]` partial properties are its state. Source generators complete them with change tracking, snapshots and network deltas, and their private setters mean only the entity’s own simulation code writes them. `OnTick` runs exactly 60 times per second of game time, whatever the frame rate, so speeds are units per tick and there is no elapsed time to multiply by.

- **The basket** moves 6 units a tick in the direction it was last told, clamped to the screen. Integers are fine in simulation code.
- **A star** falls at a fixed-point speed. `Fixed` is a 64-bit fixed-point number, used because a `float` in simulation code is a compile error: float results can differ between machines, and fixed-point results can’t. The star checks for the basket only on the tick it crosses the basket’s row, then removes itself with `World.Remove(Id)`.
- **The sky** drops a star every 45 ticks. Its position and speed come from `World.Rng["stars"]`, a random stream seeded from the world’s seed, so the same seed drops the same stars. [Deterministic game simulation in C#](https://recursiveai.org/onegame/articles/deterministic-game-simulation-csharp/) covers what the compiler rejects and why.

Collision here is a distance check you write yourself. onegame has no physics engine, and for a game like this one you don’t need one.

### Drawing with Draw2D

An entity that implements `IView2D` draws itself: the engine calls its `Draw(Draw2D d)` method every frame. `d.Sprite("art/basket.png", …)` loads the picture on first use, caches it, and draws it centered on the point you give, here at twice its size. World point (0, 0) is the center of the screen and y grows downward. The star cuts its picture into a `SpriteSheet` of four frames and plays the `twinkle` clip at 8 frames per second. Clips run on view time, so the animation is smooth on screen and identical in screenshots and replays. The sky fills a rectangle for the ground and writes “Game over” with `d.Text`. The engine batches all of it.

Draw code is view code. It’s marked `[View]`, it may use floats, and it can read state but not write it: assigning `X` inside `Draw` is a compile error. The star’s `_sheet` field is view state, never synced. In a Debug build, saving a picture while the game runs replaces it in the next frame.

### Input as actions

`DefineActions` declares the controls once: a `move` vector bound to the arrow keys, WASD and the left stick, and a `restart` button on Enter or the gamepad’s Start. The map also supports rebinding, with a ready-made bindings list in `ActionUi`, and tests drive the same actions by name. `OnEachFrame` is view code too. It reads the actions and sends the basket a `Move` command when the direction changes. Commands are the only way input reaches the simulation, which is why a recording, a replay and a network client all see the same game. The score is a UI label, rebuilt every frame from `Sky.Label()` and named `score` so tests can read it.

## Run it

`onegame run` opens the game in a window. To check it from a terminal or CI, run it headless for 30 seconds of game time, twice with seed 1 and once with seed 2:

*Terminal · onegame 1.2.0*

```
onegame run --headless --frames 1800 --seed 1 --json
onegame run --headless --frames 1800 --seed 1 --json
onegame run --headless --frames 1800 --seed 2 --json
```

*Output · onegame run*

```
{"ok":true,"game":"starcatch","headless":true,"frames":1800,"tick":1800,"hash":"c3f88ebbec2ae168","width":1280,"height":720}
 
{"ok":true,"game":"starcatch","headless":true,"frames":1800,"tick":1800,"hash":"c3f88ebbec2ae168","width":1280,"height":720}
 
{"ok":true,"game":"starcatch","headless":true,"frames":1800,"tick":1800,"hash":"c8beb185828c2035","width":1280,"height":720}
```

`hash` is a hash of the whole world after the last tick. The same seed gives the same stars and the same hash. A different seed gives a different game. That is what lets the tests below assert exact numbers.

## Test it without a window

A scenario is a test written as YAML. It starts the real game, drives it through the same actions a player uses, steps time and checks the result. The `falling-star` fixture in `Game.cs` starts the world with one star over the right half of the screen. This scenario holds right for 20 ticks, lets go, and checks that the star landed in the basket:

*scenarios/catch-a-star.yaml · onegame 1.2.0*

```
name: catch-a-star
description: Slide right under a falling star and catch it.
fixture: falling-star
steps:
  - action: { name: move, state: down, x: 1, y: 0 }
  - step: 20
  - action: { name: move, state: up }
  - step: 70
expect:
  - state: { entity: Basket, property: X, equals: 120 }
  - state: { entity: Sky, property: Score, equals: 1 }
  - state: { entity: Sky, property: Lives, equals: 3 }
  - ui: { id: score, text: "Score 1   Lives 3" }
```

Twenty ticks at 6 units is 120, right under the star. The next one stands still for 900 ticks (15 seconds), checks the game is over, then presses `restart`:

*scenarios/game-over.yaml · onegame 1.2.0*

```
name: game-over
description: Standing still loses three stars and ends the game; Enter starts it again.
steps:
  - step: 900
  - expect:
      state: { entity: Sky, property: Lives, equals: 0 }
  - action: restart
  - step: 2
expect:
  - state: { entity: Sky, property: Lives, equals: 3 }
  - state: { entity: Sky, property: Score, equals: 0 }
```

The last one checks the drawing. It compares a frame with a reference picture, and checks that the star sheet loaded:

*scenarios/looks.yaml · onegame 1.2.0*

```
name: looks
description: The basket, the ground and a star, compared with a reference picture.
window: 640x360
fixture: falling-star
steps:
  - step: 30
expect:
  - capture: { reference: refs/falling-star.png, size: 640x360, tolerance: 2, max-diff: 0.001 }
  - asset: { path: art/star.png, state: ready }
```

The first run with `--update-references` writes `scenarios/refs/falling-star.png`. Look at it once, and every later run compares against it:

*Terminal · onegame 1.2.0*

```
onegame test --scenario scenarios/looks.yaml --update-references
onegame test
```

*Output · onegame test*

```
PASS catch-a-star (42 ms, tick 90, hash c834584933efb52e)
PASS game-over (4 ms, tick 902, hash 84bf155a8ea3bc28)
PASS looks (336 ms, tick 30, hash 2f75179e169e7a69)
3 passed, 0 failed
```

The two state scenarios need no GPU. The screenshot needs a Vulkan driver, and a software one is fine. All three run in CI as they run here. [Automated game testing](https://recursiveai.org/onegame/articles/automated-game-testing/) goes further: failure output, JSON for CI, record and replay, and C# tests.

## Ship it

Export a Release build for Linux. It is compiled ahead of time with NativeAOT, so the player needs no .NET install:

*Terminal · onegame 1.2.0*

```
onegame export --target linux
export/starcatch-1.0.0-linux-x64/run.sh --headless --frames 1800 --seed 1 --json
```

*Output · onegame export*

```
exported starcatch 1.0.0 for linux-x64 (nativeaot): 7 files, 7654 KiB
  folder:  /tmp/games/starcatch/export/starcatch-1.0.0-linux-x64
  archive: /tmp/games/starcatch/export/starcatch-1.0.0-linux-x64.tar.gz (3701 KiB)
  warning glibc_too_new: Starcatch 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.
```

*Output · run.sh*

```
{"ok":true,"game":"starcatch","headless":true,"frames":1800,"tick":1800,"hash":"c3f88ebbec2ae168","width":1280,"height":720}
```

The exported program, built by a different compiler from the one `onegame run` uses, reaches the same hash for seed 1. The folder holds the program, a `run.sh` launcher, the `art` and `fonts` folders, and a third-party notices file. The `glibc_too_new` warning matters only if you ship on Steam: Steam’s Linux runtime, which the Steam Deck uses, has an older glibc than the machine this was built on. [Steam and the Steam Deck](https://recursiveai.org/onegame/cookbook/steam-and-steam-deck/) shows how to build for it. `--target win` and `--target osx` export for Windows and macOS ([Exporting](https://recursiveai.org/onegame/docs/exporting/)).

## Limits at 1.2.0

- **No physics engine.** Movement and collision are your own simulation code, in integers or `Fixed`.
- **No editor**, by design. Levels and tuning are code or data files.
- **Web, Android and iOS exports need an engine checkout.** From the NuGet packages, games export for Linux, Windows and macOS ([Export for the web, Android and iOS](https://recursiveai.org/onegame/cookbook/export-web-android-ios/)).
- **`Draw2D` covers sprites, sheets, shapes, text and a following camera.** Tilemaps and post-processing use the lower-level 2D renderer.

## Where to go next

- Add a second player over the network with the same entity code: [client-side prediction in C#](https://recursiveai.org/onegame/articles/client-side-prediction-csharp/).
- Coming from MonoGame? [onegame for MonoGame developers](https://recursiveai.org/onegame/articles/onegame-for-monogame-developers/) maps `Update`, `Draw` and `SpriteBatch` to the code above.
- Build menus and a HUD with styled UI screens and controller navigation: the [UI guide](https://recursiveai.org/onegame/docs/ui-guide/).
- Start from the docs: [Getting started](https://recursiveai.org/onegame/docs/getting-started/).
