bloom
`require("bloom")` · one of the three slices booted by `GV.bootSlices()`.
bloom.module — SLICE A: the rules and the loop
require("bloom") · one of the three slices booted by GV.bootSlices().
roam → gather 3 Gumdrop Motes → reach a Blossom Well → release → a region blooms
BLOOM is the only writer of GV.state and the only emitter on the GV
event bus. VALE and CONFECTION subscribe; neither imports this module, and
neither has to exist for it to run.
What it does
- A mote field.
TUNING.MOTE_FIELD_COUNT(14) gumdrops hoverMOTE_HOVER_HEIGHTover the terrain, bobbing and breathing. Walking withinMOTE_PICKUP_RADIUScollects one: it despawns, the charge goes up, and a replacement drifts in at least 12 m away so the field never thins. - A bubble that charges. Three motes fill it. A full bubble collects nothing — you carry three, spend them, then gather again, so a mote is never wasted by walking over it while charged.
- Wells that bloom. Release is automatic on proximity: carrying a full
bubble within
WELL_RELEASE_RADIUS(4 m) of an un-bloomed well blooms it. There is no button, so CONFECTION prompts with a direction, not a key. - A Hush that closes in.
hushRemainingcounts down in real time while playing; every bloom pushes it backHUSH_BLOOM_REPRIEVE(45 s). - A win and a lose. Five bloomed →
won. Clock to zero →lost. Once the phase leavesplayingthe rules stop: motes keep bobbing, nothing collects.
GV.state.nearestWell is kept on the closest un-bloomed well every frame, so
CONFECTION can draw a compass without repeating the search.
Events emitted
| Event | Payload | When |
|---|---|---|
moteCollected | (charge, needed) | walked through a mote |
bubbleCharged | () | charge just reached full |
bloomStarted | (wellId, x, y, z, radius) | released on a well — the wave starts now |
bloomFinished | (wellId, bloomCount, totalWells) | BLOOM_WAVE_SECONDS later |
hushTick | (remaining, fraction01) | ~1 Hz, and immediately whenever the clock jumps |
phaseChanged | (phase, previous) | title → playing → won | lost |
gameWon | (secondsLeft) | on the fifth bloom |
gameLost | () | when the Hush reaches zero |
fraction01 is measured against the full drain time and clamped to 1, so a
well-fed run reads as a full meter rather than an overflowing one.
The surface require("bloom") gives you
M.start() fresh run — state cleared, field re-scattered, phase back to title
M.stop() despawn everything, retire every driver
M.adopt() take over a run in progress without resetting it
M.restart() alias for start()
M.forceBloom(wellId) bloom a well through the real release path
M.setHushRemaining(sec) move the clock
M.report() phase / charge / blooms / hush / nearestWell / motes / player / wells
M.motes, M.wells, M.hush the internals, for anything the report does not cover
forceBloom and setHushRemaining run the same code the driver runs — there is
no second implementation behind either.
Two engine facts this file is built around
A sibling file inside a composite asset has no working require form.
./motes.luau and .motes both anchor at the world root; the absolute VFS path
does resolve but registers the file a second time under a second identity,
after which every hot reload of this module fails with
persist.origin.__init: table index is nil and the engine silently keeps
running the previous chunk. So the slice is one file.
A hot reload re-runs this chunk in place. Every module-level local resets,
while the entities it spawned and the task.loop it started keep going — so a
naive slice leaks a driver and orphans its whole mote field on every reload, and
a world three agents are writing to reloads constantly. The answer throughout is
that entities and GV.state outlive chunks:
- the driver's identity is a generation on
GV.state.bloomGeneration, with a marker entitygv_bloom_drivercarrying the one bit that has to survive agv.modulereload — whether a run exists at all. Arming a driver claims the next generation; every older driver reads it, sees it is not its own, and cancels itself, so a driver whose handle died with its chunk still retires. The number lives in a table field, not an entity attribute, because an attribute write is applied a frame later — two chunks arming in the same frame would both read the pre-write value and both claim the same number. - a mote's hover height and bob phase live in its own attributes, so a reloaded chunk rebuilds the field from the motes already standing rather than sweeping and re-scattering them.
- the clock reads
getTime()instead of integrating adt, so it cannot be accelerated by being ticked twice in one frame.
Loading this file while the marker exists therefore adopts the run in progress: charge, blooms and the Hush all survive a reload.
Multiplayer
Motes are local, temporary entities (entity.spawn, not spawnSynced), so each
client grows its own field and charges its own bubble; nothing here replicates
and nothing here is written into scene.json. The player is resolved fresh
every frame as layers.active.players.localPlayer.avatar and never stashed.
Known rough edge
The 2.5 s title dwell does not hold reliably: the phase reaches playing within
about 0.3–0.5 s of a restart. The dwell condition itself is never observed to
fire, so the flip is coming from somewhere this file has not been able to
isolate under a world being hot-reloaded several times a minute. It is cosmetic
— title is still a real phase that phaseChanged announces, and CONFECTION's
card is free to hold itself for as long as it likes.
Scoped to this part · feeds back into the world's score.