XR: stick movement and snap turning
Locomotion foundation: collider, origin ownership, physics ticks, input and diagnostics.
This example adds left-stick movement, right-stick snap turning and player collision. It requires a working XR session and action map. Physical head movement is handled separately from game movement: tracking retains control of the camera.
Prepare the project
- Complete XR setup and add XrStartup from startup handling.
- Copy RoomScaleBody.cs from room scale. Its code is used unchanged here.
- Use an upright rig without body scale or tilt: world +Y is up and XRServer.WorldScale=1. Under these conditions one unit corresponds to one meter.
- Create the structure below. Give Player a CapsuleShape3D: Radius=0.2, Height=1.8, CollisionShape3D position=(0, 0.9, 0). Start Player at floor level.
Level
├── XrStartup (XrStartup.cs)
├── Player (CharacterBody3D, XrPlayer.cs)
│ ├── CollisionShape3D (CapsuleShape3D)
│ └── XROrigin3D
│ ├── XRCamera3D (head)
│ ├── XRController3D (left_hand, grip)
│ └── XRController3D (right_hand, grip)
└── Floor (StaticBody3D)
└── CollisionShape3D (BoxShape3D)For Floor, set BoxShape3D.Size=(20, 1, 20) and body position=(0, -0.5, 0). The collider top is Y=0. Add the visible floor separately: MeshInstance3D does not replace a physics collider. Player and obstacles must have matching collision layer/mask. Do not add another script moving Player or the origin.
Configure actions
In the working action map, retain controller poses and add two Vector2 actions. The minimal select-only example is insufficient. A concrete example profile is /interaction_profiles/oculus/touch_controller.
| Action | Type | ToplevelPaths | BindingPath |
|---|---|---|---|
| grip | Pose | /user/hand/left, /user/hand/right | Separate bindings /user/hand/left/input/grip/pose and /user/hand/right/input/grip/pose |
| move_player | Vector2 | /user/hand/left | /user/hand/left/input/thumbstick |
| turn_player | Vector2 | /user/hand/right | /user/hand/right/input/thumbstick |
This is an example for a stick-equipped profile, not a promise of every device’s support. Use valid paths for other profiles. Minimal Simple Controller supplies no sticks. Save the map before session initialization; editing a resource during a session does not rebuild runtime actions. Here positive movement Y means forward and positive turning X means right.
Leave the camera Tracker=head. For controllers set Tracker=left_hand/right_hand first, then Pose=grip. Do not select Pose dynamically: the current API has a tracking-cache limitation.
Movement and turning
Save as XrWalk.cs. This is stateful: create one instance per player, not per frame. Call Step exactly once per physics tick.
using System;
using Reckfell;
public sealed class XrWalk
{
private bool turnLatched;
// Call once per physics tick. Upright, unscaled rig; one instance per player.
// true means physical head/body alignment is blocked, not successful tracking.
public bool Step(CharacterBody3D body, XROrigin3D origin, Entity3D head,
Vector2 move, float turn, bool allowInput, double delta)
{
if (!move.IsFinite() || !float.IsFinite(turn)
|| !double.IsFinite(delta) || delta <= 0 || delta > 1)
throw new ArgumentOutOfRangeException(nameof(delta), "Invalid input or physics delta.");
if (!allowInput)
{
body.Velocity = Vector3.Zero;
turnLatched = true; // Require neutral before the next snap after focus loss.
return false;
}
if (RoomScaleBody.FollowHead(body, origin, head))
{
body.Velocity = Vector3.Zero;
turnLatched = true;
return true;
}
if (Mathf.Abs(turn) <= 0.25f) turnLatched = false;
if (!turnLatched && Mathf.Abs(turn) >= 0.75f)
{
RoomScaleBody.TurnAroundHead(body, origin, head,
-Mathf.Sign(turn) * Mathf.Pi / 6);
turnLatched = true;
}
if (move.Length() <= 0.2f) move = Vector2.Zero;
if (move.LengthSquared() > 1) move = move.Normalized();
Vector3 forward = -head.GlobalBasis.Z;
forward.Y = 0;
if (forward.LengthSquared() < 0.000001f) forward = -body.GlobalBasis.Z;
forward = forward.Normalized();
Vector3 horizontal = (forward.Cross(Vector3.Up) * move.X + forward * move.Y) * 2;
Vector3 velocity = body.Velocity;
velocity.X = horizontal.X;
velocity.Z = horizontal.Z;
if (body.IsOnFloor() && velocity.Y < 0) velocity.Y = 0;
else velocity.Y -= 9.8f * (float)delta;
body.Velocity = velocity; // Units/second: MoveAndSlide applies its own frame delta.
body.MoveAndSlide(); // Carries the origin with the body; do not compensate it here.
return false;
}
}Speed is 2 units/s; gravity is 9.8 units/s². Do not multiply Velocity by delta: MoveAndSlide applies its own physics step. Movement dead zone is 0.2 without remapping the remaining range. Diagonal movement is capped to the same total speed. Direction uses the horizontal view projection; looking straight up/down falls back to body heading.
Turning is 30° when stick deflection reaches 0.75. Release to 0.25 before another step; holding does not repeat turns. Losing input permission also requires neutral before the next turn. These are example parameters, not universal comfort settings.
Wire the player
Save as XrPlayer.cs and attach to Player. Assign all five references: Origin, Head, MoveController=left controller, TurnController=right controller, Startup. An incomplete structure produces an explicit error and disables processing.
using Reckfell;
public partial class XrPlayer : CharacterBody3D
{
[Export] public XROrigin3D Origin { get; set; } = null!;
[Export] public XRCamera3D Head { get; set; } = null!;
[Export] public XRController3D MoveController { get; set; } = null!;
[Export] public XRController3D TurnController { get; set; } = null!;
[Export] public XrStartup Startup { get; set; } = null!;
public bool PhysicalGap { get; private set; }
private readonly XrWalk walk = new();
public override void _Ready()
{
if (Origin is null || Head is null || MoveController is null
|| TurnController is null || Startup is null || !IsAncestorOf(Origin)
|| Head.GetParent() != Origin || MoveController.GetParent() != Origin
|| TurnController.GetParent() != Origin)
{
ReckfellApi.PushError("XrPlayer: assign the startup and complete body/origin rig.");
SetPhysicsProcess(false);
}
}
public override void _PhysicsProcess(double delta)
{
var tracker = XRServer.GetTracker(Head.Tracker) as XRPositionalTracker;
var pose = tracker?.GetPose("default"); // Borrowed state: do not Dispose.
var movePose = MoveController.GetPose();
var turnPose = TurnController.GetPose();
bool active = Startup.HasFocus && pose is not null && pose.HasTrackingData
&& movePose is not null && movePose.HasTrackingData
&& turnPose is not null && turnPose.HasTrackingData;
PhysicalGap = walk.Step(this, Origin, Head,
active ? MoveController.GetVector2("move_player") : Vector2.Zero,
active ? TurnController.GetVector2("turn_player").X : 0, active, delta);
}
}Permission requires focus and current poses for the head and both controllers. The script reads the pose’s own HasTrackingData and does not dispose shared objects. Without permission this example zeroes velocity and stops all its locomotion, including gravity. It does not stop camera tracking or change project-wide pause. Moving platforms and other pause rules require a separate policy.
RoomScaleBody first brings the collider under the physical head while preserving the world origin. During game MoveAndSlide, the origin instead travels with the body. Do not compensate it again. PhysicalGap=true means an obstacle left the head and body separated; virtual locomotion is blocked. This cannot stop the physical head: fading and interaction blocking on penetration are separate game logic absent from this example.
Check the result
| Situation | Expected result / diagnosis |
|---|---|
| Left stick forward | Movement along the floor in view direction; diagonals do not increase speed |
| Right stick right and held | One 30° turn; another only after neutral |
| Virtual movement into a wall | CapsuleShape3D stops the body; camera travels with the body |
| Physical step through a wall | PhysicalGap is possible; head tracking continues, this is not physical protection |
| No movement | Check Startup.FailureReason/HasFocus, both hand poses, action names and profile; zero GetVector2 alone does not establish connection |
| Falling through | Check Shape, floor position, layer/mask and upright rig |
| Double movement / rotation | Remove a second movement owner; do not multiply Velocity by delta or move Head manually |
Sixteen XrWalk checks ran in the installed engine with real floor/wall colliders, a synthetic head and input. XrPlayer and XrStartup compiled together; the full player callback with a real XR session did not run. Device bindings, session recovery, comfort, slopes and moving platforms are not established. This example contains no teleport, jump or arbitrary-shape rotational sweep; the rig assumes an upright symmetric capsule.