Recursive AI

Pages: screens written in HTML and CSS

All onegame docs

A page is a screen drawn by OneRender: an HTML and CSS document (or a tree built in C#) that OneRender lays out, styles, animates and hit-tests, and that onegame draws through the render graph over your game. OneGame.UI stays: a game can use either, per screen, or both at once. Solitaire's settings screen is a page (samples/solitaire/Views/SettingsPage.cs); its menu, statistics and table are still OneGame.UI.

Pages are view code. A handler reads state and sends commands, never writes state, and showing a page changes no hash.

Page or OneGame.UI?

Reach for a page whenReach for OneGame.UI when
you want real CSS: grid, gradients, shadows, filters, transitions, animations, blend modesyou want the component kit (tables, tabs, tooltips, dialogs, lists, text input with its focus rules)
the screen is mostly layout and text, or you already have the HTMLthe screen needs focus navigation by controller across custom widgets
you want native form controls (radio buttons, checkboxes, sliders, text fields) with no codeyou want the kit's focus rules and widgets ready made

An HTML page

Build the page when its screen opens. Show replaces a page of the same name, so a screen that is built again simply remakes it.

C#
using OneGame.Host;
using OneGame.Pages;
using OneGame.UI;
using OneRender.Interaction;
using PagesGame;   // Meter: the game's own entity, with an Add command
 
public static class MenuScreen
{
    private const string Html = """
        <body style="margin:0;font:16px Body;color:#e8ecf4">
          <h1 style="margin:10px">Menu</h1>
          <button id="start" style="width:120px;height:36px">Start</button>
        </body>
        """;
 
    public static Element Build(GameInstance game)
    {
        var fonts = new PageFonts(game.Fonts);                       // the same faces OneGame.Text draws with
        fonts.Add(File.ReadAllBytes("fonts/Body.ttf"), "Body");      // the page says: font: 16px Body
 
        game.Pages.Show("menu", PageScreen.FromHtml(Html, new PageOptions(fonts)
        {
            // Runs every time the page is made, which includes after a window resize or a UI scale change.
            Setup = s => s.Interactive!.Events.AddEventListener(
                EventTarget.Of(s.Interactive.FindElement("start")), "click", _ => Meter.Of(game.World).Send.Add(1)),
        }));
        return Ui.Column();   // the screen's OneGame.UI side: empty, or the felt behind the page
    }
}

Things worth knowing:

  • Sizes are CSS pixels at the UI scale: a 620 px wide card is 620 OneGame.UI pixels wide, and a 1.5 scale draws it at 930 device pixels.
  • The page is transparent where you give it no background, and clicks on a transparent part pass through to what is under it.
  • An HTML page cannot resize in place, so it is made again when the window size or UI scale changes. Focus, scroll position and form values are lost then. Put what the page shows in your own state (the profile, the settings object) and have Setup apply it: set control values with SetChecked and SetRangeValue, and focus the first control with Focus. Setup's changes are painted before the first frame.
  • Hide a page by calling game.Pages.Remove("menu") when another screen opens (the screen builders of the other screens are the place), or SetVisible to keep it.
  • Structure is fixed once the page is made: handlers change text, control state and classes, not the element tree. Change text with SetTextContent (see Limits), or use a builder page for a HUD.

Handlers send commands

A DOM listener registered with page.Events.AddEventListener is view code. It may read state, send commands, and change the player's own settings and profile. It may not write synced state or call sim-only methods; the analyzer says so at build time:

error OG0008: View code cannot change simulation state: 'Table.NewGame' changes state.
Views read state and send commands; use entity.Send.<Command>(...) instead.

That holds for lambdas, method groups and local functions passed to AddEventListener. A command sent from a handler runs on the next tick, like any command from a view.

Solitaire's handlers save the profile as the player changes a control:

C#
using OneGame.Pages;
using OneRender.Interaction;
 
public static class Handlers
{
    public static void Wire(InteractivePage page, Action<int> setMusic, Action back)
    {
        page.Events.AddEventListener(EventTarget.Of(page.FindElement("music")), "input", _ => setMusic((int)page.GetRangeValue(page.FindElement("music"))));
        page.Events.AddEventListener(EventTarget.Of(page.FindElement("back")), "click", _ => back());
    }
}

A builder page

A page can also be a StyledTree built in C#, with no HTML. It suits a HUD that changes every frame: edit the tree and the page redraws on the next frame, and the usual edits (text, state, style) allocate nothing.

C#
using OneGame.Host;
using OneGame.Pages;
using OneRender.Style;
using OneRender.Tree;
 
public static class HudScreen
{
    public static void Show(GameInstance game, PageFonts fonts)
    {
        var tree = new StyledTree();
        var root = tree.AddRoot("div", new StyleDeclaration().Set("width", "320px").Set("height", "40px").Set("font", "18px Body").Set("color", "#ffd040"));
        var gold = tree.AddElement(root, "span");
        tree.AddText(gold, "Gold 0");
        tree.SetName(gold, "gold");
        game.Pages.Show("hud", PageScreen.FromTree(tree, new PageOptions(fonts)));
    }
 
    public static void Update(PageSet pages, int gold)
    {
        var tree = pages.Get("hud")!.Live!.Styled;
        tree.SetText(tree.FindByName("gold"), $"Gold {gold}");
    }
}

A builder page draws and can be found by name, but it takes no pointer, key or text input: the game sends input to its nodes itself.

Images and SVG

PageImages puts raster and SVG files in the game's art atlas and gives pages addresses to name. SVG is drawn at the size the page shows it, with currentColor following the CSS color.

C#
using OneGame.Host;
using OneGame.Pages;
 
public static class Art
{
    public static PageOptions Options(GameInstance game, PageFonts fonts)
    {
        var images = new PageImages(game.Art);
        images.Add("coin.png", File.ReadAllBytes("art/coin.png"));
        images.Add("heart.svg", File.ReadAllBytes("art/heart.svg"));
        return new PageOptions(fonts) { Images = images };   // <img src="coin.png"> and background-image: url(heart.svg)
    }
}

Finding pages in scenarios and the harness

ui_query, ui_click and ui_invoke, and the scenario steps click:, invoke: and ui: that use them, look in the OneGame.UI tree first and then in the shown pages, by name: an element's id, a form control's name, or #name. A click sends real pointer events through the platform queue at the element's centre, so hover, focus, transitions and handlers run as for a player. A controller button with a page focused acts like the keyboard: d-pad up and down move between controls (as Shift+Tab and Tab), left and right move along sliders and radio groups, and the south button presses.

YAML
name: settings-pad
steps:
  - screen: settings
  - step: 3
  - nav: down            # focus moves from Draw 1 to Draw 3
  - nav: south           # the south button presses it
  - nav: down
  - nav: south           # the checkbox
  - nav: down
  - nav: dpad_right      # Music volume goes up one step
  - nav: dpad_right
  - step: 3
  - expect:
      capture: { reference: refs/settings-pad-720p.png, size: 1280x720, tolerance: 2, max-diff: 0.001 }

click: draw-3 and expect: { ui: { id: start, text: Start } } work the same way, as in tests/PagesGame/scenarios/menu.yaml.

A capture draws the page. Tolerances for text edges follow the rest of the game (lavapipe is the reference); look at the frame before keeping it as the reference.

Limits and what is not there yet

  • Platforms. Pages run on Linux (lavapipe) and in headless Chrome (WebGPU on SwiftShader), where solitaire's settings captures match the desktop references within the web tolerance. Metal, Windows, phones and real GPUs in a browser have not run them.
  • Native control looks. Radio buttons, checkboxes and sliders take color-scheme: dark (Chrome's dark control palette) and accent-color for the fill, ring and dot, as solitaire's settings do. The slider track and thumb shapes cannot be styled until OneRender adds range pseudo-elements. For a custom look use appearance: none; :checked, :disabled and :indeterminate follow the live state.
  • Live changes. InteractivePage.SetTextContent changes an element's text with no allocation, so a readout can follow a slider every frame (solitaire's volume numbers do). SetValue and SetChecked change a control's state. Class edits and SetAttribute/RemoveAttribute re-style in place for about 835 KB; changing value, type, checked, src or name that way rebuilds at about 7.4 MB, so use SetValue/SetChecked for those. Per-frame changes should be text, transform and opacity; keep class and attribute edits for events (a click, a screen change).
  • No text-input method yet. Typing works; composed text from an input method and clipboard paste do not reach page fields.
  • Tab order. A radio group is one tab stop (OneRender 1.0.1), so the controller steps over the group and arrows move inside it.
  • Cost. OneRender's cost is in its own notes; a steady page allocates nothing in the adapter. Bounds on SVG keys, images, instances and clips are in docs/interfaces/onerender.md.