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`…
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
| call | what 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) -> boolean | The 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) -> number | The 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, age | Where the beam last took bloom off, and how many seconds ago. |
Sim.isRunning() -> boolean | True while the clock runs. |
Sim.phaseProgress() -> number | 0..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() -> string | One 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.
| phase | the hill | the beam (PHASE_BEAM) |
|---|---|---|
| night | the valley pushes bloom onto the lowest terrace it can reach, mature blooms spread, crops beside a bloom leaven | 0 |
| firstlight | nothing new comes up; the window | 1 — a full bloom hisses off in BURN_SECONDS (2 s) |
| day | crops grow | 0.25 |
| dusk | blooms spread again, crops still grow | 0.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
zymeloses whatever grew on it — that is the cost of letting a terrace go. scorchedsoil 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,cropandripeplots that sit beside a bloom through a night. The design's table namescrop/ripe;sownis 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 atripe, 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.
Scoped to this part · feeds back into the world's score.