Recursive AI

Physics in a C# game (preview)

All onegame recipes

Drop a crate onto a floor and kick it along with onegame’s physics package, a preview: rigid bodies on fixed-point positions, stepped each tick, checked from a scenario, and saved and restored with the world to the same hash.

Preview. RecursiveAI.OneGame.Physics is new in 1.15.0 and published only as a prerelease, 1.15.2-preview.1, because the physics library under it, OnePhysics, is a prerelease too. Its API may change before it is stable, and each OnePhysics preview moves results: positions, hashes and physics saves don’t carry across versions. Everything below ran on onegame 1.15.2 with RecursiveAI.OneGame.Physics 1.15.2-preview.1 from NuGet.

Add the package

Create the game with onegame new tumble, delete art/ and the scenarios, and add the package by its preview version:

Terminal
dotnet add package RecursiveAI.OneGame.Physics --version 1.15.2-preview.1

It isn’t part of the game template, and the other engine packages stay stable and never depend on it.

The game

Replace Game.cs:

C#
using System.Numerics;
using OneGame.Core;
using OneGame.Host;
using OneGame.Physics;
using OneGame.UI;
 
namespace Tumble;
 
/// <summary>A crate. The body lives in the physics space; the entity keeps a reference to it and mirrors what the view draws.</summary>
[Entity]
public partial class Crate : Entity, IView2D
{
    [Synced] public partial BodyRef Body { get; internal set; }
    [Synced] public partial FVec2 Position { get; private set; }
    [Synced] public partial Fixed Angle { get; private set; }
 
    [Command]
    public void Kick(int x, int y) => PhysicsSpace2D.Of(World!)!.ApplyImpulse(Body, new FVec2(x, y));
 
    // Commands, then OnTick, then the physics step: the mirror shows where the last step left the body.
    protected override void OnTick()
    {
        var space = PhysicsSpace2D.Of(World!)!;
        Position = space.Position(Body);
        Angle = space.Angle(Body);
    }
 
    // 32 pixels per meter; y is up in the simulation and down on screen.
    [View]
    public void Draw(Draw2D d)
    {
        d.FillRect(new Rect(-320, 0, 640, 32), Color.Rgb(0x5B6B7C), 0, 0);
        d.FillRect(new Rect((Position.X.ToSingle() * 32) - 16, (-Position.Y.ToSingle() * 32) - 16, 32, 32), Color.Rgb(0xC08040), 0, 1);
    }
}
 
public sealed class TumbleGame : GameDefinition
{
    public override string Name => "tumble";
 
    public override void RegisterEntities(EntityRegistry registry) => OneGame.Generated.Tumble.EntityTypes.Register(registry);
 
    // Added where the world is built, so a world made to load a save has the space too.
    public override void RegisterSystems(World world) => PhysicsSpace2D.AddTo(world);   // gravity (0, -9.81), 60 ticks a second
 
    public override void Setup(World world)
    {
        var space = PhysicsSpace2D.Of(world)!;
        space.AddBox(new FVec2(20, 1), new FVec2(0, -Fixed.FromRatio(1, 2)), new BodyOptions { Kind = BodyKind.Static });   // the floor, top at y = 0
        world.Add(new Crate { Body = space.AddBox(new FVec2(1, 1), new FVec2(0, 4)) });                                     // a 1 m crate, 4 m up
    }
 
    public override void DefineScreens(ScreenSet screens) => screens.Add("main", game =>
        Ui.Label(() => { var c = (Crate)game.World.Entities[0]; return $"x {c.Position.X.ToSingle():0.00}  y {c.Position.Y.ToSingle():0.00}"; })
            .Named("pos").Padding(Length.Px(8)).TextColor(Color.White));
}
  • PhysicsSpace2D.AddTo(world) adds the space as a hidden table of the world and a system that steps it once per tick, after the entities. The physics world is part of the game’s state: saved, restored and covered by the state hash.
  • AddBox(size, position, options) takes the full width and height. Bodies are dynamic unless BodyOptions.Kind says otherwise.
  • BodyRef is an ordinary synced value, so the crate keeps its body in its own state.
  • The view draws from the mirrored Position, not from the space: replicas never receive the physics table, so a client draws what the entities copy out.

Watch it fall

