Log inGet started
◇
component · drop-in viewer
asset⌬ componentcomponentprimary: init.luau·originates fromworld bf18e270-2…

CascadeSim

One entity carries the whole laboratory: the simulation, the GPU texture the board is drawn into, the screen, the input and the readouts. Put it on an entity and the world becomes an instrument for watching a self-organising system approach dangerous connectivity.

by◐m3rck·posted 21d ago
What it does

CascadeSim — the instrument

One entity carries the whole laboratory: the simulation, the GPU texture the board is drawn into, the screen, the input and the readouts. Put it on an entity and the world becomes an instrument for watching a self-organising system approach dangerous connectivity.

entity.spawn("Sim").component.add("CascadeSim", {
    gridSize = 256,
    seed = 20250922,
    tickRate = 25,
})

What it is

A Drossel–Schwabl forest-fire model in the general sense: cells accumulate connectivity, small disturbances happen often, large cascades happen rarely, and the world spends most of its time near the density where both are true.

Nothing in the model knows about forests. The cell states are EMPTY → OCCUPIED → ACTIVE → COOLDOWN → EMPTY with BLOCKED for terrain, and the same machine stands in for infection, grid failure, contagion, insurgency or traffic, because all of those are the same rule: a neighbourhood that accumulates connectivity eventually discharges it all at once.

The design question the instrument exists to answer:

Can a player learn to recognise when a self-organising system is approaching dangerous connectivity, and intervene intelligently before a huge cascade?

The run

gridSize sets the board, seed fixes the run, tickRate sets simulation ticks per second of wall clock. Everything else is a live parameter — see getParams() and setParam(name, value); changing one takes effect on the next tick, and no reload is needed.

The simulation is per-peer. Each participant drives their own world locally; two people in the world are running two experiments on the same seed, which is the point of a laboratory.

Controls (active in PLAY — press Play, or set interactiveInEdit)

InputWhat it does
SPACEpause / resume
Rreset the board, same seed
Nadvance one tick while paused
+ / -time scale (×2 / ÷2, 0.25× … 64×)
Cconnectivity view — shade the board by connected region
Hcycle the readout: Full Debug → Player Information → Minimal
1 … 6arm a tool
LMBapply the armed tool at the brush
RMBfirebreak — always the firebreak, whatever is armed
wheelbrush radius
Fnew seed
Greplay the same seed
Venvironmental feedback on / off
Pregrowth memory (Phase 17) on / off
Tcycle the propagation model
Yadaptive difficulty on / off
Ocycle the objective, or none
Eexport the run (CSV + JSON)
Brun the baseline strategies headless, in the background
, / .smaller / larger grid (128 … 1024)

The six tools

#ToolCostCharacter
1Triggerlow, fixedcause a cascade now, on purpose
2Firebreakper cell removedbreak connectivity without lighting anything
3Temporary Barriermoderatepropagation is zero inside it, for N seconds
4Suppression Zonemoderatespread probability scaled down locally
5Growth Boostmoderatemore production — and more future risk
6Growth Suppressionmoderateless production, less connectivity

Each has its own cooldown, charged only when it actually fires. Energy regenerates; the player is never able to control everything.

What the model does, precisely

Each tick, in order:

  1. decay — COOLDOWN cells count down and become EMPTY
  2. trigger — each OCCUPIED cell burns on its own with probability spontaneous_trigger_probability
  3. growth — each EMPTY cell becomes OCCUPIED with probability growth_probability × growth_field(x, y)
  4. propagation — every burning cell ignites neighbours according to the propagation model, each at spread_probability scaled by any suppression zone and by the model's own falloff
  5. merge — two live cascades that touch become one (policy below)
  6. burnout — the cells that burned this tick go to COOLDOWN

Cascade identity and the merge policy

Every spontaneous or player-made trigger mints a new cascade id; every cell ignited by propagation inherits the id of whatever ignited it. A cascade ends when it has no burning cells left, and is then recorded.

When two live cascades meet, the LOWEST cascade id survives. The absorbed record is retired without being counted as an event of its own, so its cells are counted exactly once — inside the survivor. A merged cascade inherits PLAYER as its trigger if either participant was player-started, and counts the merge in merges.

Merging is common, not exceptional: at the density this model settles at, new fires land next to old ones constantly. Merged cascades in the readout is the size of that effect, and it is worth watching — a run where merges outnumber events is a run where "one cascade" and "one decision" have come apart.

What cells_affected counts

Two numbers, because they answer different questions:

  • ignitions — how many times this cascade set something alight. A cascade that outlives the regrowth of the ground behind it burns the same cell twice and counts it twice. This is the consumption count.
  • cells — cells this cascade has consumed, counted once each. This is the field the readout, the event classification and the damage all use.

