---
title: "Draw sprites, shapes and text with Draw2D"
url: https://recursiveai.org/onegame/cookbook/draw-sprites-2d/
description: "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…"
version: "1.2.0"
---

# 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;

/// <summary>A rover on a long road. The sim moves it in fixed point; the view draws it from a sprite sheet.</summary>
[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/).
