Dev blog 2026-10-22 · Keel 0.5.3 development

The map that will not sit still

Every hour, a storm rebuilds a piece of the deep.

We built a map that reshapes itself while you play. This post is the story of why, how it works, what it costs us in code, and where it is still thin.

Layer storms have run since 0.5.0. The six Rifts joined the rotation in 0.5.3, on a clock of their own. We wrote this while that work landed.

Layer 6 with a red banner warning that a plasma front is building and will reshape the layer in under five minutes. Only the sectors around the centre are charted.
Layer 6, five minutes before a storm that we raised with a dev command for this capture. The banner counts down. Only the centre is charted.
The problem

A map you can memorize is a map you can finish.

We started with fixed grids. Every layer was fifteen sectors on a side and looked the same every day.

Two fixed doors, one up and one down, joined each layer to its neighbours. That is easy to build and easy to balance. It is also a map you chart once, learn by heart, and stop looking at.

A chokepoint stays a chokepoint. A good ambush spot stays a good ambush spot. Once the map is learned, there is nothing left to find.

Layer 2 in an early alpha build: a full grid of hexes with an up door at the top left and a down door at the top right, a fleet of four ships near the middle, and three hostile contacts on the right.
Layer 2 in an early alpha capture, before storms. A full 15 by 15 grid and two fixed doors, the same every day.
What we wanted instead

Three rules for a moving map.

The design came from a short list of answers we wrote down before any code. These are the three that shaped everything else.

Fresh space to chart

A storm clears your map of that layer. Visit a sector and it is charted, with its neighbours in outline. A full map pays its reward again, every shift.

One thing never moves

The centre at 8, 8 and the six sectors around it are the only constant. A sector that holds a player structure is never carved away or blocked.

A warning, not an ambush

A banner and a countdown arrive five minutes ahead. Every storm has a themed name. The clock is a rule you can read, not a secret.

What we built

One storm an hour, twelve maps, and a clock you can read.

At the top of every UTC hour a storm hits the next layer in the rotation. Each of the six layers reshapes every six hours. The six Rifts run on their own slower clock.

6 hBetween storms on one layer. One layer storm lands every UTC hour.
12 hBetween storms on one Rift. One Rift storm lands every 2 hours.
5 minWarning before any storm. A starting value that we will tune with testers.
18Storms in every 12 hours across the twelve maps: 12 on the layers and 6 on the Rifts.
UTC hour, then the pattern repeats
000102030405060708091011
Layers: one storm every hour
L1L2L3L4L5L6L1L2L3L4L5L6
Rifts: one storm every second hour
R1R2R3R4R5R6
Layer N storms at hour N minus 1 and again every 6 hours. Rift N storms at twice that hour and again every 12. On every even hour a Rift storm lands in the same hour as the storm on Layer 1, 3, or 5.
What a storm does

Some things never move. Some get moved. The rest start over.

A storm gives a map a new shape. This is the contract we wrote for what happens to everything standing on it.

StaysThe centre at 8, 8 and the six sectors around it. Every sector that holds a structure. Every sector of a running incursion. On Layer 1, the drill sector. The generator also promises that every open sector can be reached from the centre without crossing debris or narrows.
MovesShips, NPC squads, wrecks, and wormhole ends that stand where the new shape has no room. Each goes to the nearest open sector, and a ship's queued path is cleared. NPCs never land beside Haven on a layer that has one.
Starts overYour map of that layer. The belts and gas clouds, which are placed fresh and full. Every wormhole end, which has to be found again. The mapping reward, which pays again for a full map.
Before: a sprawl with a few walls, a nebula, and an ion storm. One ship stands in a far sector.
After: the same centre and structure sector inside a new shape. The ship's sector is gone, so it moved to the nearest open one.
Centre and its ring, always open Structure sector, always open Debris wall Nebula Ion storm A ship

Schematic drawn for this post, not a capture. The shapes are illustrative. The rules they show are the real ones.

Four patterns, four hazards.

Each shift draws a sector budget between a floor and 225. The floor is 207 on Layer 1 and falls to 151 on Layer 6. The sectors are spent inside a hidden 20 by 20 box, and the cells a pattern does not spend are empty space for that shift. Layer 1 reshapes more gently than Layer 6, because the hazard shares grow with depth.

  • Debris is a wall. It is 3 percent of a map on Layer 1 and 10 percent on Layers 5 and 6.
  • Nebula hides contacts from outside, and ships inside see only their own sector. Layer 1 has none.
  • Ion storms double the time it takes to move out of a sector.
  • Narrows admit Patrol, Frigate, and Destroyer hulls only. Cruisers and Carriers go around.

A wall or a narrows is placed only if every open sector can still be reached from the centre. Nebula and ion storms are not walls, so they go anywhere.