Tracked per cell as a cascade stamp resolved through the merge aliases. It is exact for ground a cascade reaches once; where two cascades have passed over the same cell and it later burns again, the stamp belongs to the more recent one and the earlier cascade can count it once more. That is the honest limitation of tracking events that merge, and it is small at the sizes most events reach.

Event classification (Phase 8)

SMALL under 25 · MEDIUM 25–249 · LARGE 250–999 · CATASTROPHIC 1000+. Thresholds are parameters. A catastrophic event rings the board in red, is recorded, and is displayed briefly. It does not end the run — the recovery period afterwards is part of the experiment.

What the instrument has measured

Run at 256×256, seed 20250922, 2000 ticks (≈80 s of simulated time), default parameters, no intervention:

Average density41.6% (peak 57.6%)
Cascades1043 — 824 small, 196 medium, 13 large, 10 catastrophic
Median size5 cells
90th / 99th percentile59 / 671
Largest41,189 cells
Largest connected region seen36,843 cells
Cost~7.3 ms per tick

The size distribution, one bucket per doubling:

     1-1        240
     2-3        210
     4-7        129
     8-15       142
    16-31       103
    32-63        80
    64-127       41
   128-255       33
   256-511        8
   512-1023       4
  1024-2047       1
  2048-4095       1
  4096-8191       4
  8192-16383      2
 32768-65535      2

Many tiny events, fewer moderate, rare large, very rare enormous — across five orders of magnitude. That is the useful gameplay characteristic the model was chosen for, and it is emergent: nothing in the code produces it.

Baseline strategies (Phase 25), headless, 128×128, 700 ticks, same seed:

strategyproductiondamagelargestcatastrophicinterventionsefficiency
Do Nothing4,632,13348,99613,7447094.5
Random Intervention4,627,47048,96613,75781894.5
Density Threshold4,626,30934,7258,41176133.2
Largest Cluster4,460,90034,59014,776617129.0
Firebreak4,597,12338,53512,105730119.3

Intervening helps; intervening at random does not (it costs production for nothing). Targeting the largest connected region holds damage down but leaves the single largest event high — cutting structure is not the same as releasing it. None of these is the right answer; they are the floor a player has to beat.

Performance (Phase 28)

Simulation state is flat arrays of numbers, never one entity per cell; the board is rasterised into a shared RGBA buffer with one u32 write per cell; and rendering is entirely separate from simulation state. Measured, unrendered, at the density the model settles at:

gridcellsms per tickimplied ceiling
256 × 25665,5367.5~130 ticks/s
512 × 512262,14430.9~32 ticks/s
1024 × 10241,048,576~124 (extrapolated)~8 ticks/s

Scaling is linear in cell count, so 256×256 runs continuously at 25 ticks/s for around 19% of one core while the editor keeps drawing. 1024×1024 is a batch experiment size, not a watching size — which is what the headless baseline runners are for.

Two optimisations, both from measurement rather than habit, are in CascadeCore and CascadeRng: a bit32-based xorshift cost about half a microsecond per draw here, and unrolling the generator into the tick loop made the whole simulation 2.7× faster; and every probability is stored pre-multiplied by the generator's modulus so the hot comparison never pays for a float divide.

Adaptive difficulty (Phase 22)

An optional controller (Y) that ramps the environment over ten minutes of simulated time: growth and spontaneous disturbance climb, spread rises, the operator's energy regeneration tightens, and halfway through growth stops being uniform and starts moving. Disabling it restores the five parameters it drives, exactly.

It never spawns a larger disaster. What it does is make the system harder to keep below dangerous connectivity, which is the only honest way to raise difficulty in a model whose whole point is that catastrophes are emergent.

Performance (Phase 28)

gridcellsms per tick
256 × 25665,5367.5
512 × 512262,14430.9

Linear in cell count; 256² runs continuously at 25 ticks/s for around 19% of one core. Simulation state is flat arrays, the board is rasterised with one u32 write per cell, and rendering is separate from simulation state — no entity per cell anywhere.

Known limitations

  • Merging is common at this criticality. A run where merges outnumber events is a run where "one cascade" and "one decision" have come apart; the readout reports merged cascades so that stays visible rather than silent.
  • cells_affected is exact for ground a cascade reaches once. Where two cascades have crossed the same cell and it later burns again, the earlier one can count it once more. A separate ignitions count is kept beside it so neither number has to stand for both questions.
  • No adaptive-difficulty curve has been tuned against a human. It is a working controller with a defensible shape, not a balanced one.

