How I made boats float
Boats floating in Whatever Floats Your Boat is the thing people ask me about most, so here is how it works — starting with the smallest version that floats, then why the naive one shakes itself apart.
Part one: the tutorial
The whole idea in one sentence
Archimedes worked this out around 250 BC: a submerged object is pushed up by the weight of the fluid it displaces. Push a beach ball underwater and it shoves back because it is displacing a lot of water. A brick displaces very little for its mass, so it sinks.
That is a force you can compute:
F = ρ · V · g
ρ water density (~1000 kg/m³)
V submerged volume (m³) ← the only part that changes
g gravity (~9.81 m/s²)
Density and gravity are constants. Everything interesting is in V.
The simplest thing that works
Take a box, treat water as a flat plane at y = 0, and work out how much of
the box is under it:
local WATER_Y = 0
local DENSITY = 1000
local GRAVITY = 9.81
local function buoyancy(part)
local half = part.Size.Y * 0.5
local depth = (WATER_Y - (part.Position.Y - half))
-- how much of the part is under the surface, 0 to 1
local submerged = math.clamp(depth / part.Size.Y, 0, 1)
if submerged <= 0 then return 0 end
local volume = part.Size.X * part.Size.Y * part.Size.Z
return DENSITY * volume * submerged * GRAVITY
end
Apply that upward each physics step and the box floats. It rises when pushed under, settles when released, and sits at the depth where buoyant force equals its weight.
It will also bounce forever, and it will not tip.
Fixing the bouncing
The oscillation is not a bug in the formula. It is the formula working correctly: push the box down and the restoring force grows with depth, which is exactly a spring. A frictionless spring never stops.
Real water removes energy through drag. Add a term opposing velocity:
local DRAG = 0.6
local function damping(part, submerged)
return -part.Velocity * DRAG * submerged
end
Scaling by submerged matters — a hull barely touching the surface
should feel almost nothing. Without that scaling, objects behave as though the air is
also water.
Fixing the tipping
A single force applied at the centre never produces rotation, so a one-point boat cannot roll, pitch, or capsize. The fix is to stop treating the hull as one object.
Sample several points across it, compute submersion at each independently, and apply a fraction of the force at that point:
local POINTS = {
Vector3.new(-2, 0, -4), Vector3.new(2, 0, -4),
Vector3.new(-2, 0, 4), Vector3.new(2, 0, 4),
}
for _, offset in ipairs(POINTS) do
local worldPos = part:PointToWorldSpace(offset)
local depth = WATER_Y - worldPos.Y
local submerged = math.clamp(depth / part.Size.Y, 0, 1)
if submerged > 0 then
local force = DENSITY * volume * submerged * GRAVITY / #POINTS
applyForceAtPosition(Vector3.new(0, force, 0), worldPos)
end
end
Now load one side and that side sits lower, because those sample points are deeper and generate more lift. Tip it far enough and the geometry stops recovering. Capsizing comes out of the model rather than being scripted.
Four points is enough to feel like a boat. More points is smoother and costs more.
Practical note: apply buoyancy in the fixed physics step, not per rendered frame. Force applied on a variable timestep produces behaviour that changes with frame rate, and it is the most common reason a boat behaves differently on someone else’s machine.
Part two: why it still misbehaves
Stiffness, and why it explodes
Buoyancy is a stiff spring, and game engines integrate with explicit methods on a fixed step. Push stiffness too high and the integrator overshoots: the correction exceeds the error, the next step corrects harder in the other direction, and the object leaves the scene.
The relationship is not subtle. Stability depends on the product of stiffness and timestep, so doubling the force scale needs a smaller step to stay stable. Anyone who has watched a boat launch into orbit after a tuning change has met this.
ω₀·Δt climbs: for semi-implicit
Euler, the integrator most engines actually ship, the amplitude is inflated by exactly
1/√(1−(ω₀Δt/2)²), which runs to infinity
at 2. Cross it and the hull leaves. This figure runs the spring undamped and unclamped
so the classical bound holds exactly — the clamps in the next section move it.
The practical route is to think in terms of the settling behaviour you want — how fast it should return to level, and how much it should overshoot — and derive the constants from that, rather than tuning two coupled numbers by feel. Catto’s soft-constraint formulation is the standard treatment.
Clamping is a physics decision
math.clamp(depth / size, 0, 1) looks like defensive programming. It is
actually the model.
Without the upper clamp, a deeply submerged object keeps gaining force with depth and rockets out. Real fluids do not work that way: once fully submerged, displaced volume is constant, so buoyant force is constant. The clamp is what encodes “fully submerged means fully submerged.”
The lower clamp matters for a different reason. Without it, an object above the water receives negative buoyancy — downward force from water it is not touching.
Waves make the surface a function
Everything above assumed WATER_Y = 0. Introduce waves and the surface becomes
a height field evaluated per sample point:
local function waterHeight(x, z, t)
return math.sin(x * 0.1 + t) * 0.8
+ math.sin(z * 0.13 + t * 0.7) * 0.5
end
Summed sines are cheap and look acceptable. The literature standard is Gerstner waves, which move particles in circles rather than only vertically and give the sharp crests real water has — the model Tessendorf popularised for film work.
The important part is that each sample point must query the surface at its own position. Evaluate once at the hull centre and the boat translates up and down but never rolls with the swell, which reads as wrong immediately even to someone who cannot say why.
What sampling actually approximates
Sampling points is a numerical integration. The exact quantity is
F = ρ g ∫∫∫ dV over the submerged region
which for an arbitrary hull under a moving surface has no closed form worth computing at 60 Hz. Sample points are a Monte-Carlo-flavoured estimate: more samples, less error, more cost.
Knowing that reframes the tuning. You are not inventing a fudge factor; you are choosing how coarse an integral you can tolerate. It also explains why sample placement matters more than sample count — points near the extremes of the hull carry most of the torque information, and clustering them centrally wastes the budget.
References
- Archimedes. On Floating Bodies, c. 250 BC. Proposition 5 is the displacement principle everything here rests on.
- L. D. Landau, E. M. Lifshitz. Fluid Mechanics, 2nd ed. Pergamon, 1987. Hydrostatics and the limits of the incompressible assumption.
- E. Catto. “Soft Constraints: Reinventing the Spring.” Game Developers Conference, 2011. How to specify stiffness and damping in terms of settling behaviour instead of raw constants.
- D. Baraff, A. Witkin. “Physically Based Modeling.” SIGGRAPH course notes. Explicit integration, stiffness, and why stiff systems misbehave on a fixed step.
- I. Millington. Game Physics Engine Development, 2nd ed. Morgan Kaufmann, 2010. Applying force at a point and the resulting torque.
- J. Tessendorf. “Simulating Ocean Water.” SIGGRAPH course notes, 2001. The standard reference for wave height fields.
- A. Fournier, W. T. Reeves. “A Simple Model of Ocean Waves.” SIGGRAPH, 1986. Gerstner waves in a graphics context.