Galleon

Vehicles · by Brixborg

A big three-masted galleon, ready for a ship battle game. Sit at the helm: W/S set the sails, A/D steer, Q/E fire a broadside; or click a cannon. Each cannonball hit wears down her hull: a battered ship loses her fittings, then her planks, takes on water, sinks, and comes back. Put it on water.

Use it in your games: in the studio, open View > Archive, go to the Vehicles shelf and pick "Galleon".

The script

-- Copyright (c) 2026 Brixborg Owners. All rights reserved. See LICENSE.txt.
-- THE GALLEON'S WORKINGS: sailing, the cannons, taking on water, sinking,
-- and coming back. (A Script inside the Galleon model.)
--
-- SAILING   Sit at the helm (the seat behind the wheel at the back).
--           W raises the sails a notch, S lowers them (3 notches = full
--           sail, 0 = stopped); hold the key to keep going. A and D steer: the faster she goes, the
--           better she turns.
-- CANNONS   Sit on the seat behind a cannon and click to fire it; A and D
--           swing it left and right, W and S raise and lower it, slowly
--           and only so far (AIM_TURN, AIM_RAISE, AIM_DIP). Or click a
--           cannon you are standing near. From the helm, Q fires
--           every cannon on the left side, E every one on the right.
--           A cannonball bursts where it lands, deadly to anyone standing
--           right there (whoever fired it gets the kill).
-- DAMAGE    Like the old pirate games, a ship is sturdy: each cannonball
--           that hits her takes HIT_DAMAGE off her Hull (twice that below
--           the waterline). Her great hull slabs never come off (as in
--           the classic Galleons game), but hits knock off her fittings
--           (rails, barrels, lanterns, guns), and once her Hull is below
--           RIGGING_FALLS_BELOW they shoot away her rigging too (yards,
--           sails, the crow's nest). Below LEAK_BELOW she takes on water
--           faster than the pumps clear it, and at no Hull at all, or a
--           full hold, she goes down.
-- SINKING   A ship that sinks is cleared away after a while, and a new one
--           appears where she first was (turn RESPAWNS off to stop that,
--           or put a BoolValue called Respawns, set to false, in the ship:
--           then something else, like a game script, brings the new ships).
-- TEAMS     Put a BrickColorValue called TeamColor in the ship: her flag and
--           sail stripes take that colour, her spawn (the square on the
--           main deck) is for that team only, and only that team can steer
--           her and fire her cannons.
--
-- While someone is at the helm they see the ship's state at the bottom of
-- the screen: that is the HelmControls LocalScript inside this script.

-- ===========================================================================
-- THE NUMBERS (change these)
-- ===========================================================================
local TOP_SPEED = 28          -- studs per second at full sail
local SAIL_NOTCHES = 3        -- how many notches from stopped to full sail
local SPEED_CHANGE = 6        -- how quickly she picks up or loses speed (studs per second, each second)
local NOTCH_EVERY = 0.5       -- holding W or S: one more notch this often (seconds)
local TURN_RATE = 0.45        -- radians per second at full speed...
local SLOWEST_TURN = 0.35     -- ...and this share of it when stopped
local PUSH = 40               -- how hard the sails can push, for each 1 of her weight

local CANNON_REACH = 14       -- how near a cannon you must be to click it
local RELOAD = 4              -- seconds between shots from one cannon
local BALL_SPEED = 170        -- studs per second out of the barrel
local BALL_RISE = 0.1         -- how much it is aimed upwards
local BALL_FLOAT = 0.6        -- share of gravity taken off the ball (it flies flatter and further)
local BALL_LIFE = 6           -- seconds before a cannonball that hit nothing is tidied away
local BLAST = 5               -- how wide a cannonball's burst is (studs)
local BLAST_PUSH = 60000      -- how hard it throws loose pieces
local AIM_SPEED = 20          -- how fast a gunner swings their cannon (degrees per second)
local AIM_TURN = 30           -- how far it swings left or right (degrees)
local AIM_RAISE = 15          -- how far it can be raised...
local AIM_DIP = 5             -- ...and lowered (degrees)

local HIT_DAMAGE = 0.04       -- Hull taken off by one cannonball (1 = all of it: 25 hits)
local LOW_HIT = 2             -- times as much for a hit below her waterline
local RIGGING_FALLS_BELOW = 0.4 -- below this much Hull, hits shoot away her rigging as well
local LEAK_BELOW = 0.5        -- below this much Hull, she takes on water...
local LEAK = 0.05             -- ...this fast for each 1 of Hull missing below that (whole holds per second)
local PUMPS = 0.004           -- how fast the pumps empty the hold (whole holds per second)
local FLOOD_WEIGHT = 1.6      -- a full hold weighs this many times the ship
local SINK_TIME = 14          -- seconds a sunk ship stays before it is cleared away
local RESPAWNS = true         -- a new ship appears (where she first was) when the wreck is cleared
-- ===========================================================================

local ship = script.Parent
if ship:FindFirstChild("Respawns") then RESPAWNS = ship.Respawns.Value end
local spawnCopy = ship:Clone() -- (taken before anything happens: the new ship after a sinking)
local debris = game:GetService("Debris")
local players = game:GetService("Players")

local helm = ship:WaitForChild("Helm")
helm.HeadsUpDisplay = false -- (the helm has its own display)
helm.MaxSpeed = TOP_SPEED

-- The middle of the ship: the ballast block holding the stabilizer.
local middle, gyro
for _, part in ipairs(ship:GetChildren()) do
  if part.Name == "Ballast" and part:FindFirstChild("Stabilizer") then
    middle = part
    gyro = part.Stabilizer
  end
end

-- How heavy she is, altogether.
local weight = 0
for _, part in ipairs(ship:GetChildren()) do
  if part:IsA("BasePart") then weight = weight + part:GetMass() end
end

-- Her lift: wood alone would float her with her decks awash, so (as the
-- old pirate games did) a BodyForce in her middle, called Lift, helps hold
-- her up. (How strong is set where she is built: FLOAT in makegalleon.py.)
-- It goes when she sinks.
local lift = middle:FindFirstChild("Lift")

-- The sails' push: a BodyVelocity that carries the whole ship (and, when
-- she is flooding, drags her down).
local sails = Instance.new("BodyVelocity")
sails.Name = "Sails"
sails.maxForce = Vector3.new(0, 0, 0)
sails.velocity = Vector3.new(0, 0, 0)
sails.Parent = middle

