Log inGet started
▣
module · drop-in viewer
asset⌬ modulemoduleprimary: init.luau·originates fromworld ce9074db-0…

Sim

`game.rules.Sim` is the whole run: the day cycle, the plot state machine, the zyme's well-up and spread, leavening, the beam's burn, the seed economy and the win/lose conditions. It is the **only writer of `FL.state`**. Every other slice reads that table and reacts to `FL.events`…

byclaude-code @ aura·posted 1mo ago
What it does

Sim — the simulation of FIRST LIGHT: RESOWN

game.rules.Sim is the whole run: the day cycle, the plot state machine, the zyme's well-up and spread, leavening, the beam's burn, the seed economy and the win/lose conditions. It is the only writer of FL.state. Every other slice reads that table and reacts to FL.events.

Nothing here touches an entity, a material or a screen. Rules.component (mounted on the game anchor) hands it the frame's elapsed time; the rest of the game calls the functions below.

local Sim = require("game.rules.Sim")
local FL  = require("game.FL")

Sim.start()                                    -- a fresh run, the first night
Sim.beamAt(x, z, dt, Sim.beamPowerFor(FL.state.phase, FL.state.phaseT))
local did = Sim.act(col, row)                  -- "sowed" | "harvested" | nil

The API

callwhat it does
Sim.start()Throws the hillside away and lays a fresh one out. Emits start, screen, phase, shrineThreat, and one plotChanged per plot.
Sim.restart()The same thing, reachable from paused / won / lost.
Sim.pause() / Sim.resume()Freeze and unfreeze. Only playing pauses; only paused resumes.
Sim.setScreen(screen)"title" "howto" "playing" "paused" "won" "lost". Emits screen. An unknown name is refused with a log line.
Sim.act(col, row) -> string?Sows bare or scorched soil for a seed; lifts a ripe crop for seed. Returns "sowed", "harvested", or nil.
Sim.beamAt(x, z, dt, power) -> booleanThe beam lands at a world point for dt at power (0..1). Returns true on a frame that took bloom off.
Sim.tick(dt)Advances the run. Rules.component calls it once a frame.
Sim.beamPowerFor(phase, t) -> numberThe plate's power for a phase, 0..1. The mapping every caller should use.
Sim.lastActReason() -> string?Why the last act did nothing: "no-seed", "growing", "zyme", "nothing-here", "not-playing". Also mirrored onto FL.state.lastActReason.
Sim.beamHit() -> col, row, ageWhere the beam last took bloom off, and how many seconds ago.
Sim.isRunning() -> booleanTrue while the clock runs.
Sim.phaseProgress() -> number0..1 through the current phase.
Sim.announce()Re-states the run as it stands — screen, phase, threat and every plot — without changing any of it. A slice that has just mounted, hot-reloaded or come through an edit/play flip calls nothing; Rules.component calls this in start() so it catches up.
Sim.setPhase(phase, dayIndex?)Put the clock at the start of a phase, hillside untouched. For a staged beat or a test that does not want to wait.
Sim.summary() -> stringOne line of the run, for a log or a test.

Two things a caller has to get right

Power is the aim factor, not the sun. beamAt multiplies the power you pass by the phase's own gate (PHASE_BEAM), so a beam at night burns nothing no matter what you pass, and a day beam is a quarter as fast as a first-light one. Pass Sim.beamPowerFor(phase, phaseT) — or your own 0..1 alignment factor on top of it. Do not pass FL.sunHeightFor directly: that function answers where the sun is (−1..1, and only ~0.3 at the top of first light), not how hard the plate burns, and handing it over makes the beam roughly three times too weak.

tick sub-steps. It advances in slices of 0.1 s, so Sim.tick(30) in one call produces the same run thirty seconds of frames would — a test can reach a later dawn without waiting for it, and a frame hitch cannot skip a well-up.

The day

night 40 → firstlight 30 → day 40 → dusk 10, from FL.PHASE_LEN. dayIndex rises as first light ends, which is what a dawn survived means; reaching FL.DAWNS_TO_WIN (5) there ends the run with won, at 550 s of a clean run.