Layer 2 fully charted. The header reads Charted 100 percent. Narrows carry a small D mark, two incursions are drawn as blocks of orange and violet hexes, and a fleet of six Carriers sits at the centre.
Layer 2 fully charted. The header reads Charted 100%. Narrows carry a ≤ D mark, and two incursions show as blocks of hexes.
SprawlOne irregular landmass grown out from the centre, with ragged edges and holes.
ChannelsLong corridors and branches. Chokepoints, ambushes, and long trips.
ArchipelagoFour to seven clusters joined by narrow bridges.
RingsConcentric bands around the centre, joined by two to four spokes.
A storm, as a pilot lives it

You get five minutes and a countdown.

The server owns the clock. What you see is the warning, the hit, and the fog that follows.

  1. Five minutes outA banner appears on that map and counts down. Global chat says where the storm is building. Each storm takes a themed name from a list of eight.
  2. One minute outThe edges of the map flicker and an alarm sounds.
  3. The hitThe screen flashes once, more softly when reduced flash is on. Global chat names the new pattern.
  4. Your shipsA ship standing in a lost sector is moved to the nearest open one, and your log says where.
  5. The fogYour map of that layer is dark. Chart it again, and a full map pays again.
GlobalAn ion squall is building on Layer 1. The layer will reshape in 5 minutes.
GlobalThe storm has passed. Layer 1 has broken into channels.
LogAn ion squall swept the layer. Your map of Layer 1 is dark again: 211 sectors to chart.
LogStorm: your ships were pushed to X, Y.

Wording follows the server's announcements. The sector count in the log line comes from the capture below. X and Y are filled in by the server, and the pattern phrase in the chat line changes with the shape.

Five minutes out

The banner names the storm and counts down.

On the current build a dev command raised a storm on Layer 1. The banner reads that an ion squall is building and that the layer reshapes in 4:57. The log notes the storm too.

The banner shares its line with an unrelated poll notice.

Layer 1 with a red banner reading that an ion squall is building on Layer 1 and will reshape it in 4 minutes 57 seconds. The header reads Charted 1 percent and the log says a storm is due in 5 minutes.
Layer 1 on the current build, a storm raised with a dev command. The banner counts down from 4:57, and the log reads Storm on Layer 1 in 5 minutes.
The hit

Dark again, and a count to chart.

Seconds later the storm hit. The log says an ion squall swept the layer and that 211 sectors are left to chart. The header still reads Charted 1%, so the fog looks the same before and after. The new shape appears as you chart it.

The same Layer 1 view after the storm. The log lists the storm hit, that an ion squall swept the layer, and that the map is dark again with 211 sectors to chart. The warning banner is gone.
The same Layer 1 moments after the hit. The log lines are the ones the server wrote. The banner row now carries only the poll notice.
The fog

The client cannot draw what it was never sent.

The server sends each pilot only the sectors that pilot has charted this shift, plus the outlines of their neighbours. A click on a charted sector routes through charted sectors. A click into the fog flies a straight line and stops at the first wall. The route never leaks what is hidden.

A Rift has no Haven to chart for you, so a Rift map starts empty.

Layer 2 with only the sectors around the centre drawn and the header reading Charted 1 percent. Two blocks of violet incursion hexes are drawn on the map outside the charted centre.
Layer 2 on the current build. The header reads Charted 1%, and only the centre is known. The violet blocks are incursion sectors, shown on the map although the space around them is fog.
Wormholes and Rifts

Doors that hide again, and six maps with a clock of their own.

Storms would be half a feature if the doors between maps stood still. So the wormholes and the Rifts ride the same rotation.

Wormholes

  • About two wormholes are open on each layer, and each lives 30 to 90 minutes.
  • An end is hidden until a ship stands in its sector or a wormhole scanner reaches it.
  • About three in ten new wormholes lead into a Rift, and any of the six can be the one.
  • A storm does not close a wormhole. It hides every end on that layer again, for everyone who had found it.
  • An end that now stands in a wall moves to the nearest open sector that no other end uses, and it keeps off Haven where there is one.

Rifts

  • Six fixed maps, Rift 1 to Rift 6. Rift N uses Layer N's NPC gear, squads, and loot tables.
  • Each is laid out on a hidden 28 by 28 box, up to 450 sectors, twice a layer's size.
  • No Haven, no layer travel, and open fire with full hull loss, Rift 1 included.
  • One Rift storm every 2 hours in rotation, so each Rift reshapes every 12 hours. A Rift storm reshapes only that Rift.
  • Each Rift keeps one static exit wormhole. When it collapses, a new one opens on the next tick and leads to a random sector of the matching layer.