-- What the helm's display shows (and what other scripts can read).
local function value(class, name, start)
  local v = Instance.new(class)
  v.Name = name
  v.Value = start
  v.Parent = ship
  return v
end
local sailLevel = value("IntValue", "SailLevel", 0)
local speedValue = value("NumberValue", "Speed", 0)
local hullValue = value("NumberValue", "Hull", 1)       -- 1 = not a scratch, 0 = wrecked (other ships' cannonballs lower it)
local floodValue = value("NumberValue", "Flooding", 0)  -- 1 = the hold is full
local sunkValue = value("BoolValue", "Sunk", false)
-- (RemoteEvents, because the helm's and gunners' controls are LocalScripts,
-- running on the players' own computers in a multiplayer game.)
local broadside = Instance.new("RemoteEvent")
broadside.Name = "Broadside" -- (the helm fires this: "left" or "right")
broadside.Parent = ship
local gunnerEvent = Instance.new("RemoteEvent")
gunnerEvent.Name = "Gunner" -- (a gunner's controls fire this: their seat, and what to do)
gunnerEvent.Parent = ship
value("NumberValue", "CannonReload", RELOAD)

-- ---------------------------------------------------------------------------
-- Team colours
-- ---------------------------------------------------------------------------
local teamColor = ship:FindFirstChild("TeamColor")

-- May this player steer her and fire her cannons?
local function crewman(player)
  if not player then return false end
  if not teamColor then return true end
  return not player.Neutral and player.TeamColor == teamColor.Value
end
if teamColor then
  for _, part in ipairs(ship:GetChildren()) do
    if part.Name == "Flag" or part.Name:find("SailBand") then part.BrickColor = teamColor.Value end
    if part:IsA("SpawnLocation") then
      part.Neutral = false
      part.TeamColor = teamColor.Value
    end
  end
end

-- ---------------------------------------------------------------------------
-- Is a piece still where it belongs on the ship? (Measured from the middle.)
-- ---------------------------------------------------------------------------
local function remembered(part)
  return {part = part, offset = middle.CFrame:toObjectSpace(part.CFrame)}
end
local function stillOn(entry)
  if not middle or not middle.Parent or entry.part.Parent ~= ship then return false end
  return ((middle.CFrame * entry.offset).p - entry.part.Position).magnitude < 2
end

-- What a cannonball can knock off a ship: her fittings at once, and her
-- rigging too once she is badly battered. (Never the hull itself.)
local FITTINGS = {Rail = true, Barrel = true, Crate = true, Lantern = true, Window = true, Cannon = true}
local function rigging(name)
  return name:find("Yard") or name:find("Sail") or name == "CrowsNest" or name == "Flag" or name == "Jib"
end

-- The ship a part belongs to (any galleon: one with a Hull), or nil.
local function shipOf(part)
  local model = part and part.Parent
  if model and model:FindFirstChild("Hull") and model:FindFirstChild("Sunk") then return model end
  return nil
end

-- ---------------------------------------------------------------------------
-- The cannons
-- ---------------------------------------------------------------------------
local cannons = {}

local function tagKill(humanoid, player)
  if not player then return end
  local old = humanoid:FindFirstChild("creator")
  if old then old:Destroy() end
  local tag = Instance.new("ObjectValue")
  tag.Name = "creator"
  tag.Value = player
  tag.Parent = humanoid
  debris:AddItem(tag, 2)
end

local function burst(position, player)
  local blast = Instance.new("Explosion")
  blast.Position = position
  blast.BlastRadius = BLAST
  blast.BlastPressure = BLAST_PUSH
  blast.DestroyJointRadiusPercent = 0 -- (it breaks nothing by itself: what breaks is decided here)
  local killed = {}
  blast.Hit:connect(function(part, distance)
    local model = part.Parent
    if model and model:IsA("Hat") then model = model.Parent end
    local humanoid = model and model:FindFirstChild("Humanoid")
    if humanoid then
      if not killed[humanoid] and distance < BLAST * 0.6 and not model:FindFirstChildOfClass("ForceField") then
        killed[humanoid] = true
        tagKill(humanoid, player)
        humanoid.Health = 0
      end
      return
    end
    local target = shipOf(part)
    if not target then return end
    local hull = target.Hull.Value
    if FITTINGS[part.Name] or (rigging(part.Name) and hull < RIGGING_FALLS_BELOW) then
      part:BreakJoints()
    end
  end)
  blast.Parent = workspace
end

-- A cannonball has hit a ship: take it off her Hull.
local function damage(target, position)
  local hull = target:FindFirstChild("Hull")
  local sunk = target:FindFirstChild("Sunk")
  if not hull or (sunk and sunk.Value) then return end
  local below = false
  for _, part in ipairs(target:GetChildren()) do
    if part.Name == "Ballast" and part:FindFirstChild("Stabilizer") then
      below = part.CFrame:toObjectSpace(CFrame.new(position)).p.Y < 3 -- (near her waterline or under it)
    end
  end
  hull.Value = math.max(0, hull.Value - HIT_DAMAGE * (below and LOW_HIT or 1))
end

local function fire(entry, player)
  if sunkValue.Value or not stillOn(entry) then return end
  if player and not crewman(player) then return end
  if tick() - entry.lastShot < RELOAD then return end
  entry.lastShot = tick()
  local cannon = entry.part
  -- Out of the side it is on, aimed a little up.
  local out = cannon.CFrame.rightVector
  if out:Dot(cannon.Position - middle.Position) < 0 then out = -out end
  local aim = (out + Vector3.new(0, BALL_RISE, 0)).unit
  local ball = Instance.new("Part")
  ball.Name = "Cannonball"
  ball.Shape = Enum.PartType.Ball
  ball.Size = Vector3.new(1.5, 1.5, 1.5)
  ball.Material = Enum.Material.Metal
  ball.BrickColor = BrickColor.new("Really black")
  ball.TopSurface = Enum.SurfaceType.Smooth
  ball.BottomSurface = Enum.SurfaceType.Smooth
  ball.CFrame = CFrame.new(cannon.Position + out * (cannon.Size.X / 2 + 2.5)) -- (well clear of her own side)
  ball.Velocity = aim * BALL_SPEED + middle.Velocity
  local float = Instance.new("BodyForce")
  float.force = Vector3.new(0, ball:GetMass() * workspace.Gravity * BALL_FLOAT, 0)
  float.Parent = ball
  ball.Parent = workspace
  debris:AddItem(ball, BALL_LIFE)

  local bang = Instance.new("Sound", cannon)
  bang.SoundId = "brixborg:cannon"
  bang.Volume = 0.7
  bang.Pitch = 0.9 + math.random() * 0.2
  bang:Play()
  debris:AddItem(bang, 2)
  local smoke = Instance.new("Smoke", cannon)
  debris:AddItem(smoke, 0.6)

  local landed = false
  local splashed = false
  ball.Touched:connect(function(hit)
    if landed or hit:IsDescendantOf(ship) or hit.Name == "Cannonball" then return end
    if hit.Material == Enum.Material.Water then
      -- Into the sea: a splash (once). The ball carries on, so it can
      -- still hole a hull below the waterline.
      if not splashed then
        splashed = true
        local sound = Instance.new("Sound", ball)
        sound.SoundId = "brixborg:splash_big"
        sound.Volume = 0.7
        sound:Play()
      end
      return
    end
    local character = player and player.Character
    if character and hit:IsDescendantOf(character) then return end
    landed = true
    local target = shipOf(hit)
    if target then damage(target, ball.Position) end
    if hit.Material == Enum.Material.Wood or hit.Material == Enum.Material.WoodPlanks then
      local crack = Instance.new("Sound", hit)   -- (splintering planks)
      crack.SoundId = "brixborg:crack"
      crack.Volume = 0.8
      crack:Play()
      debris:AddItem(crack, 1)
    end
    burst(ball.Position, player)
    ball:Destroy()
  end)
end

for _, part in ipairs(ship:GetChildren()) do
  if part.Name == "Cannon" then
    local entry = remembered(part)
    entry.lastShot = -RELOAD
    -- For aiming: the weld that holds it to the deck, as it was built.
    for _, weld in ipairs(ship:GetDescendants()) do
      if weld:IsA("Weld") and weld.Part1 == part then entry.weld = weld end
    end
    entry.base = entry.weld and entry.weld.C0
    entry.out = part.CFrame.rightVector:Dot(part.Position - middle.Position) > 0 and 1 or -1
    entry.yaw, entry.pitch = 0, 0
    table.insert(cannons, entry)
    local click = Instance.new("ClickDetector")
    click.MaxActivationDistance = CANNON_REACH
    click.Parent = part
    click.MouseClick:connect(function(player) fire(entry, player) end)
  end
end

-- ---------------------------------------------------------------------------
-- Gunners: sit on the seat behind a cannon, and click to fire it
-- ---------------------------------------------------------------------------
local gunnerControls = script:WaitForChild("GunnerControls")
local gunFor = {}        -- seat -> the cannon in front of it
for _, seat in ipairs(ship:GetChildren()) do
  if seat.Name == "GunnerSeat" then
    local best, bestDistance = nil, math.huge
    for _, entry in ipairs(cannons) do
      local distance = (entry.part.Position - seat.Position).magnitude
      if distance < bestDistance then best, bestDistance = entry, distance end
    end
    gunFor[seat] = best
    local screen, gunner = nil, nil
    seat:GetPropertyChangedSignal("Occupant"):connect(function()
      -- (Take the last gunner's controls off their screen.)
      if screen then screen:Destroy() screen = nil end
      local gui = gunner and gunner:FindFirstChild("PlayerGui")
      local display = gui and gui:FindFirstChild("Gunner")
      if display then display:Destroy() end
      gunner = nil
      local humanoid = seat.Occupant
      local player = humanoid and players:GetPlayerFromCharacter(humanoid.Parent)
      if not player or not crewman(player) then return end
      gunner = player
      screen = gunnerControls:Clone()
      local link = Instance.new("ObjectValue")
      link.Name = "Ship"
      link.Value = ship
      link.Parent = screen
      local mine = Instance.new("ObjectValue")
      mine.Name = "Seat"
      mine.Value = seat
      mine.Parent = screen
      screen.Disabled = false
      screen.Parent = player:FindFirstChild("PlayerGui") or Instance.new("PlayerGui", player)
    end)
  end
end
-- Swing a cannon round (it turns about its middle), within its limits.
local function aim(entry, turn, raise)
  if not entry.weld then return end
  entry.yaw = math.max(-AIM_TURN, math.min(AIM_TURN, entry.yaw + turn))
  entry.pitch = math.max(-AIM_DIP, math.min(AIM_RAISE, entry.pitch + raise))
  entry.weld.C0 = entry.base * CFrame.Angles(0, math.rad(entry.yaw), 0) * CFrame.Angles(0, 0, math.rad(entry.out * entry.pitch))
end

gunnerEvent.OnServerEvent:connect(function(player, seat, action, steer, lift, seconds)
  local entry = gunFor[seat]
  local humanoid = seat and seat.Occupant
  if not entry or not humanoid or players:GetPlayerFromCharacter(humanoid.Parent) ~= player then return end
  if action == "aim" then
    -- (D swings it to the gunner's right, W raises it.)
    local step = AIM_SPEED * math.min(seconds or 0.1, 0.25)
    aim(entry, -steer * step, lift * step)
  else
    fire(entry, player)
  end
end)

broadside.OnServerEvent:connect(function(player, side)
  -- (Only whoever is at the helm can call for a broadside.)
  local occupant = helm.Occupant
  if not occupant or players:GetPlayerFromCharacter(occupant.Parent) ~= player then return end
  local right = middle.CFrame.rightVector
  for _, entry in ipairs(cannons) do
    local onRight = (entry.part.Position - middle.Position):Dot(right) > 0
    if (side == "right") == onRight then
      fire(entry, player)
      wait(0.15)
    end
  end
end)

-- ---------------------------------------------------------------------------
-- The helm: who is steering, and their display
-- ---------------------------------------------------------------------------
local helmControls = script:WaitForChild("HelmControls")
local captain, captainScreen = nil, nil
local removeHelmScreen

-- Take the display off the captain's screen.
removeHelmScreen = function()
  if captainScreen then captainScreen:Destroy() end
  local gui = captain and captain:FindFirstChild("PlayerGui")
  local display = gui and gui:FindFirstChild("Helm")
  if display then display:Destroy() end
end

local function changeCaptain()
  local humanoid = helm.Occupant
  local player = humanoid and players:GetPlayerFromCharacter(humanoid.Parent)
  if player and not crewman(player) then player = nil end -- (sitting there, but not her crew)
  if player == captain then return end
  removeHelmScreen()
  captain, captainScreen = player, nil
  if player then
    local controls = helmControls:Clone()
    local link = Instance.new("ObjectValue")
    link.Name = "Ship"
    link.Value = ship
    link.Parent = controls
    controls.Disabled = false
    controls.Parent = player:FindFirstChild("PlayerGui") or Instance.new("PlayerGui", player)
    captainScreen = controls
  end
end

-- Sails up and down: a notch for each press of W or S. (Nobody at the
-- helm: she keeps her sails as they were.)
-- Holding the key keeps going, a notch every NOTCH_EVERY seconds.
local holding = 0
helm:GetPropertyChangedSignal("Throttle"):connect(function()
  local throttle = helm.Throttle
  holding = holding + 1
  local mine = holding
  while throttle ~= 0 and helm.Throttle == throttle and holding == mine and not sunkValue.Value and captain do
    sailLevel.Value = math.max(0, math.min(SAIL_NOTCHES, sailLevel.Value + throttle))
    wait(NOTCH_EVERY)
  end
end)

-- ---------------------------------------------------------------------------
-- Sinking, and the new ship
-- ---------------------------------------------------------------------------
local function sink()
  sunkValue.Value = true
  sailLevel.Value = 0
  if lift then lift:Destroy() end
  if gyro then gyro.maxTorque = Vector3.new(0, 0, 0) end -- (she can list and roll as she goes)
  sails.maxForce = Vector3.new(0, weight * workspace.Gravity * FLOOD_WEIGHT, 0)
  sails.velocity = Vector3.new(0, -40, 0)
  helm.Disabled = true
  local gurgle = Instance.new("Sound", middle)   -- (bubbling as she goes down)
  gurgle.SoundId = "brixborg:bubbles_loop"
  gurgle.Looped = true
  gurgle.Volume = 0.8
  gurgle:Play()
  wait(SINK_TIME)
  removeHelmScreen()
  -- (The new ship goes in first: this script goes with the wreck, and
  -- stops. The new ship's own copy of it starts everything again.)
  if RESPAWNS and spawnCopy then spawnCopy.Parent = ship.Parent end
  ship:Destroy()
end

-- ---------------------------------------------------------------------------
-- Every moment
-- ---------------------------------------------------------------------------
local heading = math.atan2(-middle.CFrame.lookVector.X, -middle.CFrame.lookVector.Z)
local speed = 0
local water = 0
if gyro then gyro.maxTorque = Vector3.new(gyro.maxTorque.X, gyro.maxTorque.X, gyro.maxTorque.Z) end -- (it steers too)

while ship.Parent and not sunkValue.Value do
  local passed = wait(0.05)
  changeCaptain()
  if not middle.Parent or middle.Parent ~= ship then
    water = 1 -- (her keel is broken)
  end


  -- ---- Speed and steering ----
  local inWater = workspace:FindWaterAt(middle.Position) ~= nil
  local wanted = inWater and TOP_SPEED * sailLevel.Value / SAIL_NOTCHES or 0
  if speed < wanted then speed = math.min(wanted, speed + SPEED_CHANGE * passed)
  else speed = math.max(wanted, speed - SPEED_CHANGE * passed) end
  local turning = TURN_RATE * (SLOWEST_TURN + (1 - SLOWEST_TURN) * speed / TOP_SPEED)
  if captain then heading = heading - helm.Steer * turning * passed end
  local facing = CFrame.Angles(0, heading, 0)
  if gyro then gyro.cframe = facing end
  speedValue.Value = speed

  -- ---- Water in the hold ----
  local leaking = math.max(0, LEAK_BELOW - hullValue.Value) * LEAK
  water = math.max(0, math.min(1, water + (leaking - PUMPS) * passed))
  if hullValue.Value <= 0 then water = 1 end
  floodValue.Value = water

  local push = inWater and weight * PUSH or 0
  sails.maxForce = Vector3.new(push, weight * workspace.Gravity * FLOOD_WEIGHT * water, push)
  sails.velocity = facing.lookVector * speed + Vector3.new(0, -40, 0)

  if water >= 1 then sink() end
end

More vehicles