---
title: "2D physics in a C# game (preview)"
url: https://recursiveai.org/onegame/cookbook/physics-preview/
description: "2D physics in a C# game (preview): rigid bodies on fixed-point positions, stepped each tick, checked from a test, and saved with the world to the same hash. Runnable, with onegame."
version: "1.15.2"
---

# 2D physics in a C# game (preview)

Add the preview package `RecursiveAI.OneGame.Physics`, add a physics space to the world with `PhysicsSpace2D.AddTo(world)`, which steps it once per tick, add bodies with shapes, and push them with `ApplyImpulse`: positions are fixed-point, so the simulation is the same on every machine and is saved, replayed and hashed with the world. The program below drops a crate onto a floor and kicks it along. The package is a preview: its API and results may change between versions.

```shell
dotnet tool install -g RecursiveAI.OneGame.Cli --version 1.15.2
onegame new tumble && cd tumble
rm -r art scenarios/*
dotnet add package RecursiveAI.OneGame.Physics --version 1.15.2-preview.1
```

You need the .NET 10 SDK. The package isn’t part of the game template, and the other engine packages never depend on it. Replace `Game.cs` with:

```csharp
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.

## Check it from a scenario

Save this as `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 }
```

```shell
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 on every run.

## Limits and notes

- **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)](https://recursiveai.org/onegame/docs/physics/).
