Vela Sea
A maritime simulator whose simulation layer runs, and is tested, with no
window open.
Windows download · Web demo · 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 2357 to 2368. 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 has been fixed, and fixing it found something
worse. Three checks tested hand-copied code instead of the game's own:
the course-over-ground check held a copy of a chart function so it could
run without Pygame, and two avoidance tests re-implemented the steering
step and the grounding rule from main.py. That 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. The steering step, the grounding rule and the course-over-ground
maths now live once, in engine/navigation.py, and the game,
the tests and the verification script call the same functions. A seeded
6,000-frame run gives identical vessel state and identical frames before
and after the move.
Moving the code showed that the copied chart function was never called by the game, so that check had been testing a copy of dead code, and the course-over-ground vector it exists to draw was never on the chart. It is drawn now, for the player and the followed vessel, whenever drift exceeds half a degree.
The structural problems were fixed next. main.py held the
search-and-rescue dispatch and the AI decision helpers: the layering rule
held by its letter, because main.py was never in its scope,
but that logic is simulation and sat on the wrong side of the line the
rule was drawn to protect. It now lives in engine/sar.py
and engine/ai.py, with the engine reporting event types and
main.py mapping them to colours, and main.py fell
from 3,570 to 3,159 lines. The course-over-ground arrow and
Vessel.move() now share one velocity function. A seeded
6,000-frame run is bit-identical before and after both changes, in vessel
state, every rendered frame and the event log, and changing one constant
by a billionth makes it differ from the first frame, so the check is not
blind. Vessel spawning and most of the per-step loop are still
in main.py.
Writing a test for the chart found an accuracy bug a sailor would spot: the scale bar and range rings passed nautical miles to a function that expects world units, so the bar labelled 50 nautical miles covered 7.5. They now show true distances at every zoom, and the test fails on the old code.
Two others are fixed. engine/rules.py used to be a rules
engine nothing instantiated, while the speed caps and fines sat inline in
the game loop; the zone rules now live in it, with only their side
effects (sound, log, money) left in main.py, and a seeded run
produces identical fines and caps before and after. And a rescue used to
count as complete when the rescuer reached the first waypoint of its route,
so 145 of 543 rescues in a 14-day diagnostic completed with the rescuer
more than 3.9 nautical miles from the casualty, one of them 58.6 miles
away. Completion now requires the rescuer to be in range. The cost is
visible in the same diagnostic: rescuers sail their whole route, so port
arrivals fall from 686 to 358. The busier sea was partly the bug.
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 (one velocity definition), fuel, routes
environment.py Weather drift, fog/squall/storm events, tide, day/night
collision.py COLREGS avoidance: CPA/TCPA, give-way logic, safe pathfinding
navigation.py AI steering step, grounding rule, COG maths, rescue range
rules.py Zone rules: speed caps, fines, grace period and cooldown
sar.py Search and rescue: dispatching rescuers, completing rescues
ai.py AI emergencies, mission status, captain decisions
events.py Event kinds and sim-time format shared with main.py
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 still the weakest part of the structure at 3,159 lines. The
search-and-rescue dispatch and the AI decision helpers have moved to
engine/sar.py and engine/ai.py, but vessel spawning and most of the
per-step simulation (the player's helm, groundings, the party tender) still
sit in main.py and the Game class.

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.