What's New in v1.4.0 β¨
A whirlwind tour of everything that landed in v1.4.0 β across the LegendaryMissions content and the sbs_utils library that powers it. Links go to the relevant docs.
πΎ Your setup survives a restart
Restarting the mission put every option back to settings.yaml β player ships,
difficulty, terrain, the lot. After an early death, or with one setting to change,
that meant retyping the whole screen. Two ways not to, for two different needs.
Save a preset. Build the setup you want on the server console, type a name beside the Presets dropdown, press the save icon. It is in the dropdown afterwards, restart or no restart β and it brings back the crew's ship names and hulls as well as the options. Presets are per map.
Or let it remember. RESTORE_LAST_SETUP: true brings back whatever the last
game started with, per mission and per map, with nothing to press. It saves when
a game starts, so a crash or a quit still leaves it recorded.
It is off by default, and that is deliberate: a convention or venue machine wants every group to start from the same known state, which is exactly what it already does. Turn it on for a crew replaying the same match.
Loading a saved setup puts those ships straight on helm's picker, so helm can still change one and the change sticks.
Where it all lives now
Saved setups are written to data/missions/common_data/, beside the
missions rather than inside one β so updating or re-extracting a mission
keeps them.
ποΈ One profile for every mission
A profile β one named file that decides how a mission runs β had to live inside the mission it configured. Two missions wanting the same setup meant two copies, and updating the mission could take yours with it.
Now there is a second place to put one: data/missions/common_data/profiles/.
Same file, same profile= argument, but it applies to whatever you launch:
sbs run server,science,comms profile=a28_skies -m WalkTheLine
That is a full profile, add-ons and art packs included β so the Artemis 2.8 skies
follow you into any mission, from one file. A mission's own profiles/ folder
still wins if both have the same name, so a mission can ship a definitive one.
Launch arguments now know which mission they were for. profile=, map=,
console= and var.NAME= used to follow you when you restarted into a
different mission β quietly, and a same-named profile over there meant
something else entirely. They stop applying on a switch, and say so.
seed=, run=, record= and test= describe the launch and still apply. See
Switching missions.
Sharing a game code does not share your ship names
A game code is for replaying an identical match, so it carries the map, size, seed and options β and not your crew's ship names, which are not part of the match and made the code several times longer. A setup you save does carry them.
π§΅ A handler no longer depends on which task built the widget
on gui_message, on change and on_press= all belonged to the task that
built the widget. That is invisible while the builder is the console's own
GUI task, and confusing the moment it is not β which is exactly what
gui_sub_task_schedule is for. Three changes, all on by default:
await gui()reached off the GUI task used to hang the calling task forever on a promise the page never adopted, print a bareprint()nothing reads, and strand the task ingui_task.sub_tasks. The screen still drew, so it looked like the flow died rather than like a hang. It now sends the GUI task to the screen the handler just built, superseding β never resolving β the promise the GUI task was parked on.on_press=<label>can now be a sub-task by default, which is whatis_sub_task=Truealways meant.is_sub_taskis deprecated; an explicitFalsestill works. The knock-on is the answer to the question everyone asks: a handler label now just ends with->END. Under the old jump form->ENDended the console.on changeinside a scheduled label now fires. It belongs to the GUI build, like every other handler, instead of dying with the task that registered it.
A fourth fix came out of the same work: on_new_gui ended every hosted handler
hanging off the GUI task β including the one that was currently painting. A
handler that repainted killed itself on its own first tagged widget and never
reached its await gui(). It could not tell "the GUI that owned me is being
replaced" from "I am the one replacing it".
New reference page: Handler lifetime β which task
runs each form, how long it lives, how it should end, and when you still need
gui_task_jump.
β οΈ The races add-on β add it to your story.json
This one needs action. Ship interiors and fleet compositions used to be built into the game; in v1.4.0 they ship as per-race add-ons, and a mission has to ask for them.
If your mission loads LegendaryMissions' ai or fleets add-on, add one line:
"artemis-sbs.LegendaryMissions.races.v1.4.0.mastlib"
Leave them out and two things break quietly. Your player ship gets a dead
Engineering console β no system nodes, no damcons, no internal damage β and
fleet_create finds no ladder for any race, so nothing raids you. Neither failure
prints an error, which is exactly why it is worth checking now.
New missions from sbs create already include it.
What you get in return is content that was previously unreachable. Interiors lived in
the engine's own data/grid_data.json; fleet ladders were Python literals behind an
if race == "..." chain. Now a race owns both, "random" picks from the races that
actually registered, and a new race joins the rotation by existing. Two settings β
PLAYABLE_RACES and NPC_RACES β turn races off per mission.
Every hull that declares an interior now has one, so ships that were never really flyable β Kralien, Torgoth, Skaraan, Biomech and the pirates β have a working Engineering console for the first time.
See The races add-on.
What you already have, working differently
Everything in this part is something a v1.3.0 crew or mission already has, met again the moment you launch β and a few of them change what your mission looks like whether you ask for them or not. After these come the things that did not exist before.
π» The text waterfall is gone β say hello to the ship's log
This one changes what your mission looks like whether you opt in or not. The engine never wrote to the waterfall, script could not control its background, and no mission could style it. It has been removed from every console and replaced by a log with two halves, fed by one record:
- The strip β one line, the newest message, where the waterfall used to sit. Always visible, no interaction, read at a glance.
- The tab β the history, in the info panel: scrollable and filtered into Log, Ship and Mission.
Newest is first in both, so the latest line never moves.
You write to it exactly as before β comms_broadcast(...) β with two new optional
arguments: category picks the tab (everything still shows in Log, so a filter can
never hide a message), and severity (tip / warning / danger) renders the line as a
coloured callout.
The corner toast retired into the same log. overlay_toast() and the toast <text>
quest directive still work and still compile β they write a log line instead of drawing a
card. announce(level="status") and level="minor" draw no overlay at all now. They were
the one pair of levels carrying information on a surface that kept no record: a console
that connected a second later never saw it.
Nothing seizes the console. An urgent line does not switch the info panel to the log
tab β the strip already shows it, in its severity colour, everywhere. A mission that wants
the interrupt sets RAISE_ON = ("danger",).
Docs: Messages & the ship's log, Overlays.
ποΈ Console tabs stop drawing over each other
The tab strip divided a fixed width evenly, so more tabs only ever meant narrower tabs β and the engine does not clip text, it draws it anyway, over the neighbour. Every tab was present, clickable and illegible, which is why adding a tab broke ones that already worked.
Eight tabs are shown; the rest go behind a More (N) menu that behaves exactly like
clicking a tab. BACK is never overflowed. Nothing to configure.
π Richer GUI β tables, and text that does more
-
gui_table. Describe a table as rows + column specs and get back a real, selectable, scrollable list box with the columns auto-sized to their content. Cells aren't just text β a columntypecan be a checkbox, dropdown, input, or button, and interactive cells write straight back to the row and fire anon_cell_changecallback. An editable data grid in one call. -
gui_listβ design your own rows, in pure MAST. When a row needs more than columns (a picture, a button, two lines), write the row once as awithblock and it repeats for every item β scrolling and selectable, no Python helper required:Keep the handle to read what they picked:with gui_list(ships, select=True) as ship: gui_text("{ship.name}") gui_button("Hail"): jump hailship_list.get_selected(). -
gui_grid.with gui_grid(3):arranges items in even rows of N β a palette of buttons or a board of tiles β breaking to the next row for you. Great with aforloop. -
Pipe tables, links & rules in
gui_text_area. The rich-text area learned a few new tricks in its mini-markdown:- GFM pipe tables β
| Ship | Hull |with a|:--|--:|alignment row β render as a real grid, columns sized to fit. - Hyperlinks β a
[Torgoth](ref://torgoth)line becomes a clickable link that navigates within the same document (give the area alink_resolver), so a Kralien entry can link straight to the Torgoth one. Perfect for a codex. <hr>draws a horizontal rule.
- GFM pipe tables β
-
Text areas use their full width. A long-standing measuring bug made
gui_text_areawrap text at roughly 60% of the available width; it now measures properly and fills the space. -
Rows and columns can size themselves. Add
row-height: contentorcol-width: contentand a row is as tall as its text, a label as wide as its word β at every window size, with no percentages to maintain.min-contentandmax-contentgive finer control, and1fris now the default: a column still shares the leftover space but is never squeezed below the widest word it has to show. (1fris what CSS calls an equal share with a minimum; the older spellingautostill works.) -
col-width: square. A column as wide as the row is tall. The other keywords derive a width from the column's own content; this one derives it from the other axis, which is what a portrait, an icon, a ship render or a badge nearly always wants.gui_faceandgui_iconwere already square;gui_shipand the image widgets were not β so a ship placed beside text used to flex and take half the row. Now they all say the same thing.Behavior change:
squareand an explicit width are mutually exclusive, and setting either clears the other. A square column that also carried a width used to be counted twice when the row was divided up, so the row reserved its space twice over and β since the engine never clips β drew the surplus over and outside its neighbours. If a screen of yours puts acol-widthon a face or an icon, it now gets the width it asked for instead of that double-count. -
overflow:for text that cannot fit. Because the engine never clips, text too big for its box is drawn over its neighbours. When there is genuinely no space to give β a user-entered name, a fixed strip β a widget can now sayshrink(step the font down),ellipsis(truncate with...) orhide. The default is unchanged, so nothing moves unless you ask. -
layer:β paint order you can set. The other half of the same problem.overflow:changes the text;layer:changes what is drawn on top. Raise a row or section's background above its neighbour's content and an overflowing string is simply covered β not clipped (the engine cannot clip), but invisible, which is what a player cares about. It cascades likecolorandfont, so one declaration can raise a whole panel. Two fixes came with it: a background could not be lifted above content at all before, andgui_imagewas silently discarding anydraw_layeryou gave it. Opt-in β a layout that never sayslayer:is byte-for-byte unchanged. -
Text no longer overlaps itself in a scrolling text area. A
gui_text_areameasured its wrapping against the full width but drew 20px narrower to leave room for its scrollbar, so roughly one paragraph in eight gained a line the layout had not counted β and drew it on top of the next one. It also broke lines by estimating characters from an average glyph width, which ended lines short of the edge for no visible reason; it now wraps on measured words. -
Sizes add up.
row-heightandcol-widthaccept full arithmetic β1em+10px,62-25px. A+or-term used to be silently dropped, so a layout could have been running with a size it never asked for. -
Text no longer draws on top of the row below it. Three separate causes, all now fixed: a content-sized column got the exact width its text needed and no more, so rounding tipped it into an extra line; raising one row to fit its text could starve its neighbours to zero height, and a zero-height row still draws; and a nested panel measured its own rows unwrapped, so it asked for less room than its content needed.
-
A list box no longer shrinks around its tallest row. Slot budgeting divided the available space by the largest item, so a single tall row could halve how many rows a list showed and leave half the box empty. Rows are now packed by their real heights; a list with equal rows is unchanged.
π The Quest Log says something new
Every row used to be the same square, over a caption repeating the state the square's color already showed. Now the shape is what the thing is β job, objective, beat, arc β and the color is what state it's in: two facts in one glyph.
The second line has to earn its place. It says how far along you are (2 of 6), what a
job pays while it's still a choice (Reward: 120 credits), or how long is left
(1:30 left) β and falls back to the state only for Done and Failed, where the
state is the news.
Quests written before kinds existed still render; they just say less. And because every glyph is asked for by name, a mission that ships its own icon sheet re-skins the whole log without touching a line of it.
π§ Quests & Stories
- A signal-driven quest engine with kill / collect / scan / dock / reach / arrive triggers and real rewards β all authored in simple AMD files.
- A proper Quest Log you can accept and abandon quests from.
- Multi-step bridge stories that unfold a step at a time.
- Quest-driven end-game. A mission is now a tree of quests β a parent mission
with children marked required (must finish to win) or fatal (fail one and the
game is lost). Quests carry a Win: / Lose: outcome and a
Fails when:trigger (a signal fires, a target dies, a timer expires), so the whole win/lose condition is authored in AMD, not hand-wired in script. The Siege bosses hang their objectives on the siege's mission tree withPart of:. - A quest log that isn't a spoiler.
Show:says when a quest is listed, apart from when it runs β so a story beat can drive its event unseen and appear once it has happened. A converted 2.8 mission went from 48 rows of story to 9: the six things the crew can act on, and history accruing underneath.
Docs: Quests.
π± Items & Upgrades
One pickup can be worth several units. item_spawn(key, x, y, z, qty=24) stamps a
quantity on the pickup and collecting it credits the lot. A job wanting 24 salvage used
to mean 24 collectibles scattered across the map β object churn, and a tedious flight
rather than a pickup. Pickups that don't ask for a quantity are unchanged.
- Discoverable items and upgrades driven by a data registry β collect them in space and activate them through a generic Upgrades GUI.
Docs: Items & Upgrades.
π©οΈ Hangar
- Sortie board. Fighter and shuttle pilots pick their own missions from a board.
- Loadouts as upgrades. Craft loadouts are now deltas over the ship hull β shields and ammo applied through the item/upgrade system β with an image-based cockpit overlay.
- Rearming takes time. A craft's refit now runs longer the more torpedoes it has to reload β every empty tube adds time (tunable per dock), so a bomber that spent its payload turns around slower than one that never fired. Fit the new Torpedo Autoloader upgrade to cut that per-torpedo cost.
See the LegendaryMissions addon reference.
π‘οΈ The Siege Map, Refined
- Pick your battlefield size with a new Map Size option.
- Consistent, repeatable maps from phased, keyed seeding (no more surprise spawns inside an asteroid).
- Share the exact setup with per-map seed options and a shareable game code.
- Optional bonus objectives for skilled crews.
- Survive Clock option. Choose what the time limit means: Win = outlast the clock to hold the line, or Loss = break the siege before time runs out or you lose. One dropdown flips a defensive hold into a race against the clock.
π Bosses
The Siege can now escalate into a boss that warps in when the raiders thin out β picked from a new Boss dropdown:
- Warlord β a named enemy flagship and honor-guard reinforcements.
- Continuous β endless waves until the clock nears its end, then the attackers break off in retreat for a hard-won defender victory.
- Ragnarok β the renegade "42 Fleet" led by a Terran juggernaut. Beat it outright, or have your comms officer hail XORN and turn one of its captains to your side.
- Infestation β a BioMech swarm that drifts neutral and feeds on asteroids until you provoke it, then wakes as a collective, evolves through four stages, and breeds β with the sentient Stage 4 hailable to calm or enrage. BioMechs are a reusable addon you can drop into any mission.
Bosses are data-driven and folder-scanned β each is a small file in maps/bosses/,
so authoring a new one is just dropping in a file.
Playing & hosting: LegendaryMissions Β· Authors: Writing a Siege boss.
π A Living Bestiary
Space monsters are no longer just one hostile Typhon. The Monsters map option now seeds a weighted mix of species β some deadly, some harmless, some that actually help you β and a Game Master can drop any of them from the spawn menu. Scan an unknown creature and its science readout tells you what it is before you decide to shoot.
- Seven new species over one behavior: Reaver (fast hunter that enrages β faster and redder with every wound), Ravener (an apex predator that feeds on weapon fire β beams and torpedoes only heal it; the one thing it can't eat is a black hole), Grazer (a placid drifter that's tame unless provoked, then turns on you), Bulwark (colossal, inert living reef β harmless unless you ram it), Sparkfeeder (a docile creature that recharges your ship's energy), Siphon Leech (its parasitic twin β drains energy, non-lethal), and the Warden (a friendly guardian that hunts raiders, never you).
- Every monster has an age. Individuals spawn Young, Mature or Ancient β older ones are tougher and larger (age never changes the damage they deal), and an aged Ancient eventually seeks out a black hole to die.
- Black holes bite again. Player ships and fighters that drift into a black hole's pull are now reliably destroyed β no more bobbing at the edge or warping free (fixes the long-standing "black holes don't kill" bug).
Authors add a species by dropping in a prefab file over behav_typhon; the roster
and mix live in one weight table.
π€ Peacetime, with more than one ship
Bring a second ship to a peacetime patrol and a single Quest Mode (set on the map panel) decides whether you cooperate or compete for the same board of jobs:
- Co-op β nothing is claimed. Deliver a barge and every ship holding that job is paid, and the multi-step arcs run for the whole crew together.
- Protected (default) β the moment you work a target it's locked to you. A rival's grav-tether is refused ("claimed by another ship") and only you are paid. Friendly by default, nobody can spoil your job.
- Claim-jump β claims are stealable: the grav-tether becomes the competitive tool, letting you tow a rival's salvage out from under them. Payment follows whoever delivers, and each ship banks its own earnings for a top-earner readout.
Who owns a job, who gets paid, and who gets credit for a kill are firm rules rather than best effort β they hold however many ships are flying.
Player guide: Multiplayer jobs.
π Game Results & Scorekeeping
The end-of-game screen is now a tabbed results board with real scorekeeping β built for bragging rights, and for running a scored event.
- Five tabs: Summary, Fleet, Air Wing, Quests, and Enemies.
- Objective scoring. Every kill is credited to the ship (or pilot) that landed
the final blow, and tallied three ways:
- Kills β enemies destroyed.
- Tonnage β naval-style "tonnage sunk," scaled by each hull's size, so bigger kills are worth more than swatting fighters.
- Damage dealt β raw impact, so crews who soften targets without stealing the kill still show up.
- Fleet ranks each bridge ship (kills / tonnage / damage / hull remaining); Air Wing ranks each fighter & shuttle pilot by call sign (sorties / kills / tonnage / objectives). Bridge and cockpit credit never double-count.
- Every game is saved to a rolling history β with an even denser per-ship / per-pilot / per-quest breakdown than the screen shows.
Running a tournament? β for Convention Operators
Pair the scoreboard with repeatable seeds and a shareable game code: hand every crew the same code and they all play the identical map and enemy layout, then rank them by their Fleet / Air Wing boards (kills, tonnage, damage) for an objective result. The Director console gives you a spectator / big-screen view for the venue floor.
Details: LegendaryMissions › Game features.
π€ Friend or Foe β decided by Diplomacy, not by labels
A deep pass reworked how the game answers one deceptively simple question: "is this
ship a friend or an enemy?" It used to be answered by hardcoded role labels β
raider meant "the bad guys" and tsn meant "the good guys" β an assumption quietly
baked into targeting, victory checks, docking, comms, loot, and quests. Now those
questions are answered by diplomacy: the actual side relationships. A side's
name no longer decides its allegiance.
What that unlocks:
- Runtime ceasefires & alliances that actually stick. Negotiate peace with a hostile faction and it really stops being a target β your fleets break off, its ships stop counting as "enemies remaining," you can claim its systems, and killing it no longer pays a bounty. Ally with a faction and its bases count as your friendly bases.
- Many enemy factions, not one lump. Enemies can field their own faction sides, each independently at war or at peace β instead of everyone sharing a single "raider" side.
- More than one player side. Co-op or rival player factions work without
assuming everyone is
tsn: friendly stations, escorts, and win/lose conditions all derive from who the players actually are. - Quests that mean "enemies," not "raiders." A kill objective can be authored as
destroy N enemiesβ faction-agnostic and ceasefire-safe β right alongside the familiardestroy N raiders.
For authors it arrives as a symmetric sides & diplomacy vocabulary β ask
side_are_enemies / side_are_friendly, or grab the whole set of what's hostile (or
allied) to a ship or to the players β so you never hardcode role("raider") or
role("tsn") again. Every stock single-side mission plays exactly as before; the
new reach is there when you want it.
Docs: Sides & Diplomacy.
π’ Fleets, written down at last
Fleet ladders became per-race data in v1.4.0, but how you actually spawn one was folklore.
Now documented: Fleets & raiding β prefab_fleet_raider, what a
variant is, per-faction sides via faction_side, and the difficulty encoding, which has two
forms nobody guessed:
| You pass | You get |
|---|---|
0 |
the mission's DIFFICULTY setting |
200 |
DIFFICULTY + 2 β a relative offset |
π€ Attract Mode β the ship flies itself
Flip on Auto Play (AUTO_PLAY: enable: true) and every bridge runs itself β a
great lobby / attract screen or hands-free demo. The autoplayer doesn't just
wander and shoot; it plays like a coordinated crew:
- Stand-off alpha strike. Against an enemy at range it holds at 5000u, opens with an EMP to strip shields, then a single Nuke, waits for the blast to clear, and only then closes to mop up with beams and homing torps β so it never catches itself in its own explosion.
- Fires only what it has. It reads real magazine counts and launches only loaded types β no more phantom Homing torpedoes once the tubes run dry β and it won't friendly-fire: area torps are held whenever an ally sits in the blast, and wrecks get beams only, never torpedoes. A PShock finishes a target whose shields are down.
- Talks like a crew. It hails enemies to taunt them with the right line β the one an intel scan reveals β demands their surrender once shields drop, and at the start of a match asks friendly stations to build Nukes to keep itself armed.
- Plays engineer. It overpowers drive and weapons for a faster, harder-hitting ship and balances the heat with coolant, easing off when energy runs low.
- Survives sensibly. It flees lethal terrain β steering clear of a black hole's pull well before it's caught in the well β docks to repair when hurt, and if its maneuvering is shot out it holds station to keep fighting instead of burning off into deep space.
Turn it on β and tune its stand-off range and engineering overpower β in LegendaryMissions settings.
π·οΈ Icons have names now
gui_icon("icon_index:111;") asked you to remember that 111 is a wanted poster. Every
one of the built-in sheet's 176 glyphs now has a name, and there is one call that
takes one:
gui_icon_name("quest.job", "#cc0")
Two kinds of name, and the difference is the point. A look β square, bell,
wanted β is what a glyph is. A meaning β quest.job, quest.state,
list.expand β is what it's for, and points at a look. Ask for the meaning, and
re-pointing it changes every screen at once.
The same names reach your own art. Register a cell of your sheet under a look and it wins over the built-in one:
gui_icon_add_atlas("wanted", media_shared("icons/quest-sheet"), 0, 0, 64, 64)
Every gui_icon_name("quest.job") in the game now draws your art β with no edit to the
code that draws it. Screens can be written before their art exists, and an add-on can
re-skin screens it doesn't own. An unknown name draws nothing rather than a plausible
wrong glyph, because a wrong icon looks deliberate.
Claiming a look is deliberate: only the icon domain re-skins, so an ordinary image
that happens to be called square or flag can't silently change every state pip in the
game.
Or write the sheet as a fact sheet. An ## [Icons] section registers the same keys
with no Python β the sheet, the cell size and the domain are written once on the section,
so an entry is a single line:
## [Icons](icons)
---
icons
Sheet: icons/quest-sheet
Cell: 64
---
### [Job](wanted)
---
At: 0, 0
---
The same section type registers any atlas, not just icons β a card deck or a set of
console backdrops is the same format with a different word (images, art) and its own
Domain:. sbs lint checks them: a sheet that isn't on disk, an At: with no Cell:
to measure against, a cell off the edge of the sheet. All three used to draw a blank
widget with no error anywhere.
The whole named set, with pictures: Icons by name.
β‘ Smoother with big fleets
Large battles used to stutter on a beat. Every few seconds the game processed every NPC brain in a single frame β then, on another beat, every mission objective β so the frame time spiked periodically. The bigger the fleet, the bigger the hitch.
That work is now spread evenly across frames. Each brain and each objective still updates on the same schedule as before β the AI is exactly as sharp and reacts just as fast β but instead of one big burst, the load is smoothed out tick by tick. On a busy Siege those periodic frame spikes are largely gone, and it scales far better as fleets grow. Nothing to configure; every mission gets it automatically.
For tinkerers β how it works
Per-tick work that iterated a whole set at once (all brains, all objectives)
now runs a rolling slice each tick, sized by a shared RollingSlicer so a
full pass still completes in the same period regardless of set size or tick
rate β same cadence and total cost, no batch spike. If you're chasing a
mission_tick that overruns its budget, the engine's Elapsed time log
line now prints a per-phase breakdown (dispatch_tick / gui_present /
gc / β¦) so you can see exactly which subsystem caused a given spike.
πΈ The old missions fly again
Every mission your crew ever flew in Artemis 2.8 can come back into service. Hand one over and it returns as a Cosmos mission you can host tonight: the same fleets waiting in the same corners of the map, the same enemies with the same tempers, the same voice cutting in over comms.
All 27 missions in our archive made the crossing β and not just far enough to load. They play: hostiles pick fights, stations answer hails, the timers still run out on you, and each mission still ends the way it always did.
- The briefing is no longer a memory test. The mission's goals arrive as real Cosmos quests, ticking over in the crew's quest log as you go β or, if you'd rather have the original exactly as it was written, you can have that instead.
- Enemies remember they're enemies. Old missions never spelled out who hated whom; that gets worked out and declared, so hostiles open fire, Science sorts friend from foe, and contacts show in the right colors.
- The Game Master keeps the con. Menus stay nested the way they were laid out, shortcut actions survive, and a spawn lands where the GM is pointing rather than in the corner of the map.
- The details survive. Elite Skaraan tricks, captains with a temper, fighters and shuttles in the hangar, science scans, hail text β the flavour comes with the fleet, not just the ships.
Anything the old script left genuinely ambiguous is written down for whoever finishes the port, rather than quietly guessed at. β Porting from Artemis 2.x
π¬ Quality-of-Life & Presentation
- The Director console (formerly Console View) β pair and rotate multiple views, with a cinematic mode for the big screen.
- Shareable game codes and per-map seed options so crews can replay the exact same setup.
Details: LegendaryMissions › Game features.
Now the new things
None of this existed in v1.3.0, and none of it is required β a mission that adopts nothing here still runs exactly as it did.
βοΈ AMD β write the mission, don't program it
AMD is the new way to author content: a mission's jobs, characters, places and story beats are written as plain fact sheets, in the words you would use describing them to someone. No script, no wiring β you say what a thing is, and it behaves that way.
A record says what it is, and that decides how it behaves. A Beat is a moment the
crew lives through β it runs unseen and appears in the log once it has happened, as
history. A Cue is a stage direction: it fires and is never listed. An Arc is the
heading over a run of beats. A Job is taken by a ship; an Objective is the crew's.
The word you choose carries the rest:
# [The Coils Overheat](ramscoop/coils)
---
Beat
Done when: signal ramscoop_online
---
Engineering reports the coils running hot.
One grammar answers three questions. Starts when: / Done when: / Fails when:
all take the same thing β signal X, destroy 6 raiders, reach 6, 4, 5 minutes,
all dead convoy, accepted, revealed. Learn it once and you can say when anything
opens, finishes or fails.
Reward: and Penalty: say what a job pays and what failing it costs β so a timed
job with something at stake is two lines.
Traits β what a record ALSO does. A worldlet is a Landmark that happens to yield ore; that second half is a trait, not a new kind of thing:
Landmark
Also: economy
Yields: ore 8
Reserve: 4000
Two traits ship: economy (Yields: Reserve: Price: Costs: Time:) and
reputation (Values: Standing: Reliability: Rival when:). A Side and a
Character are always regarded some way, so they carry reputation without asking;
Also: is for the optional half.
See The AMD file format.
ποΈ AMD prose reads like a screenplay
The fact sheets were only half the file. The other half is the prose, and it learned six marks β borrowed from Fountain, the screenplay format, and Obsidian, where linked notes come from. A line the format does not recognize is still prose, always, so nothing you have already written changes.
A scene can hold a conversation. The speaker's cue goes in the body, the way a script writes it, so one scene carries as many voices as it needs:
@Ashfang
% You're a long way from friends, captain.
@Vell (comms)
(shaken)
He means it, captain.
(shaken) is how the line is delivered β write anything you like. (comms) is where
it is delivered, so who-says-what-and-where is decided in the script instead of in code.
Prose can point at things. Talk to [[cmdr_vell]] before you reach
[[ds1|the station]]. These are real references, so Find All References, Rename and the
Story Graph all see them β and a link to something you have not written is not an
error:
$ sbs lint <mission> --missing
3 thing(s) referenced but not written yet:
cmdr_vell
linked from `brief` story.amd:6
Draft the whole mission as prose, link freely to scenes that do not exist yet, and let the linter hand you the list of what to write next. In the editor each one gets a Create this record quick fix.
And three smaller ones. = a note to yourself records what a beat is for β it
shows in hover and the outline and never reaches a player. /* ... */ cuts a scene
without deleting it. > [!WARNING] gives an in-fiction document in-fiction formatting.
In a cutscene, FADE IN: and > CUT TO: say how a shot arrives without appearing on
screen β so a cutscene file reads as a screenplay and is still the shot list the engine
plays.
See Writing the body.
π Drops: β what a kill leaves behind
Loot is authored now, keyed by role, because what a ship drops follows from what it is:
### [Raider](raider)
---
Drops: salvage x2-4, contraband 20%
---
The defaults stay the defaults β a mission that authors nothing behaves exactly as
before. What changes is that an author can see the answer and change it, instead of a
condemned hulk on a live-fire range leaving contraband because it happens to be spawned
hostile. Drops: none means this one drops nothing, which is not the same as having
no table at all, and sbs lint flags a drop key that names no item.
See What a kill leaves behind.
π Markers β naming a place
"Clear the asteroids in the shipping lane" needed the crew to know which asteroids, and
prose written at authoring time could only name coordinates that go stale. So put the
place in the world instead: marker_area for a region, marker_point for a spot,
and marker_object for a spot the crew has to select.
See Naming a place.
β³ A deadline you can hear coming
A job with a Fails when: clock calls in as it runs down, on comms, rather than
counting itself out silently on a tab nobody is looking at. Marks are absolute β 5:00, 2:00, 1:00,
0:30 β filtered to the ones that fit under the deadline, so a six-minute job gets four
reminders and a forty-five-second one gets two.
Who speaks is whoever the job asked for: Speaker: outright, else Held by: when it can
talk (a station's job speaks with the station's face for free), else the mission's
registered dispatch voice β and silence if none of those resolve, because a reminder
from nobody is worse than no reminder. Signal says: gives it words, with {time}
interpolated, so an automated beacon sounds like one:
Speaker: shuttle_pilot
Fails when: 6 minutes
Signal says: LIFE SUPPORT CRITICAL. {time} TO FAILURE.
See Deadline reminders.
π£οΈ Characters who ask, and leave if nobody comes
A quest could always count down. It just did it in silence β you learned the ambassador had given up when the fare quietly vanished from the board.
An urge is what an actor keeps asking for: a condition, a cadence, and a pool of authored lines. Anyone can hold one β a passenger, a station, a whole side.
- The stakes stay in the quest. An urge declares no consequence of its own; it is the voice of a quest that is already counting down. One clock, one place to tune, and deleting the urge costs the drama but not the mechanics.
- The countdown IS the drama curve. Write
%while there is time,%%as it runs short,%%%at the end, andEscalates: with deadlinereads the quest's own clock. The number of markers is the curve;Fails when:is the tempo. Nothing has to agree with anything else. - They know when to shut up. A per-actor floor stops one character monologuing, and a
global floor stops five of them piping up the moment a jump makes them all eligible β
shared with mission dispatch, so nobody talks over the Admiral. Only something urgent
(
Weight: 90) jumps that queue. - A station can hold a quest now, which is what lets a resupply run have a deadline and a cost that lands on the world instead of on whoever happened to fly past.
- Standing is a consequence.
Reward:andPenalty:takeearns <faction> <pole> <n>, so finishing a job β or abandoning one β can move how a faction feels about you, not just what it charges.
In Open Universe, Doctor Voss now waits on the docking ring at her pickup, asks more insistently as her window closes, and takes a berth on someone else's freighter if nobody comes. In Legendary Missions, Ambassador Florbin's famous passenger requests are the same character, rewritten as five lines of data instead of a hand-written loop.
API: urge.
π΅οΈ Peacetime Remastered β a mission written in AMD
Peacetime Remastered is a border-patrol shift that doubles as a worked example of
authoring a whole mission as data. Almost everything the crew sees β the story, the
job board, the cast, the clues, even the chatter β is declared in one .amd fact-sheet;
MAST holds only the logic that reacts to it.
| What the crew sees | Authored as |
|---|---|
| The Ambassador Florbin kidnapping | a quest tree β take the case, identify the kidnapper, subdue, recover |
| The cast (a deck chief, the Admiral, the Ambassadorβ¦) | lifeforms β hosted contacts appear as comms badges you hail |
| The 40 allergy clues | generic AMD records β each heading is a container the ambassador could hide in, its body the clue |
| The Ambassador's passenger complaints | a chatter line-pool β one picked at random |
| Briefings, cargo manifests, interview reports | prose templates filled in at send time |
| Object scans (cargo ships, anomalies) | dialogue-native scans (Scan of: / Tab: / % variants) |
| The job board β gunnery, rocks, poacher, mercy, customs, survey | quests with goals, rewards, and fail triggers |
The job board is a pick-up-work board. Every job starts idle β shown as Available β and the crew Accepts the ones they want from the Quest Log. Accepting is when a job's clock starts and when its targets spawn, so a timed rescue gives you the full window (the Mayday arrives, then the shuttle) and nothing clutters space for work nobody took. Weapons stays busy in peacetime: qualify on drones, break hazard rocks, and disable β don't destroy a poacher.
Under the hood the whole kidnap mystery is built by a pure, seeded Python core β one kidnapper, the Ambassador hidden in exactly one cargo hold, a decoy-laced clue trail β that is unit-tested over many seeds, so the puzzle is always solvable however the dice fall.
Authoring reference: Quests Β· Sides, lifeforms & faces.
π₯οΈ "On screen" β science can finally answer the captain
The captain says "on screen" and, until now, nobody could make it happen. A drop-down beside the science console's Follow checkbox hands the ship's main screen a shot of whatever science has selected.
[x] Follow [ On Screen - Orbit v ]
Off
On Screen - Dolly
On Screen - Orbit
Tactical 2D
Dolly pushes slowly in and out, Orbit turns around the contact, and Tactical 2D puts the radar on it. Change the selection and the shot follows; destroy the contact and the viewer stands down.
Beside the picture is a data column carrying what science actually knows: vitals
(range, bearing, shields, hull), every scanned tab together β Scan, Status, Intel,
Materials, Bio β recent comms with that contact, and any quest bound to it. It pages
itself when there is more than one screenful, and skips pages that have nothing to say.
A mission adds its own page with viewscreen_page_register.
Nothing has to be co-ordinated with helm: the viewer writes the same main-screen state helm's own control does, so last writer wins β helm reaching for the control simply takes the screen back, and the drop-down falls back to Off. It is scoped per ship, so science on one bridge cannot change what another's crew is looking at.
Writing your own main-screen console?
While a shot runs, the console is assigned to the subject β the engine only
honors a camera change when the console and the lens ride the same object. So
sbs.get_ship_of_client() on a main screen answers with the contact being filmed.
Use viewscreen_home_ship(client_id) for "this console's own ship".
See On screen.
π° The Casino is open
Dock in the hangar bay and step into the Casino β a self-contained hangout that adds a Casino tab to the bay (leave it out and nothing changes). It runs on an Arvonian bit-card deck, dealt in some games by the Understander, their revered master computer.
- Play with chips. Start with a stack; the cage lets you Buy more or Cash Out β buying dips into the crew's shared credits so you can always get back in, and a hot session earns comp chips.
- Eight games, from a ten-second flutter to a full poker duel: Parity (quick XOR bet), Blackjack, Nibble, Gates (bit-card baccarat), Choga, Video Poker, and the new KoraTa β Ghost-Writing, a five-round head-to-head where you play cards as values to build your run or as opcodes to corrupt your rival's (3-bit and 4-bit tables).
- The bar. Toast the room, buy a regular a drink, and ask for a rumor β act on it to see if it pans out. Reliable patrons earn your trust; enough trust opens the grey market.
- Pilot market. Spend your winnings on real ship upgrades and gear β chips first, then side credits.
Play guide: How to play the Casino Β· Authors: The Casino addon.
πͺ Grav-tether
A tractor beam for Weapons and fighters β one system, scaled by hull.
- Weapons hold-click any contact for a context menu: Reel cargo in (it's collected on contact), Tow a ship or derelict at distance, Lock for a rigid grab, or Release. The mode is chosen by what you grabbed.
- Fighters get a cockpit button with nose-aim targeting (it grabs what you're pointing at): reel salvage, or swing around an asteroid on a tether that holds your radius while you orbit. The button glows cyan while tethered.
- Impulse only (the canonical rule): a tether can't hold at warp β it caps you back to impulse, or optionally snaps and drops the load.
Built on the engine's native tractor. API: grav_tether.
ποΈ Fly inside a relic
A structure your ship goes into β a hollow ruin, a canyon, a docking throat.
The obvious way to build one is to make the walls out of solid objects, and it does not work. The engine has exactly one collision primitive: a keep-out sphere. A hollow shell is the opposite of a sphere, so approximating one takes hundreds of them, and the boundary still never lines up with the art β you clip through corners, or stop dead in what looks like open space.
So a relic describes the space instead of the walls. Chambers are spheres, passages are capsules, boxes are rooms with real corners, and solids are subtracted β the pillar in the middle of the hall. A dozen branching chambers is about thirty primitives instead of ten thousand voxels, and no engine collision is involved anywhere.
- The walls are scenery. Every prop is
exclusion_radius: 0β visible, not solid. Delete them all and containment behaves identically; their job is making an invisible boundary legible. - Getting caught is graded. Scrape the wall and it hurts but you fly on; push past it and you are held. The hold is an engine-side tractor, because the obvious version β teleport the ship back each tick β is right on the server and looks wrong from the seat: the client predicts its own position, so you visibly leave before snapping back.
- The atmosphere does the speed limiting. Fill a relic with nebula and the engine caps warp by itself β no per-tick throttle writes, nothing for the helm to fight. One nebula covers a whole relic.
- Author it as data. A
Relicssection in an.amdfile, with coordinates relative to the relic'sLoc:so the same layout can be dropped twice.
Measured on the real engine while building this, and both numbers surprised us: the MAST task cadence is 15 Hz, not the 5 Hz everyone quotes, and a ship at full warp travels 60 units per tick β not the 216 the old assumption predicts. That is the entire tunneling budget, which is why a wall only has to be about 120 units thick to be uncrossable.
Docs: Relic interiors. API: volume.
π°οΈ Sensor Beacons & the Fabricator
Engineering has a new job. A Fabricate tab turns materials into gear over a build timer, and its headline product is Beacons β a fabricate-only ordnance the crew builds, hands to the tube, fires, and later flies over to recover.
- A two-console loop, on purpose. Beacons don't come pre-loaded β a ship spawns with zero rounds. Weapons tells Engineering what to build, Engineering fabricates it (spending inputs over a timer) and delivers it to the tube, and only then can Weapons fire it. The coordination is the gameplay.
- Bio Beacons herd the bestiary. Program one to attract or repel a chosen space monster, fire it, and it broadcasts across the sector β baiting a Reaver into a minefield or shooing a Grazer off your six.
- Sensor Beacons β the old Probe, brought forward. The passive sensor-relay Sensor Beacon folds in Artemis 2.8's Probe concept. Ported 2.8 missions that stocked Probes come across as Sensor Beacons the crew can build and deploy.
- Recover and reprogram. Fly over a deployed beacon to add the round back and keep its program; Science can scan any beacon to read what it's broadcasting.
- Recipes are data. Every beacon (and any other craftable) is an AMD recipe β inputs, build time, and program β so a mission adds its own without touching the addon.
Authors: Fabrication & Beacons addon Β· Porting Probes: Porting from Artemis 2.x.
π Overlays β cards, HUDs & cutscenes on top of the view
A whole new overlay system for drawing on top of a console's page and its live 3D view β chapter cards, notifications, a modal choice, a live HUD β that update without repainting the page underneath.
- One-liners for the whole toolkit.
overlay_hero("CHAPTER TWO", subtitle="β¦")for a scene title (with a face, ship, icon, or image);overlay_lower_third(name, line)for someone speaking over the action;overlay_banner,overlay_toast(toasts stack β several coexist, each clearing on its own),overlay_letterboxandoverlay_flashfor a cutscene,overlay_credits(..., roll=)for rolling credits. - Aim it anywhere. Every overlay takes
toβ the current console, one client, or a role set:overlay_hero("FLEET ALERT", to=role("mainscreen")). Server story logic pushes straight to the players, or fires it with a one-line//shared/signalbridge. - A modal that returns a value.
result = await overlay_choice("Fire?", ["Yes","No"])β a full-screen card you can await, resolving to the button they pressed. - A live HUD over the view.
overlay_hud(rows=β¦, controls=β¦)floats a sticky panel over the 3D view;overlay_hud_updaterefreshes just its values, no repaint. - Author overlays as data. Declare them in an
.amdfile and fire by key (overlay_amd("ch2")), and a quest fires overlays on accept / complete / fail to its participants βComplete Overlay: convoy_savedor an inlineOn complete: hero CONVOY SAVED, no wiring. - Your own cards, in pure MAST. A
//overlay/<kind>route builds a custom card with the usualgui_*verbs and registers itself β no Python://overlay/briefing gui_face(face) gui_text(f"$text:`{name}`;font:gui-5")
ποΈ The Control Gallery β every widget, running, with its source
Stop guessing what a widget looks like. The Control Gallery is a mission you start: 54 entries in six categories, each one live on screen with the code that built it directly underneath.
- The snippet cannot be out of date, because it is not a copy. It is sliced out of the mission's own file at runtime between two comment markers β so what you read is literally what drew the thing above it. Copy puts the real line on your clipboard.
- It opens on the server screen. No console to pick, no ship needed. Start the map and browse.
- A Traps category, which may be the most useful part: mistakes that produce a
plausible screen and therefore survive review β a
1emrow under a bigger font, padding eaten out of the row height, a starvedcontentrow,update()quietly dropping the rest of your style string, a handler built in aforloop that captures the wrong item. Each runs broken and fixed side by side. - A layout playground where dropdowns set
row-height,col-widthand the font and the boxes move under you β sizing is one of those things you have to push around rather than read about. - "Take the tour" walks all 54 entries, narrating each through the overlay system's own lower third β the gallery introducing itself with the feature it was originally built to demo.
One entry is a record in a .amd file plus a marked span in the code, joined by a key,
so adding to it costs nothing.
sbs debug control_gallery --map 0 # then http://localhost:8765/server
Docs: The Control Gallery Β· Repo: artemis-sbs/control_gallery.
ποΈ Art that lives once
Shared art used to be copied into every mission that used it β LegendaryMissions' 27 MB of backdrops and card decks became 314 MB across twelve missions, and a re-release left every copy stale. Now a media pack is unpacked once beside the libraries, and missions read it there.
{ "shared_media": ["artemis-sbs.LegendaryMissions.media.v1.4.0.zip"] }
gui_image_add_atlas("card_back", media_shared("casino/terran_back"))
media_shared() looks in your mission's own media/ first, then in each pack you
declare β so overriding one file is dropping your own copy in, and nothing hardcodes the
unpacked path (which carries the version). Publishing a pack is a zip entry in
__lib__.json; export-ignore then keeps the art out of the source archive consumers
download, so a fetch no longer drags along 27 MB nothing reads.
272 MB reclaimed. Guide: Shared media.
β° Timers can tell you when they are done
A timer was always something you had to ask about. You set one, then wrote a loop that
woke up to check it, or an await delay_sim in a task that existed for no other reason
than to hold the wait. Now a timer can just say so:
== start_repairs ==
set_timer(SHIP_ID, "repair", seconds=30, signal="repair_done")
->END
//shared/signal/repair_done
repair_ship(TIMER_AGENT_ID)
->END
And set_interval is the same idea on a loop β a heartbeat that keeps its period until
you call clear_interval:
set_interval(SHIP_ID, "patrol", "patrol_beat", seconds=30)
Each emit carries TIMER_AGENT_ID, TIMER_NAME and TIMER_COUNT. Handle it with
//shared/signal if it does anything β a plain //signal route runs once per console,
which for an interval means one beat per console per beat.
It is opt-in on purpose, and that is what makes it cheap. Timers stay what they always were β one number in an agent's inventory, costing nothing β and only the ones you ask to speak are watched. Because an armed timer knows its deadline as a number, the library keeps just the earliest one and a tick costs a single comparison, no matter how many are running. That is less work than the watcher task it replaces, which had to be resumed every tick for as long as it waited.
The timer itself is unchanged, so a countdown widget reading format_time_remaining and a
route waiting on the signal can share one timer. Clearing it, re-setting it without a
signal, or deleting the agent all cancel quietly; timer_add_time carries the signal along
with the deadline.
See Timers and counters.
π Web pages, written in MAST
- Author browser pages with
//web/<path>routes using the samegui_*layout you already know, and open them in a browser while a mission runs β live pages that update in place and are parameterized by the URL query (/web/scores?title=Hi). - Living pages (leaderboards, dashboards) update during the game and are kept as a snapshot for after it.
- Bake a read-only page to a standalone HTML file, front many engines from one
address (
/web/<engine>/β¦), or run it from a non-engine MAST tool β all with no engine changes.
Docs: Web pages Β· Serving web pages.
π§ͺ Mods β ships, races and art from an add-on (experimental)
An add-on can now add ships the game does not have β with their stats, interiors, fleet ladder and race β without editing a single file in your Cosmos install.
The idea that makes it work is a split by who opens the file: MAST reads happily out of a
.mastlib, but the engine needs real files on disk, so a mod's ship data and art travel
in a shared media pack and everything else in the mastlib.
'#ship-list':
- key: dw_scrapper # YOUR key - never one the game already owns
side: Driftwake
origin: Driftwake
sbs.add_extra_ship_data("dw_ships", pack_folder) # the engine learns your hulls
grid_merge_ascii(media_read_relative_file("dw_scrapper.grid"), "dw_races")
fleet_table_load_yaml(media_read_relative_file("dw_fleets.yaml"), "dw_races")
Add your race to PLAYABLE_RACES / NPC_RACES rather than replacing them and your ships
fight alongside the shipped ones instead of replacing them. Two mods that pick distinct key
prefixes can be installed together.
What works: ship data, interiors, fleet ladders, races, sides β all verified in the
engine. What does not, yet: hull art. The path mechanism is in place, but generating
derived art from a bare .obj crashes the engine, so for now a mod points artfileroot at
art the game already owns. There is also a trap worth knowing before you start: never
commit a .paxmesh β it bakes its texture paths, so a committed one points at its author's
disk.
Marked experimental because the engine side is still moving. Full walkthrough, including publishing on GitHub or as a plain zip: Making a mod.
And the tools you build it with
New as well β none of these shipped with v1.3.0.
π Launch it without clicking β sbs run and command-line arguments
Cosmos can now be started with arguments, and a mission can read them. A shortcut, a batch file or a CI job can bring up a full bridge on a particular map with particular settings, untouched by human hands.
sbs run server + five consoles, nothing to click
sbs run comms,weapons just those two
sbs run -m LM_TestRange map=sandbox a mission and a map
sbs run --dry-run show the command lines, launch nothing
The mission comes from -m (default LegendaryMissions) and is passed as
defaultmission=, so preferences.json is no longer edited to choose one. Consoles
are selected per process instead of by rewriting a file inside the game install β two runs
at once no longer fight over it, and a crash cannot leave it changed.
Anything you add on the end reaches the mission, because the engine hands unrecognized
key=value arguments straight to script:
map= console= |
start a map, open a console |
profile= var.NAME= |
settings, in bulk or one at a time |
seed= run= |
reproducible runs, labelled |
record= |
transcribe what you click |
test= |
a pass/fail verdict from the real engine |
Settings merge defaults < settings.yaml < profile= < COSMOS_SETTINGS < var.NAME=, so a
profile file carries the bulk and the command line names it:
sbs run -m MyMission profile=soak var.DIFFICULTY=3 var.AUTO_PLAY.enable=true
test=30 is the one worth knowing about if you automate anything: the mission plays for
that long and writes records/verdict.json, which means the real engine can be checked
by a script rather than by someone watching it. It counts runtime errors, not MAST
coverage β sbs debug . --test remains the stronger check for whether your mission
actually did anything.
Anything that matches nothing says so, rather than quietly doing nothing.
π Start a mission in one command
Writing your first mission used to begin with "download this repository, rename the folder, edit these four files, then work out which libraries you need." Now:
sbs templates # see what you can start from
sbs create MyMission # make one β libraries and all
There are five boilerplates, and they are whole missions rather than empty shells:
| Template | What you get |
|---|---|
minimal |
One @map and a line of narration β the smallest thing that runs. |
sandbox |
Two sides, a station in an asteroid field, player ships, and raider waves that ramp with the difficulty setting. |
addon |
A shareable add-on β provides / requires, packaged as a .mastlib β plus a harness map to run it. |
amd |
Quests and science scans authored as data in a .amd fact sheet, with MAST holding only the logic that reacts. |
ou |
A whole procedurally generated universe, built on the OpenUniverse engine. |
It won't hand you a mission your game can't launch. Missions are pinned to a
release line, and everything a mission loads comes from the same one. sbs create
picks the newest line your install already has libraries for β capped by your version
of Cosmos β tells you which it chose and why, and never reaches for a newer one just
because it exists. Not every template is on every line, either: addon, amd and
ou need v1.4.0 language and library features.
The templates live in the
mast_starter repository and are read
straight from it, so new ones show up without updating the tool.
β Creating a mission Β· The sbs CLI
π§° The AMD editor knows the format
The VS Code extension reads AMD as a format rather than as coloured text:
- "This is a" picker β a record's word is visible and settable, grouped Story / Work / Content, and it tells you what choosing it means ("scope: shared, show: when done").
- Completion answers where you are: nouns on the fence's first line, field labels on a fence line, that field's values after the colon.
- Hover explains the field β the format's own words for it and its allowed values.
- Your mission's own vocabulary is learned, by reading the Python that declares it (never running it), so a mission's private fields get the same widgets and lint as the core ones.
- Edits are safe β changing one field leaves the kind line,
//notes and list continuations exactly as you wrote them.
π¨ Design a screen without writing the code β the GUI Editor
Lay out a console screen visually and it writes the MAST for you. Drag sections,
rows, buttons, lists and tables from a palette, size a section by dragging its corner,
rearrange the tree, and watch a live preview β then Copy the generated code or
Insert it into your .mast (it updates just a # <gui-designer> block if you have
one).
*.gui.mastfiles open as the editor β the whole file is the screen, edited visually and saved back as MAST, with a one-click toggle to the raw text and back.- Round-trips β reopen a screen and keep editing; your comments and anything the editor doesn't model are left untouched.
Docs: The GUI Editor.
π Debug your mission β pause it and look inside
Ever chased a bug by sprinkling print lines and replaying? Now you can pause your
mission while it plays and look inside it β right in VS Code. Put a pause point on
any .mast line, play your mission, and it stops there: the exact line, every value your
story is tracking, and how it got there. Then step through one line at a time and watch it
unfold.
- One click. Pick "MAST: Debug mission (one click)" and press play β the mission launches with its game window, you set pause points and debug, and pressing Stop shuts it all down. No terminals, no leftover processes to hunt for.
- Proper debugging tools. Pause points that stop only when a condition is true
(
hp < 20), on the Nth hit, or that just log a note without pausing; step over / into / out; a live Variables panel (Task / Shared / Global) you can edit while paused; a call stack; and watch expressions. - Step into the engine. On a line that calls a built-in like
terrain_to_value(...), Step Into drops you into the engine's own Python β real source and its values β then Step Out brings you back to your story. Even library code that ships zipped shows its source.
It costs nothing when you're not debugging β normal mission runs and the shipped game are completely unaffected.
Docs: Debugging your mission.
π οΈ For Mission Makers & Tinkerers
For testing only β not an engine replacement
The mock GUI and headless runner exist to test and debug missions outside Cosmos. They are not a reimplementation of the engine, and 100% parity is not a goal β many engine behaviors are approximated or absent by design. Please don't file issues or feature requests asking for more engine parity. If something must behave exactly like the engine, verify it in Cosmos.
- Run missions outside Cosmos. A full headless test mode plays a mission for N seconds and reports a pass/fail verdict with coverage β great for CI β plus a browser-based mock GUI with a 3D cinematic view and 2D radar. β Testing missions
- Web pages, written in MAST β see above. β Web pages
- A coverage "exerciser" that drives real gameplay (scans, torpedoes, docking, comms) to shake out routes automatically. β Testing missions
- A faithful mock simulation calibrated to the real engine: ship speeds, 3D steering, per-facing shields, heat, energy, and a full weapons model (beams, torpedoes, drones, mines, EMP). β Testing missions
--use-working-treeto smoke-test local library edits, and--seedfor reproducible runs. β ThesbsCLIsbs compilecan gate a build. It used to print an error and exit 0, so any script or CI reading its exit code saw success on a compile that never happened. It now reports the errors and exits non-zero. (It still can't see a multi-line{ }β only a--testrun finds that.) β ThesbsCLI- An in-game Avatar Editor β an opt-in addon that customizes a character face
inside Cosmos, with a live
gui_facepreview that updates as you move the sliders (unlike the extension's blind builder). Pick a race, tweak each feature, and the face is copied to your clipboard on every change β paste it straight into a.amdFace:field. β Avatar Editor addon sbs swapβ keep several mission sets side by side and switch between them without copying anything (see below). β ThesbsCLIsignal_nextβ one-shot await of the next signal. β Signalsonceroutes, and creates that can't duplicate.//shared/signalruns a route server-once per emit β it never promised the signal is only emitted once, so setup that got emitted twice used to build everything twice. Now mark a route//shared/signal/give_starting_cash onceand it runs at most once a mission. Better still, where the thing being made has a natural name, creating is idempotent:player_ensure(slot, β¦)and AMD landmarks and characters key off the slot or the record's own key, so asking twice gets you the same ship, station or person β while a deliberate rebuild after a reset still works. β Signals- Bring an old Artemis 2.8 mission forward.
pip install arme2cosmos, point it at the old XML, and it writes the Cosmos mission for you β as a quest tree, or as plain MAST you can edit β plus a notes file listing anything worth a second look. It's ordinary Python, so you don't need Cosmos to run it. Porting by hand instead? Thea2x_*helpers are waiting in MAST, no import needed. We tried it on 27 of the old missions and they all run. β Porting from Artemis 2.x - A faster, friendlier MAST compiler β quicker parsing, all errors reported at once, sturdier crash handling, more Python built-ins available in scripts, and now multiline expressions (see below). β The MAST language
- Declare addon dependencies β
provides/requires/suggestsmake cross-addon contracts explicit and compile-checked (see below). - Deterministic building blocks β keyed terrain fields, position-keyed scatter, and reusable game-code encode/decode.
β¨ New β sbs swap, switch mission sets in place
Cosmos loads exactly one data/missions folder. If you keep more than one set
β a converted 2.8 port, a work-in-progress copy, the stock missions β you have
been copying folders around. Instead, park each set beside it as
data/missions_<name> and let data/missions be a link:
sbs swap # which set is active, and what else is available
sbs swap amd # load data/missions_amd
sbs swap mast # load data/missions_mast
Any folder named missions_<name> is a valid target, so adding a set is just
creating the folder. If your data/missions is a normal folder, the first swap
renames it to missions_cos rather than deleting it, so sbs swap cos puts you
back on the stock missions. Close Cosmos first β a running client holds files
open under the link. β The sbs CLI
Back up your missions first
This moves and re-creates the data/missions link, and renames a real
data/missions folder aside. It is written to never delete a mission
folder β only the link β but it is rearranging the folder that holds all
your work. Take a backup of data/missions before your first swap,
especially if you have edits in there that aren't in source control.
β¨ New β multiline expressions in MAST
Python dicts, lists, and function calls can now span multiple lines, just
like Python. No more cramming a spawn onto one giant line or reaching for
~~ ... ~~ fences β write it the natural way:
prefab_spawn("prefab_fleet_raider", {
"race": "skaraan",
"fleet_difficulty": 2,
"START_X": fleet_pos.x,
"START_Y": fleet_pos.y,
"START_Z": fleet_pos.z,
})
Works for any bracketed expression β dict/list/call literals and multiline
if conditions alike. Error line numbers stay accurate, and every existing
script compiles exactly as before. β The MAST language
β¨ New β declare addon dependencies
Addons share one global namespace and load in no fixed order, so "this addon
needs that one" used to be an unwritten rule that failed with a cryptic
NameError. Now an addon states its contract at the top of its __init__.mast,
checked at compile time:
provides hangar.sortie_board
suggests hangar # optional: warn if absent, never fail
requires gamemaster # hard: fail the build if it's not loaded
A requires not satisfied by some loaded addon's provides fails the
compile (caught by sbs lint / --test, and shown as a runtime error screen);
suggests only logs a warning. Checking is order-independent and fully
backward compatible. β Making add-ons
β¨ New β colons inside quoted strings
A : inside a quoted string no longer confuses the parser, so you can write
handler labels naturally β no more assigning the button to a variable first:
on gui_message(gui_button("Test Upgrades:")):
show_upgrades()
Previously the : in "Test Upgrades:" was mistaken for the end of the
on ...: header and the line failed to compile. The same fix applies to
await ...: blocks, and button/text labels with colons
(gui_button("Score: 5")) now render as text instead of being misread as a
style key.
β οΈ Don't call sbs.delete_object β use delete_object instead
Deleting an object with the raw engine call sbs.delete_object(id) is a
known way to crash Cosmos to the desktop. Delete through the procedural
delete_object(id) (or obj.delete_object()) instead:
# β risky β frees the object immediately
sbs.delete_object(DAMAGE_TARGET_ID)
# β safe β tombstoned now, freed safely later
delete_object(DAMAGE_TARGET_ID)
Why it crashes. sbs.delete_object frees the native C++ object β and its
engine_object/data_set pointers β the instant you call it. But MAST
tasks run interleaved across a tick: another task (or even the next line,
in a different task) may still be holding that object. When it touches the
freed object it reads dead memory β a use-after-free that crashes to the
desktop, or, if the memory slot has been reused for a new object, silently
corrupts that unrelated object and crashes later somewhere else.
Why delete_object is safe. It tombstones the object immediately β
object_exists() and to_object() report it gone at once, and it stops
ticking β but defers the actual native free to the end of the event
handler, once every task for that tick has finished. So a reference held
elsewhere this tick still points at valid memory instead of crashing. It also
does nothing if the object is already gone, so a double delete can't
double-free. Scripts that already use delete_object / obj.delete_object()
need no change β only direct sbs.delete_object calls should be swapped.
π Incoming hails β the mission calls you
Comms has always been something the crew starts: pick a contact, a menu opens. An incoming hail is the other direction. It arrives in an Incoming Hails list on the comms console, newest first, and waits until somebody answers it.
Answering opens a short conversation with up to four things the crew can say back. Back
steps out without answering, so comms can read a hail through and present it later, when
the captain is ready. Answered conversations stay in the info panel and can be replayed
β a replay can never change what was chosen.
Beside the list is a dial that decides where the conversation is drawn β Off, This Console, Main Screen, or Both β and an Audio checkbox. Each console carries its own dial, so putting a hail on the main screen is a decision one officer makes, not something the mission forces on the bridge.
While a hail is placed on a comms console, that console's 2D radar follows whoever is calling, the way science points it at a scan target. It obeys the crew's existing Follow checkbox, and it hands the radar back afterwards.
Here There Be Monsters is written this way end to end: twenty scenes in one document, each named by one line of the story.
π A beat that opens with a call
The reason hails matter for authors: somebody calling is a natural way to hand out work, and taking the job is what you say back. Both halves are AMD.
#### [Take the Case](brief)
---
Scope: shared
Starts when: revealed
Action:
- ds1 hails ds1_brief
Then: reveal florbin/trail
---
### [DS 1 Briefing](ds1_brief)
---
Speaker: ds1
When: hail
Title: Ambassador Kidnapped
---
Ambassador Florbin was taken off this station inside a cargo container.
- [Take the case]() ; completes florbin/brief
- [Not now]()
The scene carries the words, the title, the portrait and the recorded line, so the beat
only names it. Written bare β ds1 hails β it opens that speaker's When: hail scene.
Who gets called follows Scope: and you never say: a shared beat calls every player
ship, a per-ship beat calls its own.
What an answer means is written on the answer: accepts, completes or fails a
quest, or signal for anything those three do not cover. It is resolved on the server, so
it happens exactly once however many consoles are connected β no //shared/signal route
to write, and no guarding against two officers pressing at the same moment.
Peacetime's Florbin case now opens this way, and the two pieces of MAST that used to do it by hand are gone.
Three smaller things came with it. At start: posting lists a quest without a working
Accept button β a board you take by answering the call that offers it, not by pressing a
button. Then: signal X now reaches other quests' Done when: signal X, which is what
those two lines always looked like they did. And a mistyped scene key, an unknown outcome
verb or an unrecognized Then: word are all lint findings now, instead of a beat that
quietly does nothing.
β οΈ List boxes: row-height is the row's height
This one may move your layouts. On a list box, row-height used to be the gap added
after each item β the height came from whatever the item template opened. It now means
what it says everywhere else: the height of one item row. The spacing between items is
a new key, item-gap.
lb = gui_list_box(items, "row-height: 1.6em; item-gap: 0.1em;", item_template=row)
If you declared row-height and meant spacing, rename it to item-gap and nothing
moves. If you declared it and the template already set its own row height, the list was
rendering at double pitch and will now tighten up β including every gui_table, which
handed one style string to both.
It is a floor, not a cap: a two-line item still grows past it. Declare neither and nothing changes at all.
Worth the churn because the old meaning was not only confusing, it was wrong in a way you could not see: an item's click region comes from its row box, so a list could draw text you could barely click, and a template whose rows declared no height collapsed to nothing and took its hit area with it.
π Read your mission on paper β sbs docs
Your .amd files hold the quests, the dialogue, the cast, the lore. Until now the only
thing that could read them was the game, so reviewing a script meant opening a code
editor.
sbs docs . --lens all --pdf
Four editions, because an .amd file is a different document depending on who is
reading it: prose (a manual or story book), catalog (a sourcebook, where every
field is rendered as its type β a color as a swatch, a reference as a working link),
screenplay (a script you could read aloud), and bible (the quest spine, its
triggers, and a map of the branches).
--profile player leaves out everything a player should never see β author notes,
the condition on a dialogue choice, what a choice secretly costs β and genuinely leaves
it out of the file, so you can hand the PDF to someone. The bible has no player profile
and says so: the bible is the spoiler.
PDFs need no extra install; --pdf drives the headless browser you already have.
Character faces composite properly into the page. See Printing a
mission.
π©Ί sbs doctor β check your setup
Reports what is installed, what a mission expects, and what is missing β with the
command that fixes each one. It checks your setup, never your writing; for that there
is still sbs lint. See Checking your setup.
π’ Add-on ships: a file that exists, not a file that gets written
An add-on that adds hulls used to hand the library its ship data and let the library
generate extraShipData.json in your mission folder just before the sim was
created. That route is now known broken: the generated file stays on disk, and on
the next run get_ship_data() prepends it whole while the add-on declares the same
entries again β measured at 51 hulls becoming 102 from run 2 onward.
Now an add-on ships its ship data in its media pack and names it:
ship_data_add_extra("turrets/extraShipData_turrets", mod="MyMod")
One call points both readers at that one file: the engine (which resolves
artfileroot, and is what makes a behav_station fire at all) and sbs_utils' own table
(which is what filter_ship_data_by_side and headless use). Nothing is written.
It has to be a media pack rather than a mastlib because a mastlib is a zip and the engine cannot read inside one β which is exactly what the writing was working around.
If you maintain a mod that calls ship_data_merge_mod, move it. See Making a
mod.
π A button's handler no longer dies with the task that built it
A widget's handler belongs to the task that built the widget: on gui_message(w):
compiles to an inline block inside that task's label, and on_press=<label> is a jump
on that task. So a builder that was scheduled and then ended took its handlers with it
β the button drew, the click did nothing, and nothing was logged anywhere. That
silence was most of the problem.
await task_schedule(build_panel) # this REQUIRES the builder to end
Handlers now run in that case, and if one still cannot, a warning naming the source
site goes to mast.runtime.log.
A second fix rides along and reaches further: an on ...: block whose body falls off
its end (no jump, no ->END) never gave the task back, so it never returned to its
await. The visible face of that was a self-repainting panel β a live countdown, a
refreshing status β freezing permanently on the first press. If a block of yours ends
in jump, it always escaped this and is unchanged.
gui_message_callback(widget, fn) remains the option with no task in the path at all,
and is still the safest choice for a handler built somewhere awkward.
π₯ A broken expression stops, instead of quietly becoming None
None is a perfectly good MAST value β default ship_art = None is ordinary β so
when an expression blew up, the runtime had no way to say so. It logged the error
and then handed the line None anyway, and the line ran with it:
range_to_base = ship.pos.distance(base_pos) # `ship` is a typo
if range_to_base < 5000: # None -> takes the ELSE
...
The assignment wrote None β and if it was shared, every other task in the story
now read None too. An if slid into its else:. Worst of all, for x in <broken>:
went on to do iter(None) and reported a second error, on the same line, about a
NoneType β and that second one is the one people saw. The real cause was three
messages up, if it was still on screen at all.
Now the line simply does not run. The assignment does not happen, the if does not
choose a branch, the loop does not iterate, jump X if <broken> neither jumps nor
falls through. The task ends there, and the first error in mast.runtime.log is the
real one.
The message improved too. It names the exception, quotes your expression, and no longer
prints the word None where the offending code should be:
mast RUNTIME ERROR
line: 12 in file: story.mast
label: patrol_logic
NameError in expression:
ship.pos.distance(base_pos)
name 'ship' is not defined
And it knows about the trap that reads as "my variable vanished":
assigned = sbs.get_ship_of_client(client_id) # assigns to NOTHING
ok = assigned == foe_id # NameError, reported HERE
shared, assigned, client and temp are scope keywords, so assigned = x
parses as the assigned scope with an empty target. The error now says so by name
instead of leaving you to find it. (Rename the variable β assigned_ship is fine.)
Nothing to change in your scripts. A mission with no broken expressions behaves exactly as it did; a mission with one now tells you which one.
πΊοΈ @map works without LegendaryMissions β a map picker in the library
@map is how a mission offers several entry points, and until now it only worked if
you loaded LegendaryMissions. Both halves lived there: LM's server console drew the
selection screen, so a mission loading only sbs_utils came up with an empty server page
and no way in, and the headless runner's --map had nothing to start.
Two functions close that, and a mission front-end is now two lines:
== main ==
chosen = await gui_map_picker()
map_start(chosen)
gui_map_picker() is a carousel of your story's maps β name, description, next/prev β
together with that map's Properties: panel and a Start button. map_start() launches
what you were handed: applies the map's Defaults:, resumes the sim, schedules the map,
sets GAME_STARTED and emits game_started. That launch sequence previously existed
twice, in LM's console and in the runner, and the two had quietly drifted apart β down to
whether the sim resumes before or after the map is scheduled. There is one definition now.
The properties panel is on by default, because a map declaring a PLAYER_COUNT slider
expects that variable to exist when it runs. Skip the panel and it starts unset β which
looks like a mission bug, not a missing feature. Pass properties=False if you really do
want just a carousel and a button.
Conditional maps are now honoured. @map/secret "Secret" if UNLOCKED was previously
offered whether or not UNLOCKED was true; maps_get_list() evaluates the condition and
hides it. Two carve-outs: with no task in context (the headless runner) maps are shown,
and maps_get_list(include_hidden=True) still returns everything, so a saved game code
naming a currently-hidden map keeps resolving. If you rely on --map 0, note the index
counts the filtered list β prefer --map <name>.
Do not ->END straight after map_start
The task running the picker is usually main, and main is the page's GUI task. The
started map calls gui_task_jump to show its own page, and that call is silently
discarded if the GUI task has already finished β no error, blank screen. Park the
task instead:
---running
await delay_sim(1)
jump running
This is the light tier on purpose. LM's console also does game codes and presets,
music, the player roster, operator mode and difficulty-scaled beam damage; it keeps all of
that. The picker is for missions that must stand on sbs_utils alone β which is exactly
what an add-on's own test mission needs, so a mod repo can now ship a mission that runs
from a fresh clone with nothing else installed.
See Map picker.
Thanks for playing, building, and tinkering. There's more under the hood than ever β go build something great. π