VelaWind · Projects

Vela Sea

A maritime simulator whose simulation layer runs, and is tested, with no window open.
Web build · Source

A night sea chart on which a cargo ship’s status panel reads RESCUING and its track bends sharply toward a mayday call, where MV Thornwick lies aground; a banner reports the nearest vessel diverting to assist, a timestamped event log records the mayday and the response, and a roster lists the rest of the fleet underway among named ports and islands.

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.

Storm conditions

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.