With the MCP client from Drive a running game over MCP, on onegame run --no-hot --headless -- --live /tmp/onegame/tumble.sock:

Text
step {"frames": 30} -> {"frame":30,"tick":30,"hash":"02ba74a5d8d4e712"}
state_query {"entity": "Crate"} -> {"id":1,"type":"Crate","Body":"body:1.1","Position":[0,2.844259902],"Angle":0}
step {"frames": 90} -> {"frame":120,"tick":120,"hash":"5d7c602dad35b329"}
state_query {"entity": "Crate"} -> {"id":1,"type":"Crate","Body":"body:1.1","Position":[0.002093574,0.49993946],"Angle":8.82e-07}
command_send {"entity": 1, "command": "Kick", "args": [4, 3]} -> {"entity":"Crate#1","command":"Kick","status":"Queued"}
step {"frames": 120} -> {"frame":240,"tick":240,"hash":"eb801b0a28c54fe0"}
state_query {"entity": "Crate"} -> {"id":1,"type":"Crate","Body":"body:1.1","Position":[2.494690065,0.499940149],"Angle":-5.8e-08}

After half a second the crate was still falling. By tick 120 it rested on the floor, its center half a meter up. The kick slid it about two and a half meters to the right. A capture at tick 240 shows the gray floor across the middle of the frame, the orange crate on it right of center, and “x 2.49 y 0.50” in the corner.

Check it from a scenario

scenarios/kick.yaml

YAML
name: kick
description: The crate falls onto the floor and settles, then a kick slides it along.
steps:
  - step: 120
  - expect:
      state: { entity: Crate, property: "Position[1]", min: 0.49, max: 0.51 }
  - command: { entity: Crate, name: Kick, args: [4, 3] }
  - step: 120
expect:
  - state: { entity: Crate, property: "Position[0]", min: 2, max: 3 }
  - state: { entity: Crate, property: "Position[1]", min: 0.49, max: 0.51 }
  - ui: { id: pos, text: x 2.49  y 0.50 }
Terminal
onegame test
onegame test
Text
PASS kick (258 ms, tick 240, hash eb801b0a28c54fe0)
1 passed, 0 failed
PASS kick (271 ms, tick 240, hash eb801b0a28c54fe0)
1 passed, 0 failed

The same hash both times, and the one the live session reached at tick 240.

Saved with the world

Save a snapshot once the crate has settled, kick it, then load the snapshot and kick it again:

Text
step {"frames": 120} -> {"frame":120,"tick":120,"hash":"5d7c602dad35b329"}
snapshot_save {"path": "settled.snap"} -> {"path":"/tmp/onegame/tumble/artifacts/settled.snap","bytes":1408,"hash":"5d7c602dad35b329","tick":120}
command_send {"entity": 1, "command": "Kick", "args": [4, 3]} -> {"entity":"Crate#1","command":"Kick","status":"Queued"}
step {"frames": 120} -> {"frame":240,"tick":240,"hash":"eb801b0a28c54fe0"}
snapshot_load {"path": "settled.snap"} -> {"frame":240,"tick":120,"hash":"5d7c602dad35b329"}
command_send {"entity": 1, "command": "Kick", "args": [4, 3]} -> {"entity":"Crate#1","command":"Kick","status":"Queued"}
step {"frames": 120} -> {"frame":360,"tick":240,"hash":"eb801b0a28c54fe0"}

The whole physics world, with velocities, contacts and sleeping bodies, is in the 1,408-byte snapshot, so the restored world continued to the same hash.

Good to know

  • Preview. Pin the exact version. Upgrading onegame upgrades OnePhysics, and then results move: re-record replays and re-pin hashes. A physics save from another preview is refused, never half loaded.
  • The crate dropped straight down yet came to rest 0.002 m off center, with a tiny angle. That is the solver settling, and it is the same on every run.
  • Characters (AddCharacter, moved with collide-and-slide), triggers, ray casts and collision layers are in the package too. Joints, shape casts and overlap queries aren’t wrapped yet.
  • Don’t mark a command that reads or changes physics as predicted: a replica’s predicted world has no physics space.
  • Sim code never sees a float: everything takes and returns Fixed and FVec2, and the sim analyzers accept the package.

More in Physics (preview).