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:
dotnet add package RecursiveAI.OneGame.Physics --version 1.15.2-preview.1It isn’t part of the game template, and the other engine packages stay stable and never depend on it.
The game
Replace Game.cs:
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 unlessBodyOptions.Kindsays otherwise.BodyRefis 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:
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
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 }
onegame test onegame test
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:
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 returnsFixedandFVec2, and the sim analyzers accept the package.
More in Physics (preview).