Recursive AI

A game screen in HTML and CSS

All onegame recipes

Write a C# game’s title screen as an HTML and CSS page with onegame’s Pages package, send a command from its button, click that button by name from a scenario, and keep a capture of it as a reference.

New in 1.6.0. A page is a screen drawn by OneRender: an HTML and CSS document that OneRender lays out, styles, animates and hit-tests, and that onegame draws over the game. OneGame.UI stays, and a screen can use both at once. Pages are view code: a handler reads state and sends commands, and showing a page changes no hash. Everything below ran on onegame 1.15.2 from NuGet, on Linux.

The game

Create it with onegame new lantern, delete art/ and the scenarios, and add the package:

Terminal
dotnet add package RecursiveAI.OneGame.Pages --version 1.15.2

Replace Game.cs:

C#
using System.IO;
using OneGame.Core;
using OneGame.Host;
using OneGame.Pages;
using OneGame.UI;
using OneRender.Interaction;
 
namespace Lantern;
 
[Entity]
public partial class Lamps : Entity
{
    [Synced] public partial int Lit { get; private set; }
 
    [Command]
    public void Light() => Lit += 1;
}
 
public sealed class LanternGame : GameDefinition
{
    // Real HTML and CSS: a grid, a gradient, a shadow, a transition and :hover.
    private const string Html = """
        <body style="margin:0;font:18px Body;color:#f4ead8">
          <style>
            .card { position:absolute; left:40px; top:60px; width:360px; padding:24px; border-radius:14px;
                    background:linear-gradient(160deg,#3a2a1c,#1c1612); box-shadow:0 10px 30px rgba(0,0,0,.5);
                    display:grid; grid-template-columns:1fr auto; gap:12px 20px; align-items:center }
            h1 { grid-column:1 / 3; margin:0; font-size:30px; color:#ffcf70 }
            p { margin:0; color:#d8c8b0 }
            button { width:110px; height:40px; border:0; border-radius:8px; background:#b5651d; color:#fff; font:18px Body;
                     transition:background-color 150ms ease-out }
            button:hover { background:#d97d2a }
          </style>
          <div class="card">
            <h1>Lantern</h1>
            <p>Light one for every traveler.</p>
            <button id="light">Light</button>
          </div>
        </body>
        """;
 
    public override string Name => "lantern";
 
    public override void RegisterEntities(EntityRegistry registry) => OneGame.Generated.Lantern.EntityTypes.Register(registry);
 
    public override void Setup(World world) => world.Add(new Lamps());
 
    public override void DefineScreens(ScreenSet screens) => screens.Add("main", game =>
    {
        var fonts = new PageFonts(game.Fonts);
        fonts.Add(File.ReadAllBytes("fonts/NotoSans-Regular.ttf"), "Body");   // the page says: font: 18px Body
 
        // Show replaces a page of the same name, so a rebuilt screen simply remakes it.
        game.Pages.Show("title", PageScreen.FromHtml(Html, new PageOptions(fonts)
        {
            // A DOM listener is view code: it sends a command and never writes state.
            Setup = s => s.Interactive!.Events.AddEventListener(
                EventTarget.Of(s.Interactive.FindElement("light")), "click", _ => LampsOf(game).Send.Light()),
        }));
 
        // The screen's OneGame.UI side, drawn with the page: a label in the corner.
        return Ui.Label(() => $"{LampsOf(game).Lit} lit").Named("count").Padding(Length.Px(8)).TextColor(Color.White);
    });
 
    private static Lamps LampsOf(GameInstance game) => (Lamps)game.World.Entities[0];
}
  • game.Pages.Show(name, PageScreen.FromHtml(html, options)) shows a page. Sizes are CSS pixels at the UI scale, and the page is transparent wherever it has no background.
  • PageFonts gives the page the faces the game’s text draws with. The font name in the CSS is the one you register.
  • Setup runs every time the page is made, including after a window resize or a UI scale change. The page is made again then, because an HTML page can’t resize in place.
  • The click handler is checked like any view. Call LampsOf(game).Light() instead of Send.Light() and the build fails:
