Swimming and character integration
Water movement can be integrated in two ways:
- Use Water Character 2D when you want the supplied surface-swimming, diving, entry-plunge, and water-jump behavior, plus an unchanged
CurrentRawreading. - Read Water Sensor 2D directly when your game already owns its complete swimming model and only needs water state, depth, surface, and raw current data.
In both approaches, your controller remains responsible for input, land movement, collision resolution, animation, and applying the final velocity. Neither WaterCharacter2D nor WaterSensor2D moves the Rigidbody for you.
Choose a controller approach
| Starting point | Water responsibility | Controller responsibility | Best fit |
|---|---|---|---|
SampleDynamicMovement2D + WaterCharacter2D |
Calculates base water locomotion and exposes CurrentRaw. |
Handles land movement, converts raw current into its preferred push/resistance response, and assigns the final Dynamic Rigidbody velocity. | Physics-driven platform controllers. |
SampleKinematicMovement2D + WaterCharacter2D |
Calculates the same base water locomotion and exposes CurrentRaw. |
Owns current response and persistent velocity, resolves casts/sliding, moves with MovePosition(...), and reports achieved velocity. |
Cast-based or custom kinematic controllers. |
SampleDirectWaterSensorMovement2D |
Reports water state and CurrentRaw only. |
Implements the complete swim model and converts raw current into controller-specific movement. | Existing controllers with their own swimming rules. |
Start from the sample whose movement architecture matches your game. Do not combine the direct-sensor movement calculation with ApplyWaterMovement(...) during the same physics step; that would apply water speed and buoyancy twice.
What WaterCharacter2D calculates
WaterCharacter2D is a water-locomotion calculator and state machine. It reads its attached WaterSensor2D and returns a velocity for the controller to apply.
Its high-level state is exposed through CurrentLocomotion:
| State | Calculated behavior |
|---|---|
None |
No dedicated surface, entry, or dive state owns movement. |
WaterEntryBuoyancy |
Continues an entry plunge, then returns the character toward the surface. |
SurfaceSwimming |
Moves horizontally while keeping the floating point near the animated waterline. |
Diving |
Provides two-axis underwater swimming and controlled resurfacing. |
The helper uses its own water-specific settings—such as Surface Swim Speed, Dive Swim Speed, and Water Control Responsiveness—rather than guessing the controller’s land speed. The controller supplies normalized input, its current velocity, a dive request, its maximum fall speed, and the simulation time step.
Vector2 waterVelocity = waterCharacter.ApplyWaterMovement(
moveInput,
velocity,
divePressed,
maxFallSpeed,
Time.fixedDeltaTime);
ApplyWaterMovement(...) does not assign linearVelocity, call MovePosition, resolve collisions, or apply currents. It returns only the base water-locomotion velocity. Read CurrentRaw separately and add the controller’s chosen response afterward.
Choose one movement method per step
| Method | Use it for |
|---|---|
ApplyWaterMovement(...) |
Complete entry, surface-swim, persistent dive, and water-jump coordination. This is the normal choice. |
GetSwimVelocity(...) |
A narrower surface/free-swim calculation when the controller owns more state transitions. |
GetRecommendedWaterVelocity(...) |
Basic shallow-speed and buoyancy recommendations when the controller owns its own swim state machine. |
Call only one water-movement method per physics step. None of these methods changes or integrates water-current movement. See Water Current → Reading current values from a controller for the available values, both API paths, and the retained-flow example.
Shared controller update order
Use this order whether the final movement is Dynamic or Kinematic:
- Buffer one-frame input edges such as Jump, Dive, and Dash in
Updateor Input System callbacks. - At the beginning of
FixedUpdate, read continuous movement input and consume the buffered buttons. - Call
waterCharacter.RefreshNow()once. Disable automatic sensor updates when the controller owns this call. - Handle a valid water jump before requesting ordinary water movement.
- Call one water-movement method when water locomotion is active.
- Convert
CurrentRawthrough the controller’s push/resistance model and add that result to the base water velocity. - Add controller-owned abilities such as dash or knockback.
- Apply the final velocity through the controller’s normal Rigidbody or collision-aware move.
- For custom/Kinematic movement, report the velocity actually achieved after collisions to
MotionTracker2D.
Dynamic Rigidbody2D controller
The Dynamic sample uses forces for ordinary land movement. While water owns locomotion, it disables Rigidbody gravity, asks WaterCharacter2D for the base water velocity, updates its own current offset, adds that offset, and skips the land-force branch for the step.
private void FixedUpdate()
{
float dt = Time.fixedDeltaTime;
Vector2 velocity = body.linearVelocity;
Vector2 input = ReadMoveInput();
bool divePressed = ConsumeDivePress();
bool jumpRequested = ConsumeJumpPress();
waterCharacter.RefreshNow();
bool waterActive = waterCharacter.IsInWater ||
waterCharacter.CurrentLocomotion != WaterCharacterLocomotionState.None;
bool waterJump = jumpRequested &&
waterCharacter.CanWaterJump(velocity);
if (waterJump)
{
waterCharacter.NotifyJumpedFromWater();
body.gravityScale = GetLandGravityScale();
body.linearVelocity = new Vector2(velocity.x, 0f);
body.AddForce(Vector2.up * jumpImpulse, ForceMode2D.Impulse);
return;
}
if (waterActive)
{
body.gravityScale = 0f;
Vector2 baseInputVelocity = velocity - currentVelocityOffset;
Vector2 waterVelocity = waterCharacter.ApplyWaterMovement(
input, baseInputVelocity, divePressed, maxFallSpeed, dt);
currentVelocityOffset = UpdateCurrentVelocity(
currentVelocityOffset,
dt);
body.linearVelocity = waterVelocity + currentVelocityOffset;
return;
}
body.gravityScale = GetLandGravityScale();
ApplyLandMovement(input, jumpRequested, dt);
}
Subtract the previously applied currentVelocityOffset before calling ApplyWaterMovement(...), then add the updated offset exactly once. The early return prevents land gravity and movement forces from running after the final water velocity is assigned.
Kinematic controller
The Kinematic sample owns a velocity field and a separate current offset. It removes the previous offset before requesting base water movement, updates the offset from CurrentRaw, recombines both values, and resolves the resulting displacement with collider casts.
private void FixedUpdate()
{
float dt = Time.fixedDeltaTime;
Vector2 input = ReadMoveInput();
waterCharacter.RefreshNow();
bool waterActive = waterCharacter.IsInWater ||
waterCharacter.CurrentLocomotion != WaterCharacterLocomotionState.None;
if (!waterActive)
velocity = CalculateLandVelocity(input, velocity, dt);
if (waterActive)
{
Vector2 baseInputVelocity = velocity - currentVelocityOffset;
Vector2 waterVelocity = waterCharacter.ApplyWaterMovement(
input,
baseInputVelocity,
ConsumeDivePress(),
maxFallSpeed,
dt);
currentVelocityOffset = UpdateCurrentVelocity(
currentVelocityOffset,
dt);
velocity = waterVelocity + currentVelocityOffset;
}
Vector2 start = body.position;
MoveWithCollision(velocity * dt);
// Report what collision resolution actually achieved.
velocity = dt > 0f ? (body.position - start) / dt : Vector2.zero;
motionTracker.SetExternalVelocity(velocity);
}
The cast solver still owns collision response. Store and report the collision-limited final velocity, but keep the current offset as distinct controller state so it is never hidden inside WaterCharacter2D.
Direct WaterSensor2D controller
Use the direct-sensor approach when the game’s controller should define all water rules. The sensor reports facts—whether the character is inside water, at swimming depth, fully submerged, and affected by a current—but it does not decide how those facts change velocity.
The included SampleDirectWaterSensorMovement2D uses one calculated velocity for both Dynamic and Kinematic modes:
sensor.Refresh();
bool inWater = sensor.IsInsideWaterVolume;
bool freeSwimming = sensor.IsSwimmingDepth || sensor.IsFullySubmerged;
if (!inWater)
{
velocity = BuildLandVelocity(input, velocity, dt);
}
else
{
Vector2 playerVelocity = velocity - currentVelocityOffset;
playerVelocity.x = Mathf.MoveTowards(
playerVelocity.x,
input.x * swimSpeed,
waterControlAcceleration * dt);
if (freeSwimming)
{
playerVelocity.y = Mathf.MoveTowards(
playerVelocity.y,
input.y * swimSpeed,
waterControlAcceleration * dt);
}
else
{
playerVelocity.y -= gravity * shallowWaterGravityMultiplier * dt;
playerVelocity.y += shallowBuoyancyAcceleration *
sensor.Submersion01 * dt;
}
currentVelocityOffset = UpdateCurrentVelocity(
currentVelocityOffset,
dt);
velocity = playerVelocity + currentVelocityOffset;
}
Dynamic mode assigns the result to body.linearVelocity. Kinematic mode sends velocity * dt through its cast-and-slide solver, stores the achieved velocity, and supplies that value to MotionTracker2D.
The current helper used here and the sample’s Use Water Character Current API toggle are documented in the Water Current module chapter. When this controller applies sampled current itself, exclude the player from automatic Water Current Rigidbody application. Otherwise the same current is applied twice.
Water jumping
Water jumping is deliberately split between helper and controller:
CanWaterJump(velocity)decides whether surface position, locomotion state, lockout, and vertical velocity allow a jump.- The controller applies its own jump speed or impulse.
NotifyJumpedFromWater()clears surface/dive/entry state and starts a short surface-follow lockout.
if (jumpRequested && waterCharacter.CanWaterJump(velocity))
{
velocity.y = waterJumpSpeed;
waterCharacter.NotifyJumpedFromWater();
jumpRequested = false;
}
For a direct-sensor controller, define the rule explicitly. The sample permits a water jump while inside and touching water, but not when fully submerged:
bool canWaterJump = sensor.IsInsideWaterVolume &&
sensor.IsTouchingWater &&
!sensor.IsFullySubmerged;
Water entry and automatic plunge
Entry speed is not a fixed value stored by WaterCharacter2D. It is the actual downward velocity when the collider crosses the waterline. The controller supplies currentVelocity to ApplyWaterMovement(...); the helper also checks WaterSensor2D.Velocity and uses the stronger downward reading.
| Value | What it controls |
|---|---|
| Controller gravity and fall velocity | The character’s real speed on reaching the water. |
Controller maxFallSpeed |
The fall-speed cap and the high-speed reference used to map entry speed to plunge depth. |
| Sensor surface-entry minimum vertical speed | Rejects upward or negligible crossings; it is not a target speed. |
| Water Entry Plunge Depth | Maximum automatic depth produced by a fast entry. |
| Water Entry Return Strength | Acceleration back toward the surface after the plunge. |
On a valid downward crossing, the helper maps the measured speed from a small minimum plunge toward Water Entry Plunge Depth. It continues downward to that target, then accelerates upward and hands movement to surface swimming near the animated waterline. Stall recovery starts the return when collision prevents the target depth from being reached.
The sensor’s impact01 value is for scaling splash audio or VFX; it does not set locomotion speed. Tune controller gravity/fall limits to change arrival speed, and tune plunge depth/return strength to change the water response.
MotionTracker2D and custom movement
Add MotionTracker2D when a transform-driven or Kinematic character must produce reliable splash, wake, or entry velocity. After collision resolution, call:
motionTracker.SetExternalVelocity(achievedVelocity);
Report achieved velocity rather than requested velocity. If a wall clips a dash or a floor stops a plunge, water effects should see the motion that actually occurred.
Custom movement example: underwater dash
Dash belongs in the game controller. The controller owns the input, timer, cooldown, direction, animation, collision, and final applied velocity. The water integration only answers whether underwater dashing is currently allowed and supplies the base water movement.
Use separate Dive and Dash actions. Buffer the Dash edge in Update or an Input System callback:
public void RequestDash() => dashQueued = true;
Shared dash state:
[SerializeField] float underwaterDashSpeed = 10f;
[SerializeField] float underwaterDashDuration = 0.15f;
[SerializeField] float underwaterDashCooldown = 0.4f;
bool dashQueued;
float dashTimeRemaining;
float dashCooldownRemaining;
float facingDirection = 1f;
Vector2 dashDirection;
The controller can use one helper to start and apply the dash:
Vector2 ApplyUnderwaterDash(
Vector2 baseVelocity,
Vector2 input,
bool canStartDash,
float dt)
{
dashCooldownRemaining = Mathf.Max(0f, dashCooldownRemaining - dt);
if (Mathf.Abs(input.x) > 0.01f)
facingDirection = Mathf.Sign(input.x);
if (dashQueued && canStartDash &&
dashTimeRemaining <= 0f && dashCooldownRemaining <= 0f)
{
dashDirection = input.sqrMagnitude > 0.01f
? input.normalized
: new Vector2(facingDirection, 0f);
dashTimeRemaining = underwaterDashDuration;
dashCooldownRemaining = underwaterDashCooldown;
}
dashQueued = false;
if (!canStartDash)
dashTimeRemaining = 0f;
if (dashTimeRemaining <= 0f)
return baseVelocity;
dashTimeRemaining = Mathf.Max(0f, dashTimeRemaining - dt);
return baseVelocity + dashDirection * underwaterDashSpeed;
}
Dash with WaterCharacter2D
Call ApplyWaterMovement(...) first so dive transitions and water exit continue updating. Apply the controller-owned current response next, then allow the dash only while the helper reports Diving:
Vector2 waterVelocity = waterCharacter.ApplyWaterMovement(
moveInput,
velocity - currentVelocityOffset,
divePressed,
maxFallSpeed,
dt);
currentVelocityOffset = UpdateCurrentVelocity(
currentVelocityOffset,
dt);
waterVelocity += currentVelocityOffset;
bool canDash = waterCharacter.CurrentLocomotion ==
WaterCharacterLocomotionState.Diving;
velocity = ApplyUnderwaterDash(waterVelocity, moveInput, canDash, dt);
Move(velocity * dt);
The additive form preserves ordinary swimming and the controller’s current response. For a dash that replaces ordinary swim speed but still respects current, use dashDirection * underwaterDashSpeed + currentVelocityOffset while the dash timer is active.
Dash with WaterSensor2D directly
First calculate the controller’s normal sensor-based water velocity, including its own CurrentRaw response. Then use sensor state as the permission rule:
sensor.Refresh();
bool freeSwimming = sensor.IsInsideWaterVolume &&
(sensor.IsSwimmingDepth || sensor.IsFullySubmerged);
Vector2 baseWaterVelocity = BuildSensorWaterVelocity(
moveInput, velocity, dt);
velocity = ApplyUnderwaterDash(
baseWaterVelocity,
moveInput,
freeSwimming,
dt);
Move(velocity * dt);
This version does not use WaterCharacter2D at all. The controller chooses whether IsSwimmingDepth, IsFullySubmerged, another sensor state, stamina, or gameplay tags permit the dash. In both versions, apply collision after the dash is added and report the collision-limited velocity to MotionTracker2D when using custom or Kinematic movement.
Currents have their own module chapter because they can affect swimmers, ordinary Rigidbodies, marked transforms, and buoyant objects—not only character controllers.
