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:
dotnet add package RecursiveAI.OneGame.Pages --version 1.15.2Replace Game.cs:
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.PageFontsgives the page the faces the game’s text draws with. The font name in the CSS is the one you register.Setupruns 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 ofSend.Light()and the build fails:
/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
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
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 }
onegame test --scenario scenarios/light.yaml --update-references onegame test
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:
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 testand the export’srun.shstart in the right folder; the font ships in the export’sfonts/. Started from another directory, the exported program stops witherror: 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 aStyledTreewith 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
Setupapply 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.