Layer 1207 to 225 sectors on a 20 by 20 box. Rift 1: 414 to 450 on a 28 by 28 box.Rift 1
Layer 2185 to 225 sectors. Rift 2: 370 to 450.Rift 2
Layer 3176 to 225 sectors. Rift 3: 352 to 450.Rift 3
Layer 4167 to 225 sectors. Rift 4: 334 to 450.Rift 4
Layer 5158 to 225 sectors. Rift 5: 316 to 450.Rift 5
Layer 6151 to 225 sectors. Rift 6: 302 to 450.Rift 6

Every Rift floor is twice its layer's floor. A test pins that.

Under the hood

A clock, a seed, a tag, and a settle.

The whole rotation and reshape lives in two files of about four hundred lines each, plus small hooks in the modules around them. Here is the shape of it.

Clockstorms.py is a mixin on the World class. Pure arithmetic on the UTC hour says which slot a map is in. No scheduler and no thread.
Generatorlayout.py turns a seed string into a Layout, a dictionary from sector to kind. It is pure and deterministic.
Settle and viewsstorms.py moves what stands in the wrong place. progress.py keeps each pilot's chart, and the snapshot cuts the fog on the server.

One storm, as a call chain

  1. TickThe server steps the world every 50 milliseconds. Each step calls the storm check once.world.tick, _tick_shared, _tick_storms
  2. DecideFor each of the twelve maps the clock gives a slot. A forced storm that is due wins. A moved slot is a storm. A due time inside five minutes is a warning._slot, next_shift_at, _storm_warn
  3. RebuildThe tag changed, so the layout is built again from its seed, and the anchors are forced open._layout, layout.generate, layout.anchor
  4. SettleShips, NPCs, wrecks, and wormhole ends in lost sectors move. Every wormhole end is hidden again._settle_layer, _wormhole_spot
  5. Refill and tellBelts and clouds are placed full. Global chat and the pilots on that map hear about it. The world is saved._seed_nodes, _announce, save_world
  6. ShowThe next snapshot carries the new tag. The client sees it, flashes, and redraws._layout_view, _storm_view, _sync_storm

The main functions

storm_layersstorms.py

The twelve maps that storm: the six layers, then the six Rifts.

_rotation, _slot, next_shift_atstorms.py

A layer storms at its index hour every 6 hours. A Rift storms at 2 hours times its number minus one, every 12. The slot counts how many storms have passed, so it is a pure function of the clock.

_tick_stormsstorms.py

Runs every step. For each map it runs a due forced storm, or shifts when the slot has moved, or warns when the next storm is inside five minutes. The first tick after a boot settles every map into its shape.

_layout, _layout_tagstorms.py

The live Layout of a map, cached until its tag changes. The tag is the map, the slot, and the forced-storm counter. Every neighbour, path, spawn, node, incursion, and wormhole call reads it.

generate, generate_riftlayout.py

Seed in, Layout out. Draws a budget and a pattern, grows and trims to fit, bridges every piece to the centre, then places debris, narrows, nebula, and ion storms. A Rift is the same generator with a bigger box and a bigger budget.

anchor, _layout_anchorslayout.py, storms.py

Collects the sectors that must stay open, then forces them open and bridges them to the centre. The anchors are saved with the world.

_storm_shiftstorms.py

The reshape itself. New layout, settle, fresh belts and clouds, an announcement, a line for each pilot on that map, and a save.

_settle_layer, _wormhole_spotstorms.py

Moves ships, NPCs, wrecks, and wormhole ends out of sectors that can no longer hold them, and hides every wormhole end again.

_known_cells, _map_recordprogress.py

A pilot's chart, per map and per tag. A new tag clears it. What the pilot knows is the visited sectors plus their neighbours.

_layout_view, _storm_view, _storm_schedulestorms.py

What the snapshot carries: the known cells of the pilot's map, the countdown when a storm is near, and the next layer storm.

_open_wormhole, _roll_destination, _tick_riftswormholes.py, rifts.py

Open a two-ended wormhole, roll where it leads, and keep one static exit open on every Rift.

_dev_stormstorms.py

The slash command and the Dev menu. It fires a storm now or after a warning, and the storm raises the map's counter, which changes the tag and the shape.

Four choices we are happy with

One tag drives everything.The layout cache, each pilot's chart, the snapshot cache, and the client's redraw all key on the same tag. A storm is a tag change, and everything downstream follows.
The seed makes restarts cheap.A layout comes from its map, slot, and counter, so a restart rebuilds the current shape. We save the counters and the anchor sectors, not the maps.
Guarantees live in the generator.Walls are placed only if they keep the map connected. Pieces are bridged to the centre. Trimming never splits the map. Anchors are forced open last. Nothing is checked after the fact.
Rifts reused the machinery.The rotation, the tag, the settle, and the views already took a map name. A Rift needed its own rotation line and its own generator call, and little else.
How the client redraws

