VelaWind · Projects

Vela Sea

A maritime simulator whose simulation layer runs, and is tested, with no window open.
Windows download · Web demo · 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 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.

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.