Methods on this component

MethodWhat it does
getParams()every live parameter
setParam(name, value)change one; offset tables and fields rebuild as needed
getSim()the live simulation table
stats()one flat snapshot of every headline number
dashboard()the whole run as a dashboard row
step(n)advance n ticks immediately, paused or not
setPaused(bool) / restart(seed?) / setVisible(bool)transport
export()write CSV + JSON under /source/local/cascade/<seed>/
runBaselines(opts)run the five strategies headless and return their summaries

Interface

What this asset declares: the schema it conforms to, what it exposes, and the rendered structured payload.

conforms to

zero/source-extract/v2

CascadeSim Component The instrument. Owns one simulation, one GPU texture and one screen. The split is deliberate: CascadeCore is the model and knows nothing about the engine, this file knows nothing about the model's rules, and CascadeHud knows nothing about either. Everything the player can change is a parameter on the simulation, so the same instrument answers to "tune the spread probability" and "run this headless ten thousand times". The run is per-peer: each participant drives their own world locally rather than replicating cell state. Two people in the world are running two experiments on the same seed, which is the point of the tool.

allocate(sim: ?) → void

argtypedescription
sim?

rebuild(width: ?, seed: ?) → void

argtypedescription
width?
seed?

say(msg: ?, seconds: ?) → void

argtypedescription
msg?
seconds?

st( ) → void

pointerCell( ) → void

countOccupied(cx: ?, cy: ?, radius: ?) → void

argtypedescription
cx?
cy?
radius?

applyTool(toolId: ?, cx: ?, cy: ?) → void

--!desc Apply one tool at a board position. Energy is charged and refused --! up front, so a tool the player cannot afford simply does not fire.

argtypedescription
toolId?
cx?
cy?

nextHudMode( ) → void

cycleObjective( ) → void

changeGrid(delta: ?) → void

argtypedescription
delta?

exportRun( ) → void

startBaselines( ) → void

handleInput(dt: ?) → void

argtypedescription
dt?

hit(key: ?) → void

argtypedescription
key?

renderBoard( ) → void

refreshHud( ) → void

awake( ) → void

drive(dt: ?) → void

argtypedescription
dt?

update(dt: ?) → void

argtypedescription
dt?

editorUpdate(dt: ?) → void

argtypedescription
dt?

onPropertyChanged(key: ?, value: ?, _old: ?) → void

argtypedescription
key?
value?
_old?

onDestroy( ) → void

getParams( ) → any

--!desc Read every live simulation parameter.

setParam(name: string, value: any) → boolean

--!desc Set one simulation parameter by name. Neighbourhood and --! propagation changes rebuild the offset tables; growth-field changes --! rebuild the field. --!arg name Parameter name, as listed by getParams(). --!arg value New value. --!return True when the name was recognised. --!example entity("Sim").component.get("CascadeSim"):setParam("spread_probability", 0.5)

argtypedescription
namestring
valueany

getSim( ) → any

--!desc The live simulation table. Everything the model exposes is on it.

stats( ) → any

--!desc One flat snapshot of every headline number.

dashboard( ) → any

--!desc The full picture of a finished run: dashboard, distributions and --! every retained cascade.

restart(seed: number?) → void

--!desc Restart with a specific seed, or with the current one.

argtypedescription
seednumber?

setPaused(paused: boolean) → void

--!desc Pause or resume the simulation.

argtypedescription
pausedboolean

setVisible(visible: boolean) → void

--!desc Show or hide the instrument screen.

argtypedescription
visibleboolean

step(n: number?) → void

--!desc Step the simulation `n` ticks immediately, paused or not.

argtypedescription
nnumber?

export( ) → string

--!desc Write the run out as CSV and JSON under /source/local and return --! the directory it landed in.

runBaselines(opts: any?) → any

--!desc Run the five baseline strategies headless on the current seed. --! Blocks — use the B key in play for the yielding version.

argtypedescription
optsany?

Sub-parts

Everything contained inside this part. Assets are composite children (clickable cards). Files are leaf payloads. Expand any row to view its source.

Showing up to 3 levels deep. Open a part to see what it holds further down.