phasethe hillthe beam (PHASE_BEAM)
nightthe valley pushes bloom onto the lowest terrace it can reach, mature blooms spread, crops beside a bloom leaven0
firstlightnothing new comes up; the window1 — a full bloom hisses off in BURN_SECONDS (2 s)
daycrops grow0.25
duskblooms spread again, crops still grow0.25 fading to 0

The plot state machine

bare ──sow(1 seed)──▶ sown ──grow──▶ crop ──grow──▶ ripe ──harvest──▶ bare  (+2 seed, +4 leavened)
any ──bloom──▶ zyme ──beam──▶ scorched ──sow(1 seed)──▶ sown
  • A plot turning to zyme loses whatever grew on it — that is the cost of letting a terrace go.
  • scorched soil is where the bloom spreads fastest (SPREAD_SECONDS_SCORCHED, half the normal interval), so burning ground buys the window and sells the next night.
  • Growth runs while the sun is up (first light, day, dusk) at GROW_SECONDS (55 s) from sowing to ripe: sow in a day and lift it in the next.
  • Leavening marks sown, crop and ripe plots that sit beside a bloom through a night. The design's table names crop/ripe; sown is included here so a plot deliberately sown next to a bloom is a real gamble from the first dusk. The double yield only ever pays at ripe, so nothing else changes.

The shrine

FL.state.shrineThreat climbs by the share of row 1 carrying bloom, and a row 1 fully held for THREAT_SECONDS (40 s — one night) is 1.0 and a loss. Clearing row 1 walks it back over RELIEF_SECONDS (25 s). The shrineThreat event fires when the value has moved THREAT_STEP (0.02), and always at the ends, so a HUD meter can follow it without a per-frame event.

The opening hillside

M.OPENING dresses rows 1..6 at start(): crop, ripe, crop, sown, bare, bare, uphill first. The hill reads as a farm from the first frame, the ripe row teaches harvest before the first night ends, and the bare lower terraces are where the zyme arrives. Seeds start at 6 (FL.resetState).

Determinism

The well-up and the spread draw from a small LCG seeded to a fixed value in start(), so two runs of the same inputs produce the same hillside. A reproduction is a reproduction.

Measured

  • A full-grown bloom under a full-power first-light beam: 2.02 s.
  • One untouched night leaves 9 blooms on the lower terraces.
  • A player who never acts loses at 224 s, in the day of the second dawn.
  • A player who burns the nearest bloom each first light and works the soil each day wins at 550.4 s with 83 harvests.

Interface

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

conforms to

zero/source-extract/v2

strict

phaseLen(phase: string) → number

argtypedescription
phasestring

phaseBeam(phase: string) → number

argtypedescription
phasestring

rngSeed(s: number) → void

A small deterministic generator so a run replays the same way twice — the fix phase can reproduce a hillside instead of chasing a fresh one.

argtypedescription
snumber

rnd( ) → number

plotAt(col: number, row: number) → Plot

argtypedescription
colnumber
rownumber

changed(plot: Plot) → void

argtypedescription
plotPlot

neighbours(col: number, row: number) → void

argtypedescription
colnumber
rownumber

infect(plot: Plot, level: number) → void

Turn a plot into bloom. Anything growing on it is lost, which is the whole cost of letting the zyme reach a terrace.

argtypedescription
plotPlot
levelnumber

beamPowerFor(phase: string, t: number) → number

The public surface The plate's power for a phase and the time into it, 0..1 — the canonical mapping every caller should use, so the beam, the HUD and the sound all agree on how strong the light is. The facer slice may multiply its own aim factor in on top; `beamAt` clamps whatever arrives to 0..1 and then applies the phase gate again, so a beam at night never burns.

argtypedescription
phasestring
tnumber

lastActReason( ) → string

Why the last `act` did nothing, when it returned nil: "no-seed", "growing", "zyme", "nothing-here", or "not-playing".

beamHit( ) → void

Where the beam last landed on a bloom, and how many seconds ago. The zyme and facer slices use it to put steam and a hot spot at the right plot.

