Quest construction language¶
The language custom quests are written in. A recipe runs once and produces a finite, ordered table of existing spawn-template invocations. It has no callbacks during gameplay and cannot change creature behavior, collision or arena geometry.
The Python game, the browser and desktop crimson-core client, and the leaderboard
play, verify and rank custom quests. A recipe resolves into a CustomQuest
definition, which a run starts the way the original starts a quest and which its
replay carries (replay format 33). Publishing a quest
(crimson quest publish, POST /api/custom) requires a completed one-player ranked
replay of it and creates its page and leaderboard at crimson.land/custom/<id>. Recipes for
all 50 stock quests are written in it too, but the stock builders remain the game's
authoritative implementation.
crimson.land/custom/llm.txt is the single-file version of this page for LLMs: the grammar, these semantics, creature and weapon numbers to balance with, and the toolchain.
Try it¶
With uv installed, no checkout is needed:
uvx crimsonland@latest quest build my.quest
uvx crimsonland@latest quest play my.quest
uvx crimsonland@latest quest publish my.quest
quest build compiles a recipe and prints its ID, weapon, terrain, entry count and
copies; --json prints the resolved CustomQuest. quest play opens the game
straight into the quest, and every attempt saves a replay. quest publish uploads
the recipe with a clear of it as proof. It picks the newest clear in the replays folder whose quest matches
the recipe; --replay names one instead. A quest's ID is the first 12 hex digits
of the resolved definition's digest, so any change to what a recipe produces is a
new quest that needs its own clear. The browser plays published quests only.
From a checkout, the compiler also takes stock quests and other construction inputs:
uv run python -m crimson.quests.dsl --stock 4.7 --seed 0xBEEF
uv run python -m crimson.quests.dsl --stock 4.7 --players 4 --hardcore
uv run python -m crimson.quests.dsl --file src/crimson/quests/dsl/examples/clockwork.quest --output /tmp/clockwork.json
uv run pytest tests/quests/test_quest_dsl.py -q
--seed is a construction seed, not a gameplay start seed. JSON includes
the resolved positions, headings, counts, trigger times, starting weapon and terrain
slots. Each entry also has a source location that is unique within the plan: its
line, enclosing loop iterations, pattern call-site lines and position in a place list.
A recipe¶
The syntax uses indentation. Each statement occupies one line; a bracketed list
may continue over several. Comments start with #; expressions use familiar
arithmetic, comparisons and indexing. The grammar in
quest.lark
is the syntax's specification: a Lark LALR
grammar, so syntax errors name the line, the unexpected token and what could come next.
quest "Clockwork" version 1
weapon assault_rifle
terrain (0, 1, 0)
let sockets = ring(center, radius=320, count=8)
let entrances = ["north", "east", "south", "west"]
pattern reinforcement(side):
spawn formation.alien_ring at edge(side, outside=160)
repeat 8 as i:
at 1s + i * 6s:
spawn nest.alien_slow at sockets[i]
at 3s + i * 6s:
reinforcement(entrances[i % 4])
at 50s:
spawn spider.boss at (768, 512)
This constructs 17 native entries. Pattern calls expand before play. A template is an opaque game primitive: offspring, formation members and AI remain native.
Statements and values¶
| Syntax | Meaning |
|---|---|
quest "Title" version 1 |
Required header; title of 1..64 printable ASCII characters |
author "Name" |
Optional byline under the title: 1..31 printable ASCII characters; presentation only |
weapon pistol |
Starting weapon; lowercase WeaponId name, default pistol |
terrain (0, 1, 0) |
Three terrain-slot IDs in 0..7, default (0, 1, 0) |
order chronological |
Stable sort by trigger time, retaining construction order for ties; default |
order as-written |
Keep construction order; see table order |
no_bonuses [...] |
Disable random bonus drops (bonus IDs 2..14; points stays enabled) |
no_perks [...] |
Disable perk offers (perk IDs 1..57; instant_winner stays enabled) |
no_weapons [...] |
Disable specific weapons (IDs 2..33) from random weapon drops |
fixed_weapon true |
Lock the run to the starting weapon (disables weapon bonus, weapon perks, and non-Pistol weapon drops) |
let name = expression |
Immutable local binding |
var cue = 2s |
Mutable construction-local binding |
cue += 500ms |
Update a declared mutable binding |
repeat N as i: |
Repeat with indices 0 through N−1 |
for pos in points: |
Iterate a bounded list or range; tuple destructuring is supported |
if condition: / elif / else: |
Construction-time decisions |
while condition: / break |
Construction-only loops, bounded by the compiler work budget |
at time: |
Absolute nominal quest time; applies to enclosed spawn/pattern operations. Every spawn must run inside one |
spawn template at point_or_points [copies N] |
Emit one entry per point (single point or list of points), default count 1 |
burst template at point copies N |
Alias for spawn on a single point with count N |
place template on points [copies N] |
Alias for spawn on a list of points |
heading angle |
Optional suffix on spawn/burst/place; default 0 radians, facing north. Sets the initial facing only: it does not rotate formations |
pattern name(args): |
Reusable construction block; declared before use; no recursion or return values |
Settings precede construction statements. Block-local declarations can shadow
outer values. Assignments update the nearest declared var; loop bindings are
local to each iteration. Patterns see their parameters and quest-scope bindings,
and inherit the caller's at time. They may also contain explicit at blocks.
Values are numbers, booleans (true/false), double-quoted strings, immutable lists,
points and template references. Words such as at, heading and copies act as
keywords only where the grammar expects one, so they remain usable as names. There are no mutable collections. range(start, stop, step)
has an exclusive stop. +, -, *, /, //, %, unary signs, and, or,
not, comparisons, membership, indexing and conditional expressions are supported.
Points add and subtract, and scale by numbers (west + point(0, 158),
start + direction * 62.4); lists have no arithmetic or comparisons.
Comparisons use scalar values or points; membership uses lists of those values.
Time literals 500ms, 2s, 2.5s resolve to whole integer milliseconds. Bare
integers used as times also mean milliseconds. Angle literals such as 90deg
resolve to radians; bare angles are radians. Units are literal conveniences,
not a dimension/type system. at never means time relative to a previous block.
Available construction inputs are players (1..4), hardcore, center
(point(512, 512)), arena_size (1024), pi and tau. In hardcore, the game
also adds 8 copies to every entry with more than one (2 for fast aliens, none for
plasma shooters); the compiler applies that rule after construction, as the game
does for every quest, so recipes read hardcore only to change their own design.
A custom quest builds once, with players 1, hardcore false and construction
seed 0, and plays that table under the ranked rules
whatever the player's settings.
Table order¶
The classic scheduler keeps no separate notion of a wave. Each frame it takes the first due entry in table order, together with the entries immediately after it that share its trigger time, and spawns that group. Once the arena has been empty for 3 s, it takes the first remaining entry in table order early, whatever its time. An entry's copies spawn in a line through its point, 40 units apart on alternating sides (offsets 0, −40, +80, −120, …): along x, or along y when the point lies off the west or east side. The quest is won once every entry has spawned and no creature is alive, nests included. So the table is a queue:
- Entries with the same time arrive together only when they are adjacent. Two same-time entries separated by another time arrive on consecutive frames.
- Clearing the arena early brings forward whatever is next in the queue. In a chronological table that is the next wave by time; otherwise it may be a later one.
The scheduler spawns one group per frame. Groups that fall due together queue up a frame apart, so same-time entries split by other times, or many groups due at once, arrive late by a frame each, and in a dense table those frames add up. Beyond that, order matters only to fast players. Several stock quests list entries out of time order, and early clears expose it:
- The Beating lists all its ghosts before its alien rings, although both arrive from 40 s. An early clear brings only the ghosts forward; the rings keep their time.
- Lizard Raze lists its three lizard dens after every lizard wave. An early clear brings the next wave forward; the dens still wait for 10 s.
- Spider Spawns lists its inner nests at 20.5 s, 26 s, 31.5 s and 22 s, mixed with spiders at 21 s. Early clears pull them forward in that listed order.
order chronological, the default, gives the intuitive queue. Stock recipes for
quests whose table is not chronological use order as-written and say what
queues out of time order.
Template vocabulary and geometry¶
All implemented SpawnId members have a canonical reference such as
template.alien_small_gray_26. Two templates are excluded: the unused 0x02, and
0x37, a ranged spider whose projectile type the original never sets. Numeric
native IDs are also accepted. Short aliases currently cover:
alien:small_gray,fast,small_green,green,purple,big_gray,gray_fast,boss,cyan,ghost,bonus_carrier,delayed,flanker,spawner_ring,randomspider:small_blue,plasma,splitter,boss,red_boss,white_fast,small_brown,stop_and_go,stop_and_go_weak,flanker,random,random_red,random_greenlizard:gray,boss,flanker,randomzombie:random,spawner_boss,small_white,brutenest:alien,alien_slow,alien_weak,spider,plasma,spider_weak,lizard,lizard_slowformation:alien_ring,alien_ring_small,alien_chain,lizard_chain,alien_grid,alien_grid_green,alien_grid_bronze,lizard_grid,spider_grid
These are selections from the existing catalog, not new enemy definitions.
place alien.small_gray on ring(...) creates independent enemies; spawning
formation.alien_ring invokes the original linked formation.
Creatures has their health, speed, bites, nests
and linked groups; formation.alien_grid_green is the one template no stock quest uses.
| Helper | Result |
|---|---|
point(x, y) or (x, y) at a spawn |
A position; .x/.y are available on point values |
edge("west", outside=64, fraction=0.5) |
Point along an arena edge; also east/north/south |
edge_line(side, count, spacing, outside=64) |
Ordered centered line outside an edge; ascending x for north/south, ascending y for west/east |
opposite(side) |
The opposing cardinal edge name |
corner("northwest", outside=64) |
A point beyond an arena corner; also northeast/southwest/southeast |
corners(outside=64) |
Ordered northwest, northeast, southwest, southeast corner points |
line(start, step, count) |
Ordered positions, advancing by a vector |
ring(center, radius, count, start=0) |
Ordered evenly spaced positions, clockwise in screen coordinates |
polar(center, radius, angle) |
One circular position |
arc(center, radius, start, sweep, count) |
Ordered positions including both endpoints; positive sweep is clockwise; a singleton is at start |
rotate(points, angle, around=center) |
Rotate positions clockwise for positive angles; preserve list order and the input list |
facing(point, target) |
The heading that faces target from point |
min, max, abs, int, float, len |
Construction calculations; min/max take numbers or one numeric list |
Parameters with defaults (outside, fraction, start, around) are
keyword-only: ring(center, 240, 8, start=0.5), not ring(center, 240, 8, 0.5). The y axis points down, so north
is y = 0, polar/ring/arc angle 0 points east, and positive angles turn
clockwise on screen. edge with fraction 0 is the west end of the north and south sides and the north end of the west and east sides.
Geometry uses ordinary double-precision math; emitted positions and headings are stored as float32, like native entries. Off-arena positions are allowed because the stock quests deliberately use them. Rotating a position does not rotate a native formation's internal offsets.
rand() draws from the construction MSVCRT stream (0..32767). choose(list) and
rand_int(low, high) (inclusive) use a draw modulo the list length/range width;
they are reproducible, but not unbiased sampling over arbitrary ranges.
shuffle(list) returns a new immutable list using Fisher–Yates, consuming one
construction draw for each index from N−1 down to 1. Each swap index is the draw
modulo (index+1). Input order is preserved in the original list, and empty or
singleton lists consume no draws. Shuffling uses the same construction stream
as choose and rand_int.
Stock quests¶
Every campaign quest has a recipe in src/crimson/quests/dsl/stock/, written as
a designer would describe it rather than transcribed from the builder. The recipes
are a corpus the language must express: loops, player and hardcore scaling, edge,
corner, ring and spiral geometry, random placement, and out-of-order queues.
The test compares each recipe with its stock builder for players 1..4, both
hardcore settings and four seeds. The recipe's rand() uses the same seeded stream
as the builder:
- The ordered sequence of template, count and trigger time must match exactly, since that is everything the scheduler reads.
- Positions must agree within 0.01 px and headings within 1e-4 rad. Recipes use plain arithmetic; there is no float32 or x87 emulation.
- Random choices draw from the stream in the builder's order, so Target Practice, Sweep Stakes, Deja vu and The Random Factor match exactly.
The Killing is the one listed exception. The original rolls a random enemy and side for each wave, then ignores both, so its rotation is fixed. The recipe skips those ignored rolls, so its random nests land elsewhere for the same seed; the test checks they stay within the original's 128..895 square, and still checks every heading.
Example encounter library¶
src/crimson/quests/dsl/examples/ contains the scenarios from the design discussion.
These are structurally validated authoring fixtures for future playtesting and
challenge curation. They have no verified clears or balance claims yet.
Entry counts measure native template invocations in the timeline, not total
creatures or offspring. All recipes fit the existing 256-entry table.
| Recipe | Entries | Design decision |
|---|---|---|
clockwork.quest |
17 | Eliminate successive nests while native formations arrive |
crosswind.quest |
120 | Alternating ranged pressure against growing melee crowds |
spiral-descent.quest |
60 | Manage contracting arrival positions; movement remains native |
accelerating-encounters.quest |
12 | Increasing native bursts with shrinking wave intervals |
shuffled-threats.quest |
8 | Reproduce a construction-seeded deal of threats and entrances |
plasma-orchard.quest |
55 | Choose which ranged nest to eliminate while melee crosses the arena |
moving-factory.quest |
17 | Pursue a mobile spawner or create space in its crowds |
split-decision.quest |
65 | Choose safe finishing positions for opposing splitters |
formation-parade.quest |
12 | Break native rings, chains and grids before the next arrival |
closing-circle.quest |
240 | Preserve an escape route through a rotating 60-degree gap |
nest-circuit.quest |
24 | Follow a tightening nest activation schedule or cut across it |
same-problem-assault-rifle.quest |
61 | Compare the same encounter with different starting weapons |
same-problem-shotgun.quest |
61 | Same ordered entries and terrain as the Assault Rifle variant |
same-problem-gauss-gun.quest |
61 | Same ordered entries and terrain as the Assault Rifle variant |
The three weapon variants deliberately keep their encounter bodies identical. They change only the title and starting weapon; a regression check preserves entry-for-entry equality. Crosswind retains the design discussion's exact eight waves, while the named scenario sketches now have concrete timings and counts.
from crimson.quests.dsl import build_custom_quest, compile_quest
plan = compile_quest(source, seed=0xBEEF, player_count=1, hardcore=False)
entries = plan.entries
quest = build_custom_quest(source, seed=0xBEEF)
compile_quest constructs the table one variant plays. build_custom_quest
resolves a recipe into the single-table definition a custom quest run plays
(RunSpec.custom_quest): the title, author, starting weapon, terrain slots,
resolved entries, and drop/offer restrictions (no_bonuses, no_perks, no_weapons).
crimson quest play builds one (construction seed 0 unless --seed) and starts it in ranked mode
straight after boot; crimson quest publish uploads the recipe alongside a completed
replay to establish its leaderboard on crimson.land/custom/<id>.
Bounds and runtime semantics¶
Default limits: 64 KiB source, 10,000 AST nodes, syntax/pattern depth 64, 100,000 construction work units, 4,096 items per collection, 256 emitted entries and a one-hour nominal time horizon. Copies are capped at 384 per entry and per trigger time (the size of the creature pool) and 8,192 per quest, counted after the hardcore adjustment; the stock quests peak at 108 per trigger time and 2,000 in total. Integer intermediates have at most 64 magnitude bits, and numbers and point coordinates must be finite. Spawn positions must lie within −1024..2048 on both axes: a full arena beyond each edge, which every stock arrival fits. Work accounts for interpreted statements, expressions, iteration and collection operations; it also stops loops that emit nothing.
The compiler interprets the parse tree directly; anything outside the grammar is a
syntax error, and nothing is evaluated as Python. Calls reach only the listed helpers,
and each helper's arguments are checked against its declared parameter types (points,
finite numbers, integers, strings, lists) before it runs. Names beginning with _,
recursion and access to live game state are unavailable.
Copies count template invocations, not creatures: formations bring their members and nests keep spawning. These limits are not a guarantee about creature or spawner occupancy during every possible run; pool saturation reporting belongs in later playtesting work.
The plan assumes the existing classic quest scheduler and clear-all objective.
Times are nominal gameplay-clock triggers with a strict due-time comparison;
table order decides grouping and early dispatch. There is no exact
wall-clock scheduling, kill-triggered scripting or enforced deadline. The stock
time_limit_ms metadata is deliberately not exposed as an objective.