Recursive AI

2D platformer character controller in C#

onegame is a free, open-source game engine for .NET from Recursive AI. MIT or Apache-2.0, no editor required.

Move a capsule right through a 2D level. The full program adds gravity and a jump.

Terminal
dotnet new console
dotnet add package RecursiveAI.OneGame.Physics

Requires .NET 10 (net10.0); .NET 9 and earlier aren’t supported.

Fixed and FVec2 are onegame’s deterministic fixed-point number and 2D vector types.

C#
using OneGame.Core;
using OneGame.Physics;
 
var space = new PhysicsSpace2D();
var hero = space.AddCharacter(FVec2.Zero);
space.Move(hero, new FVec2(1, 0));
space.Step();
var position = space.Position(hero);
Console.WriteLine(position.X > 0);

Moves the capsule right and prints True.

Text
True

Unzip the example, then dotnet run.

The full program

An entity is a game object; its synced properties are saved and shared state, and its commands queue actions for a simulation tick.

The full program lands on a floor, queues a jump and lands again. Hero keeps its vertical speed, grounded flag and capsule reference in synced properties. Its generated command queues the jump for the next world tick.

The project references Core directly so its source generator supplies the entity, synced properties and command code. Add it alongside Physics:

Terminal
dotnet add package RecursiveAI.OneGame.Core

The generated registry namespace uses the assembly name Example. Keep this project setting if you copy the full program into a differently named project:

XML
<PropertyGroup>
  <AssemblyName>Example</AssemblyName>
</PropertyGroup>
<ItemGroup>
  <PackageReference Include="RecursiveAI.OneGame.Core"
                    Version="2.0.0" />
</ItemGroup>
C#
using OneGame.Core;
using OneGame.Physics;
 
var registry = new EntityRegistry();
OneGame.Generated.Example.EntityTypes.Register(
    registry);
var world = new World(1, registry);
var space = PhysicsSpace2D.AddTo(world);
space.AddBox(new FVec2(40, 1), new FVec2(0, -1),
    new BodyOptions { Kind = BodyKind.Static });
var hero = world.Add(new Hero
    { Capsule = space.AddCharacter(
        new FVec2(0, 3)) });
for (var tick = 0; tick < 120; tick++)
    world.Step();
Console.WriteLine($"Grounded: {hero.Grounded}");
hero.Jump();
world.Step();
var jumping = hero.VerticalSpeed > Fixed.Zero;
Console.WriteLine($"Jumping: {jumping}");
for (var tick = 0; tick < 120; tick++)
    world.Step();
Console.WriteLine($"Landed again: {hero.Grounded}");
 
[Entity]
public partial class Hero : Entity
{
    [Synced]
    public partial CharacterRef Capsule { get; set; }
    [Synced]
    public partial Fixed VerticalSpeed { get; set; }
    [Synced]
    public partial bool Grounded { get; set; }
    [Synced]
    public partial FVec2 Position { get; set; }
 
    [Command]
    public void Jump()
    {
        if (Grounded) VerticalSpeed = 11;
    }
 
    protected override void OnTick()
    {
        var space = PhysicsSpace2D.Of(World!)!;
        var dt = space.StepSeconds;
        var vy = VerticalSpeed - 25 * dt;
        var move = space.Move(Capsule, new FVec2(
            2 * dt, vy * dt));
        if ((move.Grounded && vy < Fixed.Zero) ||
            (move.HitCeiling && vy > Fixed.Zero))
            vy = Fixed.Zero;
        VerticalSpeed = vy;
        Grounded = move.Grounded;
        Position = space.Position(Capsule);
    }
}

dotnet run prints:

Text
Grounded: True
Jumping: True
Landed again: True

Using a removed character reference throws StaleBodyException.

Variations

Respawn without sweeping through the level

C#
using OneGame.Core;
using OneGame.Physics;
 
var space = new PhysicsSpace2D();
var hero = space.AddCharacter(FVec2.Zero);
space.Teleport(hero, new FVec2(5, 3));
Console.WriteLine(space.Position(hero));
Text
(5, 3)

The short answer

Use PhysicsSpace2D.AddCharacter for the capsule and Move for each tick’s displacement. Read Grounded to decide whether a jump is allowed. Keep vertical speed as a synced Fixed value on the owning entity, not as a view-side animation value. The full program above includes that state and a generated jump command.

The controller is not a falling rigid body with a movement script attached. It is a kinematic capsule: the game asks it to move, and the physics step advances it. That distinction matters when choosing where to apply gravity and when to read its position. The physics guide describes the capsule’s collide-and-slide behavior, step climbing, slope following, moving-ground support and dynamic-body pushing.

How do you add gravity to a 2D character controller?

Subtract gravity times StepSeconds from vertical speed, then multiply that speed by StepSeconds to get the vertical displacement. The OnTick method above does both operations. Move expects a distance for this tick, not units per second. Passing a speed directly makes movement depend on how often the method is called.

Keep this calculation in simulation code. A view may run at a different cadence, and its floating-point drawing coordinates are not the source of the character’s physics position. In an attached space, commands run first, entities tick next, and the physics system steps afterward. Do not also call space.Step() from the entity: the registered system already owns that step.

Clear a negative vertical speed when the move reports walkable ground. Clear a positive vertical speed when it reports a ceiling. Otherwise a character can keep accumulating downward speed while standing still, or retain upward speed against a ceiling. The full program handles both cases; the small snippet at the top only demonstrates horizontal movement.

How do you make a 2D character controller jump?

Treat jumping as a game rule. The example’s Jump command checks the synced grounded flag and sets an upward vertical speed. The following tick applies gravity and includes that speed in the displacement. Repeated calls while airborne do not create another jump because the handler checks Grounded.

The example uses a constant horizontal speed to keep the physics behavior visible without a keyboard or window. For player steering, send a direction through a command and store it in a synced property, then use that value in the same displacement calculation. The public physics example shows that input pattern.

Read positions with the step order in mind. Inside OnTick, space.Position(hero) is the position after the last completed step. CharacterMove.Position is where the capsule stood when Move ran, not a promise about the upcoming step’s final position. Only the host world has physics; clients don’t, so copy the physics position into synced properties for client views. Use Teleport for a respawn, where sweeping from the previous location would be the wrong operation.

All onegame recipes