isRunning( ) → boolean

True while the clock is running: a run is in progress and not paused.

phaseProgress( ) → number

0..1 through the current phase.

setReason(reason: string?) → void

argtypedescription
reasonstring?

setScreen(screen: string) → void

Move to a named screen and tell everyone. The only writer of `FL.state.screen`.

argtypedescription
screenstring

setPhase(phase: string, resetT: boolean) → void

argtypedescription
phasestring
resetTboolean

finish(outcome: string) → void

argtypedescription
outcomestring

dressOpening( ) → void

Lay the opening hillside out and announce every plot, so the world and zyme slices can build their visuals from one pass of `plotChanged`.

announce( ) → void

Re-state the run as it stands, without changing any of it: the screen, the phase, the shrine's threat and every plot. A slice that has just mounted, hot-reloaded or come through an edit/play flip has no history to read, so this is how it catches up — every listener sees the same events it would have seen had it been there from the start.

start( ) → void

Begin a run: a fresh hillside, the first night, the clock running.

pause( ) → void

Freeze the run. Only meaningful while playing.

resume( ) → void

Unfreeze a paused run.

restart( ) → void

Throw the run away and begin another. Reachable from paused, won or lost.

act(col: number, row: number) → string

Work the plot the Facer is facing: sow bare or scorched soil for a seed, lift a ripe crop for two seeds — four if the zyme leavened it. Returns "sowed", "harvested", or nil with a reason in `Sim.lastActReason()`.

argtypedescription
colnumber
rownumber

beamAt(x: number, z: number, dt: number, power: number) → boolean

The beam lands at this world point for `dt` seconds at `power` (0..1, the facer's own aim factor). Burns whatever bloom is there; when the bloom is gone the soil is scorched and `burn` fires. Returns true on a frame that actually took bloom off, so the facer can put a hot spot there.

argtypedescription
xnumber
znumber
dtnumber
powernumber

growCrops(dt: number, lit: boolean) → void

The clock

argtypedescription
dtnumber
litboolean

wellUp( ) → void

spreadBloom(dt: number) → void

argtypedescription
dtnumber

leaven( ) → void

riseZyme(dt: number, dark: boolean) → void

argtypedescription
dtnumber
darkboolean

threat(dt: number) → void

argtypedescription
dtnumber

advancePhase( ) → void

step(dt: number) → void

argtypedescription
dtnumber

tick(dt: number) → void

Advance the run. `Rules.component` calls this once a frame; a test can call it with any dt and get the same run, because it sub-steps.

argtypedescription
dtnumber

setPhase(phase: string, dayIndex: number?) → void

Put the clock at the start of a named phase, keeping the hillside as it is. The staging slice uses it to reach a beat; a test uses it to reach a phase without waiting out the ones before it.

argtypedescription
phasestring
dayIndexnumber?

summary( ) → string

A one-line reading of the run, for a log line or a test.

Sub-parts

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

7items
▣
module · born here
❒asset
# FL The shared constants, geometry and contracts of *First Light: Resown*. Every slice of the game requires this and nothing else of each other. - **Hillside geometry** — `COLS x ROWS` plots on terraces that run down the hill along +Z; `plotCenter`, `pathCenter`, `rowZ`, `groundY`, `floorAt`, `rowAtZ`, `colAtX`, `shrineCenter`. - **The day** — `PHASES`, `PHASE_LEN`, `CYCLE`, `DAWNS_TO_WIN`, `sunHeightFor(phase, t)`. - **Shared state** — `FL.state` (screen, phase, plots, seeds, score, shrine threat, sun height); the rules slice writes it, everyone reads it. - **Events** — `FL.events.on / emit / clear`, with the event names every slice agrees on listed in the source. - **The look** — `PALETTE`, and `sz(fraction)` / `isPortrait()` for UI sizes proportional to the live screen. Consumed by every component and module under `/zero/source/game/`.
▲ 0↑ born
backing path · game/rules/Sim.module

Problems

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

0problems
No problems reported. This asset, its contents, and its direct deps are clean as of the latest commit.
⌬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.