Vela Sea
A maritime simulator whose simulation layer runs, and is tested, with no
window open.
Web build · Source
The problem
A game loop and a simulation want to live in the same objects. The thing that knows a vessel's heading is the thing that knows how to draw it, and the frame that advances the world is the frame that paints it. That is the path of least resistance, and it is also the end of being able to check anything. Once the two are one thing, nothing can be verified without opening a window, so confirming that a vessel handles a crossing correctly means playing it and watching.
Why the obvious approach did not work
The obvious fix is discipline: keep drawing out of the simulation and be careful. That does not hold, because "be careful" has no failure mode. Nothing reports it broken. No build turns red on the day a rectangle from the drawing layer ends up inside the physics, and the drift only becomes visible much later, when the test that was supposed to exist turns out to be impossible to write.
So the rule is stated instead as a property a machine can check: nothing
under engine/, data/ or config.py
imports Pygame. That is one grep, and it either passes or it does not. That
single constraint is what lets a 16-scenario bot play the whole game under
SDL's dummy video and audio drivers, with no display and no sound card,
through movement, contracts, zone fines, grounding damage, hull failure,
fuel exhaustion and rescue dispatch.
The place this is most visible is the one that looks like a workaround.
In engine/collision.py at line 164 the router imports its
waypoint list from data/world_data.py inside the function that
needs it rather than at the top of the file, so that
loading engine/ does not pull in data/. That is
the rule being obeyed at the point where obeying it costs something, not
waived because it had become inconvenient. A module-level import would have
been tidier to read and would have quietly made the layer depend on the
thing it is not allowed to depend on.
One trade-off
Collision avoidance does not run inside the fixed timestep. It runs once
per rendered frame, at main.py lines 2789 to 2800. Inside the
simulation loop it ran up to 375 times per frame, and that O(n²) pair scan
repeated hundreds of times per frame was the crash at 3× speed, so it was
lifted out of the loop.
The cost is worth stating precisely, because it is not the kind that arrives as a bug report. The fixed timestep exists so that the simulation does not depend on frame rate. This is the one subsystem exempt from it. Avoidance is now sampled at display rate, so the world advances further between two give-way decisions on a slow machine than on a fast one, which means a slow machine gets coarser collision behaviour than a fast one. The web build halves it again, to every other frame. Nothing in the repository measures the difference between them.
What should change
The least comfortable one first. test_cog_math.py holds a
hand-copied body of the function at render/chart.py line 140,
described in its own docstring as an exact copy of that function so the
logic can be tested without importing Pygame. The two agree today,
checked line by line. Nothing keeps them agreeing. Two of the
avoidance tests do the same thing from the other
end: tests/test_collision.py line 70
and tests/test_traffic.py line 72 each re-implement the
steering step that the game itself runs at main.py line 2503,
so they pass against a loop the game does not execute. This is the same
parallel-texts-drift failure the Lodestar case study is built on,
reproduced under the same handle, after the argument had already been
made.
Then the two structural ones. main.py is 3,544 lines, and the
search-and-rescue dispatch and the AI decision helpers are in it. The
layering rule holds by its letter, because main.py was never
in its scope, but that logic is simulation and it sits on the wrong side of
the line the rule was drawn to protect.
And engine/rules.py defines a rules engine that is exported
from engine/__init__.py and never instantiated anywhere, while
the zone rules the game actually enforces, the speed caps and the fines,
sit inline in the game loop. Someone looking for the rules finds a module
named for them that does nothing.
The rest of this page is fetched from the Vela Sea README at build time and rendered here, so it is maintained in the repository and not in this site. It covers the architecture, the layering rule and how it is enforced, and, under "Known limits", what does not work.
Architecture
The layering rule is that simulation never draws and rendering never mutates
state. It is enforced by a simple property: nothing under engine/, data/ or
config.py imports Pygame, which is what makes the headless test suite
possible.
engine/ Pure Python simulation, no Pygame
world.py World, Port (berths, draft limits), Island, Zone, NavMark
ship.py Vessel physics, fuel, route state machine, autopilot
environment.py Weather drift, fog/squall/storm events, tide, day/night
collision.py COLREGS avoidance: CPA/TCPA, give-way logic, safe pathfinding
career.py Career state, job board, contracts, ranks, save and load
mission.py AI mission generation and tracking
render/ Pygame drawing, reads engine state
camera.py World to screen conversion, single source
chart.py Chart layers, vessels, weather visuals, status bar
panels.py HUD, career panel, docking menu, title, minimap, game over
sound.py Sound manager and standard-library WAV synthesis
data/world_data.py Sea geography, ports, islands, zones and AI routes
tests/ Headless pytest suite and a 16-scenario gameplay bot
main.py Game loop: input, fixed-timestep simulation, render
config.py Tunable constants (401 of them)
Every tunable number lives in config.py rather than at its use site, so
balance changes are edits to one file.
main.py is the weakest part of the structure at 3,544 lines, and some logic
that belongs in engine/ (search-and-rescue dispatch, AI decision helpers,
vessel spawning) currently sits in the Game class instead.

Known limits
Two limits are worth stating next to the architecture rather than leaving to be found. The save is career-only: money, reputation, statistics, achievements and hull persist, but the active contract, world state, weather and position do not, so Continue restores a career at the spawn point rather than resuming the passage that was under way. And a vessel displaced far from its route has no reliable way back to it, which is the largest remaining source of AI groundings: measured over three seeds and fourteen simulated days, 84% of groundings happen more than 15 world units off the vessel's own route, against 1% on it. Three attempted fixes each measured worse than the shipped behaviour and were not merged. KNOWN_ISSUES.md carries every known defect with a severity rating, the measurements behind that second limit, and the rejected patches.