23+items
▣
module · born here
❒asset
# CascadeHud — the instrument's face Builds the screen tree. Knows nothing about the simulation's rules and nothing about the engine's input: it is handed a snapshot and draws it. ```lua local Hud = require("CascadeHud") ui.updateScreen("cascade_instrument", Hud.build(ctx)) ``` ## Three things worth knowing before editing it **A canvas point is a two-element array.** `{ x, y }` means `{ 10, 20 }`, not `{ x = 10, y = 20 }`. Getting that wrong is silent: the canvas reports the command as skipped and draws a blank rectangle. Every point in the charts here is an array literal. **A canvas's `id` sits at the top level of the node**, beside `type` — not in `props` — which is how `gui.bounds` and `gui.elementCapture` address it. **A label that is not hard-wrapped widens its whole column.** An unwrapped line is as wide as its text, so one long legend pushes everything laid out beside it off the screen. `Hud.controlsHint` wraps to the pixel width it will be drawn at rather than trusting the layout engine to. ## The layout adapts The instrument is often given a short space — the editor's play pane is a wide, shallow rect. So the readout is assembled from **blocks** and filled with whole blocks while they fit: a short viewport loses its least important sections instead of clipping every one of them. It never draws half a number. ## Phase 10 — three levels of information - **A · Full Debug** — everything: density, connectivity, component statistics, cascade statistics, the distribution, production, damage, energy, intervention tallies and the strategy comparison. - **B · Player Information** — production, energy, density, recent event sizes, largest recent event. The question this mode exists to ask is whether a player needs the rest. - **C · Minimal** — production, energy, score. The point of having all three is to find out how much direct information the player actually needs, because the destination is a player who reads the world visually instead of a dashboard. ## Phase 14 — pressure without a risk meter The frame around the board warms and the background lifts as `system_pressure` rises, driven through a slow pulse. It is deliberately unquantified: there is no "disaster probability" anywhere on screen. The test is whether *this place feels dangerous* arrives before the numbers say so.
▲ 0↑ born
▣
module · born here
❒asset
# CascadeCore — the model The simulation, and nothing else. This module owns no engine API: it is pure Luau over flat arrays, so the same code steps headless (baseline strategies, batch experiments) at the speed it steps on screen. ## Shape ```lua local Core = require("CascadeCore") local sim = Core.new({ width = 256, height = 256, seed = 42, params = {} }) for _ = 1, 1000 do Core.step(sim) Core.afterTick(sim, 1 / 25) end print(Core.summary(sim).efficiency) ``` `Core.step` advances the model by one tick. `Core.afterTick` does the bookkeeping a driver wants at its own cadence — production accrual, connectivity, pressure, history samples — and takes the wall-clock seconds since the last call. A caller that only wants the physics can skip it. ## Cells `EMPTY` 0 · `OCCUPIED` 1 · `ACTIVE` 2 · `COOLDOWN` 3 · `BLOCKED` 4 The board is a flat array **padded by one `BLOCKED` ring**, so a neighbour is a plain index offset and no propagation step ever needs a bounds check. `Core.idx(sim, x, y)` maps board coordinates to that index. ## Propagating the grid `neighbor_mode` (4 or 8) fixes the neighbourhood. `propagation_mode` picks how fire crosses it: 1. **adjacency** — every occupied neighbour ignites 2. **probabilistic** — each at `spread_probability` 3. **distance** — within `spread_radius`, probability falling with distance 4. **directional** — probability weighted toward `direction_x/y`, with `direction_bias` as the floor a cell keeps against the flow Adding a model means adding a branch to `Core.rebuildNeighbors`, which fills each offset's probability once per parameter change rather than per tick. ## Performance The tick is written for the common case: one pass over the board for decay, trigger and growth, then work proportional to the *burning* cells rather than the whole grid. Two things matter and are easy to get wrong: - **The generator is unrolled into the loop.** A tick draws a number for most cells on the board, and at this VM's call costs a `bit32`-based xorshift made the simulation three times slower than the arithmetic it was producing. See `CascadeRng`. - **Thresholds are integers.** A probability is stored pre-multiplied by the generator's modulus, so the hot comparison never pays for a float divide. Measured: ~5.4 ms per tick at 256×256 on a sparse board, ~7.3 ms at the density the model settles at. ## `system_pressure` (Phase 21) A smoothed read of density, largest-component ratio and recent cascade activity. **It is observational only.** Nothing in this module reads it to decide an outcome — catastrophic events remain emergent consequences of the cellular system, and there is no branch anywhere that spawns one.
▲ 0↑ born
▣
module · born here
❒asset
# CascadeObjectives — Phase 23, as data A scenario is a row: a `setup` that reshapes the world, and a `check` that reads the run and returns progress plus a verdict. ```lua for _, o in ipairs(Objectives.LIST) do print(o.id, o.name, o.description) end ``` | id | name | what it asks | |---|---|---| | `stability` | Stability | survive 10 minutes without a cascade over 2,000 cells | | `high_output` | High Output | 1,000,000 production while total damage stays under 50,000 | | `dense_world` | Dense World | hold above 75% occupancy for 5 continuous minutes | | `minimal_intervention` | Minimal Intervention | stay stable using fewer than 20 interventions | | `recovery` | Recovery | after a catastrophic event, bring the world back to 70% | Nothing here is special-cased in the simulation. Adding an objective is adding a row; `Objectives.evaluate(sim, obj, ctx)` is the whole runner, and `setup` belongs to the moment the objective is *chosen* — run once, never on a redraw, or progress would reset under the player. Objectives are deliberately hard at the model's own criticality: the Stability scenario fails in seconds on a run nobody is tending, because cascades over 2,000 cells are exactly what a critically self-organised board produces.
▲ 0↑ born
▣
module · born here
❒asset
# CascadeStats — everything measured, and every way of getting it out The rule this module exists to enforce: **no number is hidden behind another.** The efficiency score is offered, but always beside the production and the damage it was computed from. ```lua local Stats = require("CascadeStats") Stats.snapshot(sim) -- one flat table of every live number Stats.strategyComparison(sim) -- Phase 13, both strategies side by side Stats.dashboard(sim) -- Phase 26, the whole run Stats.csvCascades(sim) -- Phase 27 Stats.csvSamples(sim) Stats.jsonRun(sim) ``` ## Export `Stats.jsonRun` writes the dashboard, every retained cascade and every periodic world sample as one JSON document; the two CSV writers write the same records row-wise. Each cascade carries `simulation_tick`, `timestamp_seconds`, `cascade_id`, `trigger_type`, `cascade_size`, `duration`, `density_at_trigger`, `largest_component_at_trigger`, `energy_at_trigger`, `pressure_at_trigger`, `peak_active`, `merges` and `classification`. Each sample carries `tick`, `density`, `largest_component`, `production`, `cumulative_damage`, `player_energy` and `pressure`. That is the artefact for analysing the mechanics outside the engine. ## Percentiles Computed over the retained cascade window (the last `history_capacity` events, 1000 by default), not over all time — so the readout answers "what does this world look like now", and the lifetime counters beside it answer "what has it ever done". Both are on screen; neither replaces the other.
▲ 0↑ born
▣
module · born here
❒asset
# CascadeAgents — Phase 25, non-human baselines Five strategies, from doing nothing to actively hunting the largest connected region. Each pays the same energy costs a player does, so the comparison is between strategies and not between budgets. | id | name | what it does | |---|---|---| | `do_nothing` | Do Nothing | never intervenes — the control condition | | `random` | Random Intervention | acts at random whenever it can afford to | | `density_threshold` | Density Threshold | fires a controlled burn once occupancy passes a threshold | | `largest_cluster` | Largest Cluster | burns the centroid of the largest connected region | | `firebreak` | Firebreak | cuts a line across any region that grows too large | ```lua local Agents = require("CascadeAgents") local rows = Agents.runAll({ seed = 20250922, width = 128, height = 128, ticks = 700 }) print(Agents.formatComparison(rows)) ``` ## Headless These run through `Core.runHeadless` — no rendering, no UI, no frames — so a strategy can be measured far faster than it can be watched. `Agents.makeRunner(id, opts)` returns an incremental runner (`stepChunk(n)`, `progress()`, `finish()`), and `Agents.runAllChunked` drives one for every strategy while yielding, which is what the instrument's `B` key uses. A long baseline run therefore happens *behind* a board that keeps moving. `Agents.largestRegionInfo(sim)` gives a region's centroid and bounding box; it costs one pass over the board, so it is asked for on the agent's decision cadence and never per tick.
▲ 0↑ born
backing path · CascadeSim.component

Problems

Everything affecting this asset right now: its own problems, anything wrong inside it, and problems on its direct dependencies.

0problems
No problems reported on this asset or on the parts this page shows. Parts it leaves out are not counted here.
⌬ZeroMind agent review · awaiting first pass
Findings
Reviewer findings (handle · model · tag · quoted note) appear here once the per-pass review log lands. Today only the rolled-up agent_score is exposed.
usability—
did it work as advertised
quality—
authoring polish + cohesion
performance—
frame & memory budget held
agent review score
—
/ 100
awaiting first pass
usability × 0.40
+ quality × 0.35
+ performance × 0.25
± compat factor

Usability ratings

Did the part work as advertised when consumers tried to drop it in. Separate from upvotes: those are taste; this is "did it function".

—%no reports yet
Sign in to report whether this part worked for you.
Discussion

Scoped to this part · feeds back into the world's score.

0comments
Sign in to post.sign in
No comments yet. Be the first.