The 3D character controller
Complete SquashPlayer.cs: movement, gravity, jump, contact checks and resetting state.
Attach Scripts/SquashPlayer.cs to the player root. Add SquashMob.cs, SquashGame.cs, SquashHud.cs and LandingPulse.cs before the full build; these classes reference one another.
Complete SquashPlayer.cs
using Reckfell;
public partial class SquashPlayer : CharacterBody3D
{
[Signal] public delegate void HitEventHandler();
[Export] public float Speed { get; set; } = 5;
[Export] public float Gravity { get; set; } = 24;
[Export] public float JumpSpeed { get; set; } = 8;
[Export] public float BounceSpeed { get; set; } = 6;
public bool Active { get; set; } = true;
private Area3D _detector = null!;
private LandingPulse _visual = null!;
public override void _Ready()
{
_detector = GetEntity<Area3D>("MobDetector");
_visual = GetEntity<LandingPulse>("Visual");
}
public override void _PhysicsProcess(double delta)
{
if (!Active)
{
Velocity = Vector3.Zero;
return;
}
Vector3 velocity = Velocity;
if (!IsOnFloor()) velocity.Y -= Gravity * (float)delta;
if (IsOnFloor() && Input.IsActionJustPressed("jump")) velocity.Y = JumpSpeed;
Vector2 input = Input.GetVector("move_left", "move_right", "move_forward", "move_back");
velocity.X = input.X * Speed;
velocity.Z = input.Y * Speed;
bool falling = velocity.Y < 0;
Velocity = velocity;
MoveAndSlide();
Position = new Vector3(Mathf.Clamp(Position.X, -6, 6), Position.Y, Mathf.Clamp(Position.Z, -6, 6));
if (falling && IsOnFloor()) _visual.Pulse();
for (int i = 0; i < GetSlideCollisionCount(); i++)
{
KinematicCollision3D collision = GetSlideCollision(i);
if (falling && collision.GetNormal().Y > 0.5f &&
collision.GetCollider() is SquashMob mob && mob.TrySquash())
{
velocity = Velocity;
velocity.Y = BounceSpeed;
Velocity = velocity;
return;
}
}
if (IsOnFloor())
{
foreach (Entity3D body in _detector.GetOverlappingBodies())
{
if (body is SquashMob mob && !mob.IsQueuedForDeletion())
{
TakeHit();
break;
}
}
}
}
public void StartAt(Vector3 position)
{
Position = position;
Velocity = Vector3.Zero;
ResetPhysicsInterpolation();
_visual.ResetVisual();
Active = true;
}
public void TakeHit()
{
if (!Active) return;
Active = false;
Velocity = Vector3.Zero;
EmitSignal(SignalName.Hit);
}
}Speed and physics step
_PhysicsProcess() updates Velocity in world units per second. MoveAndSlide() applies the physics interval, so do not multiply movement velocity by delta again. Gravity changes vertical velocity over time, which is why Gravity * delta belongs there.
Input.GetVector() limits direction length. Input X becomes world X; input Y becomes world Z. W means negative Z, and controls do not rotate with the camera. No input clears X/Z velocity.
Position X/Z is clamped to −6…6 to keep the character on the 14 × 14 floor. This is a prototype rule, not invisible physics walls.
State
Active = false explicitly disables controls and clears velocity. StartAt() resets position, velocity, interpolation and visual animation for another round. Call it on a ready player; _Ready() obtains MobDetector and Visual references.
After MoveAndSlide(), contacts describe the current movement. See stomps and side contacts. They explain parts of this complete class, not additional scripts.
Result
Build all five classes, run Arena.rscene and press Start. The character moves at 5 units/second without faster diagonals, and Space jumps only from the floor. For a missing character, check camera and mesh; for falling through, check both shapes and player mask 5.