The server owns the map. The client only draws it.

  • The snapshot carries a layout: its tag, its pattern, its open count, its box, and a map from sector to kind. A sector missing from the map is fog. A kind of missing is empty space.
  • hex_grid.gd is pure hex math: pointy-top hexes, conversions to cube coordinates, neighbours, and a breadth-first path. It fits the camera to the shape, not the hidden box, and it knows a Rift box reaches 28.
  • When the layout tag changes on the same map, main.gd flashes once, plays an alarm, and writes a note to the log. Fixed maps and Enclaves never flash.
  • The settled map is one cached back layer. It redraws only when its key changes, and the key includes the tag, so a storm is one redraw. Ships and pulses draw on top every frame.
  • In the last minute before a storm the edge sectors flicker. sector_card.gd reads the hazard of the sector you stand in from the same map and prints its rule.
What the code looks like in numbers
50 msThe fixed server step that runs the storm check
8Themed storm names
12Maps in the storm rotation
28Hidden box side of a Rift, against 20 for a layer
What the tests prove

What is pinned, and what is not.

The storm tests live in test_alpha.py, and they run on every commit. Here is what they hold, and the gaps we know about.

Pinned

  • test_layouts_keep_their_guarantees: across forty seeds on each of the six layers, every open sector is reachable from the centre without narrows or debris, the centre ring and anchors stay open, the sector count never drops under its floor, and Layer 1 has no nebula.
  • test_rotation_one_layer_an_hour: the next storm times for Layers 1, 2, and 6 from a fixed instant, and a map's slot flips at its hour.
  • test_dev_storm_reshapes_moves_refills_and_darkens_the_map: the warning shows in the snapshot, the tag changes, an NPC ends on an enterable sector, belts are full, the pilot's map is dark, Global chat says the storm passed, and fewer than thirty sectors are known.
  • test_storm_pushes_ships_out_of_walls: a ship placed on debris lands on an enterable sector within three hexes.
  • test_rifts_storm_on_their_own_rotation: each Rift storms twelve hours after its last, Rift storms land two hours apart, and the layers keep six.
  • test_a_rift_storm_reshapes_only_that_rift_and_keeps_structure_sectors: the other Rift and a layer keep their tags, a structure sector stays enterable, and Global chat names the Rift.
  • test_a_rift_is_twice_a_layers_size: a 28 box, a sector count between its floor and 450, floors twice the layers', and the Rift is connected.
  • Narrows, ion storms, nebula, the fog route, and the per-shift mapping pay each have a test. The live-socket pilots in playtest_check.py also run a storm: warning, new tag, no ship left in a wall, belts refilled, map dark.

Not pinned, or thin

  • No test moves the clock across an hour. Storm tests force a storm with a due time of zero. The slot arithmetic has its own test, but the natural warning and the natural hit are not replayed.
  • Old shapes are not pinned. A test checks that a seed gives the same map twice in one run. Nothing stores a map from a past build, so a change to the generator would change the shape a restart rebuilds.
  • Most test worlds run without storms. Unit-test worlds default to a fixed 15 by 15 layer, and a test turns storms on.
  • A storm runs inside one server step: generate, settle, refill, and save. load_check.py never fires a storm, so the 25 ms target is not checked against one.
  • The client is thin. The headless self-test checks that the hex grid reaches 28 for a Rift, and we did not find a client check that feeds it a reshaped layout.
  • The client keeps its own copy of the narrows rule and of the hazard wording. They match the server today, and nothing makes them stay matched.
What we learned and what is next

Small lessons from a moving map.

Ask the schedule question twice.Our first plan for the Rifts was one shared rotation of twelve maps. We moved them to a slower clock so the layers keep their rhythm. Then a first reading of one Rift storm every 12 hours would have meant each Rift reshaping every 72. We asked again before building.
Count only what a ship can reach.A report taught us that a pocket walled off by debris must not count against a full map. The total now counts the sectors a ship can reach from the centre.
Spread the first arrival.A Rift can hold up to 450 sectors. It seeds four squads a step, so the first pilot to arrive never stalls the 50 ms tick.

Where we would push next

  • Tune the stand-ins. The warning time, the sector budgets, and the hazard shares are starting values that we adjust after testers play them.
  • Drive the real clock across an hour in a test, so the natural warning and the natural hit are covered too.
  • Bring the player text up to date. The storm help topic and the Journal Mapping line still describe one storm an hour on layers. The Next storm line and the Journal schedule list layers only, so a Rift storm has a banner and a chat line but no countdown in the schedule.
  • Measure a storm step against the 25 ms target in the load check.
Join the open alpha

Come and chart it with us.

Keel is free to play on PC, and the open alpha is where the storms live. Find us in the Keel Discord, tell us what a storm did to your evening, and file a report from inside the game when something looks wrong.

Players whose reports become fixes are credited in game. Bring a fleet, watch the banner, and be among the first to chart the new shape.