You have a `getWaveHeight(x, z) → y` function. You want objects that float, rock, get pushed by waves, and eventually a boat that feels great to drive. Here are 17 experiments that build one concept at a time. Each experiment produces a visible, tweakable result. No experiment is wasted — every one teaches something the next one needs.
These four experiments use a single point, a sphere or cube, constrained to move only vertically. No rotation yet. This is where you build correct intuition about forces.
Goal: See why a spring alone is not enough.
Setup: A sphere. One sample point at its center. Each physics tick:
waterY = getWaveHeight(object.x, object.z)
displacement = waterY - object.y
force = springK * displacement // pulls toward water surface
object.velocity.y += force * dt
object.y += object.velocity.y * dt
Parameters to play with:
- springK: try 5, 20, 100
- Start the sphere above water, below water, exactly at water level
What you'll see: The sphere oscillates forever. Higher springK = faster oscillation, same amplitude. It never settles. If you start it far below, it rockets past the surface and keeps going.
Key takeaway: A spring with no damping conserves energy. It will always overshoot. This is the root cause of the "shooting out" problem — you've built a trampoline, not a pool.
Goal: Replace the arbitrary spring with a physically-motivated force that is automatically mass-independent.
Setup: Same sphere, but now the forces are:
gravity = -mass * g // always pulling down
submersion = (waterY - point.y) / referenceDepth // how deep the point is, normalized
submersion = max(submersion, 0) // no force above water
buoyancyForce = mass * g * submersion // pushes up
netForce = gravity + buoyancyForce
Why this is mass-independent: Both gravity and buoyancy are proportional to mass. The mass cancels out in the acceleration (a = F/m). Change the mass to anything — the object behaves identically. Designers never touch mass to tune buoyancy feel.
referenceDepth is your main knob. It's the depth at which buoyancy exactly equals gravity (submersion = 1.0). Think of it as "how deep the object sits when at rest." Small referenceDepth = object sits high in the water (like a cork). Large = sits low (like a log).
What you'll see: Still oscillates (no damping yet), but now the equilibrium point is physically meaningful — it's referenceDepth below the water surface.
Try: Change mass. Observe that behavior doesn't change. Change referenceDepth. Observe that equilibrium depth changes.
Key takeaway: Always formulate buoyancy forces as mass * g * (something). This guarantees mass-independence. The "something" is your submersion factor.
Goal: Stop the oscillation. Understand underdamped, critically damped, and overdamped.
Add this force:
dampingForce = -dampingCoeff * mass * velocity.y
Again proportional to mass — this keeps the damping ratio constant regardless of mass.
The critical damping formula:
For a spring system with stiffness k and mass m, critical damping is:
c_critical = 2 * sqrt(k * m)
In our system, the effective spring constant is k = mass * g / referenceDepth (the slope of the buoyancy force). So:
c_critical = 2 * sqrt((mass * g / referenceDepth) * mass)
= 2 * mass * sqrt(g / referenceDepth)
Since we factor out mass in our damping (dampingCoeff * mass), the mass-independent critical damping coefficient is:
dampingCoeff_critical = 2 * sqrt(g / referenceDepth)
Parameters to play with:
- `dampingCoeff = 0.3 * critical` → underdamped, bouncy, fun for small objects
- `dampingCoeff = 1.0 * critical` → critically damped, settles fast, no overshoot
- `dampingCoeff = 3.0 * critical` → overdamped, sluggish, feels like molasses
- `dampingCoeff = 0.7 * critical` → **the sweet spot for most game objects** — one small overshoot then settles
What you'll see: The sphere drops onto water and settles smoothly. With 70% critical damping it does one gentle bob then rests. Feels like it has weight.
Key takeaway: Damping must also be proportional to mass. The critical damping coefficient only depends on g and referenceDepth — both are designable constants.
Goal: Solve the "object rockets out when pushed deep underwater" problem.
The problem: When submersion > 1, buoyancy exceeds gravity. The deeper you push it, the harder it launches. This is physically wrong — a real object fully underwater has constant buoyancy (equal to its displaced volume), not increasing buoyancy.
Solution A — Clamp submersion:
submersion = clamp((waterY - point.y) / referenceDepth, 0, 1)
Now the max upward force is exactly mass * g — it can never exceed gravity. The object rises gently from any depth.
Solution B — Saturating curve (smoother):
submersion = 1.0 - exp(-alpha * max(0, waterY - point.y) / referenceDepth)
This approaches 1.0 asymptotically. alpha controls how fast (3.0 is a good start). It gives a softer transition near the surface compared to a hard clamp.
Solution C — Velocity-dependent drag (additional safety net):
dragForce = -dragCoeff * mass * velocity.y * abs(velocity.y)
This is quadratic drag — it resists fast motion much more than slow motion. Even if the force is large, drag prevents high velocities. This mimics real water resistance.
Test procedure: 1. Start with NO clamp, NO drag. Place object 20m underwater. Watch it fly. 2. Add clamp only. Repeat. It rises smoothly but may still oscillate at surface. 3. Add quadratic drag. Repeat. It rises smoothly AND settles quickly. 4. Try saturating curve + drag. Find your preferred feel.
What you'll see with correct tuning: Object rises from any depth at a reasonable speed, reaches the surface, and settles with one gentle bob. No rockets. No trampolines.
Key takeaway: Clamp the submersion to [0, 1]. Add quadratic velocity drag. These two together make the system robust to any initial condition.
Now we add rotation. Switch from a sphere to a long rectangular box (like a plank or simple hull shape) with a rigidbody. All the force math from Phase 1 stays identical — we just apply it at multiple positions.
Goal: See torque emerge naturally from off-center forces.
Setup: Place two sample points on the box — one at the front (bow), one at the back (stern). Each point independently calculates its own buoyancy force using the same formula from Experiments 2-4, but now:
forcePerPoint = (mass * g / numPoints) * clamp(submersion, 0, 1)
Divide by numPoints so the total force still balances gravity. Apply each force at the point's world position using AddForceAtPosition (or your engine's equivalent).
That's it. You don't calculate torque yourself. Applying a force at a position that isn't the center of mass automatically generates torque. The physics engine does the cross product for you.
What you'll see: Place the box on a tilted wave. The higher end sinks, the lower end rises. The box pitches to match the wave slope. On moving waves, the box rocks fore-and-aft naturally.
Try:
- Move the points closer together → less torque, slower pitch response
- Move them further apart → more torque, faster pitch response, might overshoot
- Put both points on the same side → the box flips (intentionally break it to understand it)
Key takeaway: Torque is free — it comes from force position, not from explicit torque calculations. Point spread controls rotational responsiveness.
Goal: Get full 3D rotation response.
Setup: Place 4 points at the four "corners" of the box's waterline:
Front-Left, Front-Right
Back-Left, Back-Right
For a boat shape, common layouts are: - 4 points: rectangular corners - 5 points: 4 corners + 1 center (center adds stability) - 6 points: 3 pairs along the length (bow, midship, stern)
Each point applies (mass * g / numPoints) * submersion at its position, same as before.
What you'll see: The box now rolls AND pitches with the waves. On diagonal waves, it does both simultaneously. It looks like it's actually floating.
Try: - Different point layouts — asymmetric layouts make the box list to one side - Different point counts — more points = smoother response, diminishing returns past ~6 - Move all points to one end — watch it nose-dive permanently
Key takeaway: Point layout IS your hull shape as far as physics is concerned. You're defining a simplified waterline with discrete sample points.
Goal: Understand the single most important stability parameter.
Setup: Same box with 4 buoyancy points. Now move the center of mass up and down.
rigidbody.centerOfMass = new Vector3(0, comHeight, 0) // local space
What you'll see: - CoM well below waterline (e.g., -0.5 for a 1m tall box): Very stable. Self-righting. Hard to tip over. Feels like a heavy-bottomed boat. - CoM at waterline (0.0): Neutral. Floats normally, moderate stability. - CoM above waterline (0.3): Unstable! The box wants to flip upside down. Small waves capsize it.
The physics: When the CoM is low, gravity pulls down at a low point while buoyancy pushes up at the waterline. This creates a restoring torque — like a pendulum. When CoM is above buoyancy points, the "pendulum" is inverted and the object is unstable.
Try: - Find the exact height where it transitions from stable to unstable - Set CoM very low and apply a big force — watch it right itself - Set CoM slightly too high — watch it capsize in calm water
Key takeaway: Center of mass is your #1 tool for controlling stability. Lower = more stable. This is why real boats have heavy keels. Set this BEFORE tuning anything else.
Goal: Control HOW FAST the object rotates, independent of whether it rotates at all.
Setup: Same floating box from Experiment 7. Now we tune two things:
A) Inertia Tensor: The inertia tensor determines resistance to rotation around each axis. Higher values = slower rotation.
rigidbody.inertiaTensor = new Vector3(pitch, yaw, roll)
For a boat, you typically want: - Yaw (Y) high — boats shouldn't spin like tops - Roll (X) moderate — some rocking is nice - Pitch (Z) moderate — fore-aft rocking
Try multiplying the default tensor by a scale factor:
rigidbody.inertiaTensor = defaultTensor * inertiaMult
inertiaMult = 1 is physically accurate. inertiaMult = 5-10 makes it feel heavy and boat-like. Think of it as artificially widening the mass distribution.
B) Angular Damping:
rigidbody.angularDamping = value // or apply manual torque: -angDamp * angularVelocity
This makes rotations decay over time. Without it, a rocking boat rocks forever. Values:
- 0 = no angular damping, rocks forever
- 1-3 = moderate, rocking decays over a few seconds
- 5+ = heavy damping, rotations die quickly
What you'll see: With high inertia multiplier and moderate angular damping, the box moves like a heavy vessel — it responds to waves slowly and stops rocking smoothly instead of snapping back and forth.
Key takeaway: Inertia tensor controls rotation speed. Angular damping controls how fast rotations die. Together they control the "weight feel" of rotation, independent of actual mass.
The object floats and rotates. Now make it resist movement through water properly.
Goal: Add resistance to linear motion so objects don't slide forever on water.
Simple linear drag:
dragForce = -linearDrag * mass * velocity
Better — quadratic drag (velocity-squared):
speed = length(velocity)
dragForce = -quadDrag * mass * speed * velocity // direction of velocity, magnitude of v²
Quadratic drag is how real fluids work: drag force scales with velocity squared. This means: - Slow movement: almost no drag (objects respond freely to waves) - Fast movement: heavy drag (natural speed limit)
This is also how top speed works: when thrust equals drag, acceleration becomes zero. topSpeed = sqrt(thrust / quadDrag).
What you'll see: Without drag, wave forces slowly accelerate the box and it drifts away forever. With drag, it bobs in place. Push it with an impulse — it decelerates smoothly.
Key takeaway: Quadratic drag is essential. It prevents runaway drift, creates natural speed limits, and feels more physically correct than linear drag.
Goal: Make the object resist sideways motion more than forward motion. This is critical for anything boat-shaped.
Setup: Decompose velocity into local-space components:
localVelocity = inverseRotation * worldVelocity
forwardDrag = localVelocity.z * forwardDragCoeff // low — boats slice forward easily
sidewaysDrag = localVelocity.x * sidewaysDragCoeff // HIGH — boats resist sliding sideways
verticalDrag = localVelocity.y * verticalDragCoeff // moderate
dragForce = rotation * new Vector3(-sidewaysDrag, -verticalDrag, -forwardDrag) * mass
Typical ratios:
- Forward drag: 1.0 (baseline)
- Sideways drag: 5.0 - 20.0 (the "keel")
- Vertical drag: 2.0 - 4.0
What you'll see: Without directional drag, pushing the box sideways and forward feels the same — it slides like a hockey puck. With high sideways drag, pushing it sideways meets huge resistance, but it glides forward easily. This single mechanic makes a box FEEL like a boat even before adding any controls.
Try: - Set sideways drag to 0 — push the box. It drifts sideways. No boat feel at all. - Set sideways drag to 20 — push from the side. It barely moves. Push from behind — it surfs. - This is why real keels exist.
Key takeaway: Directional drag is arguably the single most important thing that makes a boat feel like a boat. Implement it early, not as polish.
Your object floats and resists motion. Now make the waves actively move it.
Goal: Learn to derive the water surface normal from your height function.
Method: For each sample point, take two extra height samples:
h0 = getWaveHeight(x, z)
hx = getWaveHeight(x + offset, z)
hz = getWaveHeight(x, z + offset)
// Tangent vectors
tangentX = normalize(Vector3(offset, hx - h0, 0))
tangentZ = normalize(Vector3(0, hz - h0, offset))
// Normal from cross product
normal = normalize(cross(tangentZ, tangentX))
offset should be small (0.1 - 0.5m). Too large = smooths over wave detail. Too small = noisy.
Visualize it: Draw the normal as a debug line at each sample point. On flat water it points straight up. On wave slopes it tilts.
Important note on Gerstner waves: Gerstner waves displace water horizontally, not just vertically. The "true" surface position at sample (x,z) may be shifted. For game buoyancy this usually doesn't matter much — the visual match is close enough. But if you see your normals looking wrong at wave peaks, this is why. The proper fix is iterative: sample, adjust for horizontal displacement, re-sample. For now, ignore this.
Key takeaway: You can derive surface orientation from any height function using finite differences. Three samples per point gives you one normal.
Goal: Make waves push the floating object around.
Your existing approach (which you said works well) is basically correct. Here's the refined version:
// At each sample point:
waterY_now = getWaveHeight(x, z)
waterY_prev = previousWaterHeight[pointIndex] // stored from last frame
verticalWaveVel = (waterY_now - waterY_prev) / dt
normal = computeWaveNormal(x, z)
// Rising water pushes along normal, falling water pulls
wavePushForce = normal * verticalWaveVel * waveForceStrength * (mass / numPoints)
previousWaterHeight[pointIndex] = waterY_now
Why use the wave's vertical velocity instead of just the normal? The normal tells you the direction of push. The vertical velocity tells you the magnitude — a fast-rising wave pushes harder than a slow one. A wave at its peak (velocity = 0) doesn't push at all, even though it's high. This matches how real waves transfer momentum.
Parameters:
- waveForceStrength: start at 0.5, tune to taste. Higher = objects surf more.
- Proportional to mass for mass-independence.
What you'll see: Objects drift with the waves. On large waves, they surf down the slopes. On small waves, they barely move. This looks remarkably natural.
Try:
- Crank waveForceStrength up — objects get tossed around like toys in a storm
- Set it to 0 — objects float in place while waves pass through them (looks wrong)
- Compare with your directional drag — high sideways drag means objects mostly get pushed in their forward direction
Key takeaway: Wave force = normal direction × wave vertical velocity × strength. It's simple but produces convincing wave interaction.
Time to drive.
Goal: Apply a forward force and see the boat move.
Setup: Apply force at the stern (back of the boat), in the boat's forward direction:
thrustForce = boat.forward * thrustPower * throttleInput // throttleInput: 0 to 1
ApplyForceAtPosition(thrustForce, sternPosition)
Why at the stern? Two reasons: 1. That's where real engines/propellers are. 2. Applying force behind the center of mass creates a small self-correcting torque — the boat straightens itself while thrusting. Applying at CoM would give no natural stability.
Top speed emerges from the balance of thrust vs drag:
At equilibrium: thrust = drag
thrustPower = quadDrag * mass * topSpeed²
So: topSpeed = sqrt(thrustPower / (quadDrag * mass))
You can work backwards: decide the top speed you want, and calculate the thrust needed.
What you'll see: Hold throttle — the boat accelerates, reaches top speed, and stays there. Release — it decelerates via drag. The acceleration curve is fast at first and slows as drag builds up. This is naturally arcade-y and feels good.
Try: - Apply force at the bow instead — the boat becomes unstable, wants to spin - Apply at center of mass — stable but lacks the natural "tracking" feel - Vary throttle in real time — watch the speed response lag naturally
Key takeaway: Thrust at stern + quadratic drag = natural speed curve with self-correcting stability.
Goal: Make the boat turn.
Method — Hybrid steering (recommended for arcade feel):
// Base torque — always available, even at low speed (prevents feeling stuck)
baseTorque = steerInput * baseTurnStrength
// Speed-dependent torque — turning gets better with speed (feels nautical)
speedFactor = clamp(forwardSpeed / referenceSpeed, 0, 1)
speedTorque = steerInput * speedTurnStrength * speedFactor
yawTorque = baseTorque + speedTorque
// Apply around Y axis
ApplyTorque(Vector3(0, yawTorque, 0))
The lateral drag you added in Experiment 10 is crucial here. When you apply yaw torque, the boat rotates, but its velocity still points in the old direction. The high sideways drag kills the sideways component of that old velocity, making the boat "grip" and follow its new heading. Without lateral drag, the boat spins but slides sideways — like driving on ice.
Optional — lean into turns (visual polish):
targetRoll = -steerInput * speedFactor * maxLeanAngle // negative = lean into turn
// Apply as a small roll torque or blend into visual rotation
Try: - Turn with zero lateral drag — ice skating boat - Turn with high lateral drag — tight, responsive turns - Turn at low speed vs high speed — feel the difference - Adjust base vs speed torque ratio — more base = more arcade, more speed = more simulation
Key takeaway: Steering is yaw torque + lateral drag. The drag does most of the work in making turns feel right.
Goal: Thrust only works when the propeller is in the water.
sternWaterY = getWaveHeight(sternPoint.x, sternPoint.z)
sternSubmersion = clamp((sternWaterY - sternPoint.y) / referenceDepth, 0, 1)
effectiveThrust = thrustPower * throttleInput * sternSubmersion
What this gives you:
- Boat catches air on a big wave → engine leaves water → thrust cuts out → feels real
- Boat nose-dives → stern rises → partial thrust → realistic deceleration
- Boat is beached → no thrust → can't drive on land
What you'll see: Drive fast over waves. When the stern lifts out, the engine note should change (if you have audio) and the boat slows. It adds a ton of physical believability for almost no code.
Optional — require ANY sample point to be submerged for steering to work too. This means a fully airborne boat can't turn, adding to the realism.
Key takeaway: Gate forces on submersion state. It's cheap and adds significant physical believability.
Goal: Float an object with no rigidbody — for decorations, distant boats, lightweight visuals.
// Average all sample points to get target position and rotation
avgWaterY = average of getWaveHeight at each sample point
targetPos = Vector3(object.x, avgWaterY, object.z)
// Compute orientation from 3+ sample points (plane fitting or normal averaging)
targetNormal = averageOfWaveNormals(samplePoints)
targetRotation = QuaternionFromUpVector(targetNormal, object.forward)
// Smooth interpolation (not snap)
object.position = lerp(object.position, targetPos, smoothSpeed * dt)
object.rotation = slerp(object.rotation, targetRotation, smoothSpeed * dt)
When to use: LOD0 boats far away, floating debris with no physics interaction, cosmetic buoys.
Key takeaway: No physics needed for visual-only floating. Lerp to wave height + normal rotation.
Goal: A rigidbody that tracks the water perfectly but can still interact with physics objects.
Kinematic bodies don't respond to forces — you set their position/rotation directly. But other dynamic bodies CAN collide with them and be pushed around.
// Same as Experiment 16, but use MovePosition/MoveRotation
rigidbody.MovePosition(targetPos)
rigidbody.MoveRotation(targetRotation)
When to use: The player's boat in an arcade game where you want guaranteed responsive controls, NPC boats that must follow precise paths, anything that must look physics-y but not actually be driven by forces.
A hybrid approach (advanced): Use kinematic for horizontal position (player controls exactly where the boat goes), but let the buoyancy forces handle vertical position and rotation dynamically. This gives the best of both worlds: precise steering with organic wave response.
// Horizontal: kinematic (direct control)
rigidbody.MovePosition(new Vector3(controlledX, rigidbody.position.y, controlledZ))
// Vertical + rotation: dynamic forces (experiments 2-12)
ApplyBuoyancyForces() // these affect Y and rotation only
Key takeaway: Kinematic = predictable control. Dynamic = organic feel. Hybrid = both. Choose based on gameplay needs.
Goal: Combine everything into one system. This is your final architecture.
Per physics tick, for each floating object:
for each samplePoint:
// 1. Wave query
waterY = getWaveHeight(point.worldPos.x, point.worldPos.z)
// 2. Buoyancy (Experiments 2-4)
submersion = clamp((waterY - point.worldPos.y) / referenceDepth, 0, 1)
buoyancyForce = Vector3.up * (mass * g / numPoints) * submersion
ApplyForceAtPosition(buoyancyForce, point.worldPos)
// 3. Velocity damping (Experiment 3)
dampForce = -(dampingCoeff * mass / numPoints) * velocity.y * Vector3.up
ApplyForce(dampForce)
// 4. Wave push (Experiment 12)
normal = computeWaveNormal(point.worldPos)
waveVel = (waterY - point.prevWaterY) / dt
wavePush = normal * waveVel * waveForceStrength * (mass / numPoints)
ApplyForceAtPosition(wavePush, point.worldPos)
point.prevWaterY = waterY
// 5. Gravity (handled by physics engine, or manually: ApplyForce(-mass * g * Vector3.up))
// 6. Directional drag (Experiment 10)
localVel = InverseTransformDirection(velocity)
dragForce = TransformDirection(Vector3(
-localVel.x * sidewaysDrag,
-localVel.y * verticalDrag,
-localVel.z * forwardDrag
)) * mass
ApplyForce(dragForce)
// 7. Angular damping (Experiment 8)
// (Often handled by engine's angularDamping property, or manually)
// 8. Thrust (Experiment 13)
if (throttleInput > 0):
sternSubmersion = clamp((sternWaterY - sternPoint.y) / referenceDepth, 0, 1)
thrust = forward * thrustPower * throttleInput * sternSubmersion
ApplyForceAtPosition(thrust, sternPoint.worldPos)
// 9. Steering (Experiment 14)
speedFactor = clamp(forwardSpeed / referenceSpeed, 0, 1)
yawTorque = steerInput * (baseTurn + speedTurn * speedFactor)
ApplyTorque(Vector3(0, yawTorque, 0))
Your tuning knobs (designer-facing):
| Parameter | What it controls | Typical range |
|---|---|---|
referenceDepth |
How deep the object sits in water | 0.2 - 2.0m |
dampingCoeff |
How bouncy vs settled (% of critical) | 0.5 - 1.0 |
forwardDrag |
Top speed / deceleration | 0.5 - 2.0 |
sidewaysDrag |
How much the boat "grips" in turns | 5.0 - 20.0 |
waveForceStrength |
How much waves push the object | 0.0 - 2.0 |
thrustPower |
Acceleration / top speed | depends on drag |
baseTurnStrength |
Low-speed turning ability | 0.5 - 3.0 |
speedTurnStrength |
High-speed turning ability | 2.0 - 10.0 |
inertiaMult |
Rotation sluggishness | 1.0 - 10.0 |
angularDamping |
How fast rocking dies | 1.0 - 5.0 |
centerOfMassY |
Stability (low = stable) | -1.0 - 0.0 |
None of these require knowing the object's mass. Mass affects force magnitudes but not behavior, because every force is proportional to mass.
Exp 1 → Springs oscillate (the problem)
Exp 2 → Mass-independent buoyancy (the foundation)
Exp 3 → Damping stops oscillation (critical damping)
Exp 4 → Clamping + drag stops rockets (robustness)
Exp 5 → Two points → pitch (torque from position)
Exp 6 → Four points → full rotation (hull shape)
Exp 7 → Center of mass → stability (the #1 knob)
Exp 8 → Inertia tensor → rotation speed (the #2 knob)
Exp 9 → Translational drag → speed limits (quadratic)
Exp 10 → Directional drag → the keel effect (boat feel)
Exp 11 → Wave normal sampling → surface orientation
Exp 12 → Wave push forces → wave interaction
Exp 13 → Thrust at stern → forward motion
Exp 14 → Steering → turning
Exp 15 → Engine contact → conditional thrust
Exp 16 → Transform mode → visual-only floating
Exp 17 → Kinematic mode → controlled floating
Exp 18 → Full integration → the complete boat
Each experiment adds exactly one concept. No experiment requires understanding anything you haven't built in a previous experiment. By the end, you understand every line of code because you wrote every line incrementally.
Object slowly drifts in one direction: Your wave push forces have a net bias. Make sure you're computing wave vertical velocity (delta height over time), not just using the wave height itself.
Object vibrates at high frequency: Your physics timestep is too large for your spring stiffness, or referenceDepth is too small. Increase referenceDepth or use a smaller fixed timestep.
Object sinks slowly over time: Your buoyancy total doesn't quite equal gravity due to floating point or because some points are above water. Add a 5% buoyancy bias (submersion * 1.05) or check your force division by numPoints.
Boat turns but slides sideways: Sideways drag is too low. Crank it up.
Boat feels "floaty" in a bad way: Angular damping too low, inertia too low, or vertical damping too low. Increase all three.
Boat can't get over waves: Thrust too low relative to drag. Or your engine-contact check is cutting thrust when the bow rises over a wave crest.
Different mass objects behave differently: You forgot to multiply a force by mass somewhere. Every single force in the system must include mass as a factor.
Rotation feels twitchy: Inertia tensor multiplier too low. Try 5-10x. Real boats have very distributed mass.