Text
/tmp/onegame/lantern/Game.cs(58,83): error OG0008: View code cannot change simulation state: 'Lamps.Light' changes state. Views read state and send commands; use entity.Send.<Command>(...) instead.

Click it from a scenario

click:, ui: and the other UI steps look in the OneGame.UI tree first, then in the shown pages, by name: an element’s id, a form control’s name, or #name. A click sends real pointer events at the element’s center.

scenarios/light.yaml

YAML
name: light
description: The page's Light button, found by its HTML id, sends a command; the frame is kept as a reference.
window: 640x360
steps:
  - expect: { ui: { id: light, text: Light } }
  - click: light
  - click: light
  - step: 1
  - tweens: settle
expect:
  - state: { entity: Lamps, property: Lit, equals: 2 }
  - ui: { id: count, text: 2 lit }
  - capture: { reference: refs/light.png, tolerance: 2, max-diff: 0.001 }

And the same two commands without the page:

scenarios/light-by-command.yaml

YAML
name: light-by-command
description: The same two commands sent directly, with no click.
steps:
  - command: { entity: Lamps, name: Light }
  - command: { entity: Lamps, name: Light }
  - step: 1
expect:
  - state: { entity: Lamps, property: Lit, equals: 2 }
Terminal
onegame test --scenario scenarios/light.yaml --update-references
onegame test
Text
PASS light (579 ms, tick 1, hash 82f3ce76daebd03a)
created reference /tmp/onegame/lantern/scenarios/refs/light.png
1 passed, 0 failed
PASS light-by-command (26 ms, tick 1, hash 82f3ce76daebd03a)
PASS light (574 ms, tick 1, hash 82f3ce76daebd03a)
2 passed, 0 failed

Both end on the same hash: the page sent the commands and changed nothing else. refs/light.png shows “2 lit” in the top-left corner and the card below it: “Lantern” in gold, the line of text wrapped in its grid column, and the orange Light button, on a dark gradient with rounded corners and a soft shadow. Look at a capture before you keep it as a reference.

From live control

The pages tool lists the shown pages, and ui_query finds page elements as it finds OneGame.UI ones. With the MCP client from Drive a running game over MCP:

Text
pages {} -> {"screens":[{"name":"title","kind":"html","order":0,"visible":true,"creations":1,"viewport":{"width":1280,"height":720},"animating":false}],"renderer":{"passes":0,"instances":0,"layers":0,"dropped":0,"missingImages":0,"refusedGlyphs":0,"pathBitmaps":0,"artPages":0,"glyphPages":0},"clockMs":0}
ui_query {"selector": "#light"} -> [{"kind":"page","id":"light","screen":"title","text":"Light","states":[],"bounds":{"x":314,"y":141,"width":110,"height":40}}]
ui_click {"id": "light"} -> {"tick":0}
step {"frames": 1} -> {"frame":1,"tick":1,"hash":"bde8f2d1ce053ffc"}
state_query {"entity": "Lamps"} -> {"id":1,"type":"Lamps","Lit":1}

creations counts how often the page was made. It goes up after a resize or a UI scale change.

Good to know

  • File.ReadAllBytes("fonts/...") reads from the working directory. onegame run, onegame test and the export’s run.sh start in the right folder; the font ships in the export’s fonts/. Started from another directory, the exported program stops with error: Could not find a part of the path '/tmp/fonts/NotoSans-Regular.ttf'. and exits 4.
  • At 1.15.2 the game SDK already brings OneGame.Pages: the game built without the package reference too. The reference pins the version.
  • Structure is fixed once a page is made. Handlers change text (SetTextContent), control state (SetValue, SetChecked) and classes, not the element tree. For a HUD that changes every frame, a builder page (PageScreen.FromTree) edits a StyledTree with no allocation.
  • Focus, scroll position and form values are lost when a page is made again. Keep what it shows in your own state and have Setup apply it.
  • A controller works on a focused page like a keyboard: up and down move between controls, and the south button presses.
  • Pages ran on Linux (lavapipe) and in headless Chrome upstream. Metal, Windows, phones and real GPUs in a browser haven’t run them yet. Input-method text and clipboard paste don’t reach page fields.

More in HTML pages. For screens built from the component kit, see A game UI screen.