Recursive AI

Draw sprites, shapes and text with Draw2D

All onegame recipes

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

C#
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.
Terminal
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 }
Terminal
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 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, 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.
  • 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 and assets notes.

See both in a complete game: C# game development without an editor.