DRIFTLANDS co-op survival on drifting tides — studio progress

Tide Scheduler

Status: Designed — independent review applied
Author: Jeroen + agent panel
Last Updated: 2026-08-26
Independent /design-review: APPROVED WITH CONDITIONS 2026-08-26 — all findings applied same day (H2 recurrence bound restated honestly; B1 signed index ranges; M2 epochIndex diff keys; H1 completion-tick sampling; B2 held-violet ruling; H3/M1/M3/M4 precision; L1–L5 incl. salt constants + RNG pin)
Creative Director Review (CD-GDD-ALIGN): APPROVED 2026-08-26
Implements Pillar: Pillar 1 — The Tide Waits for No One
Spike Reference: prototypes/tide-scheduler-spike-2026-08-25/SPIKE-NOTE.md (contract validated 9/9)

Overview

The Tide Scheduler is the Foundation system that makes Driftlands' world move. It is a pure function — DockedLands(seed, wallClockTime) — that determines, for any moment in real time, which landmasses are docked at the home island's shore sockets, which are approaching, and which are departing. It stores no schedule state: the world's past, present, and future are all computable from the world seed and a clock, which is what lets the game run with no server and lets nothing happen while everyone is offline except time passing. Every other MVP system hangs off it — docked lands are where harvesting, combat, and hauling happen; departure is the deadline that gives them stakes; recurrence is the promise that makes missing a land a story instead of a loss.

Players never interact with the scheduler directly. They experience it as the tide: lands arriving on a learnable rhythm, the shore warming amber before a docking and cooling violet before a departure, and the between-sessions question that drives the group's planning — "what's docking tomorrow?" This GDD defines the tide's cadence and recurrence as rules (per the Creative Director's gate condition: recurrence is Pillar 1's only legal mitigation and cannot stay vague), on top of the deterministic contract validated by the scheduler spike (prototypes/tide-scheduler-spike-2026-08-25/SPIKE-NOTE.md, 9/9 contract tests).

Player Fantasy

The tide is infrastructure the player never touches — the fantasy is **living with it, not controlling it**. Its emotional promise is earned literacy: the quiet competence of someone who reads a system the world treats as chaos. The anchor moment: it's Wednesday night, someone posts a screenshot of the horizon — amber bleeding into the cloudbank at the northeast socket — and types *"tin highlands, tomorrow, before work."* No wiki, no menu. They know, the way a fisherman knows weather from cloud shape, because they've watched that color twenty times and it always meant the same thing next. This is the game's #1 aesthetic (Discovery) applied to the schedule itself, and it is a fantasy that fires every session — most tide moments are one player, logging in alone, glancing at the color of the sky.

Its necessary shadow is legible consequence. Someone logs in Saturday morning and the tin highlands are gone — cooled to violet and departed in the night while everyone slept. The beat of "damn, we missed it" resolves into "it'll come back," and the story told in group chat is fond, not bitter — sailors recounting the storm that scared them, not the one that ruined them. Because players are literate readers of the tide, a missed land is a legible failure ("we knew Thursday was coming and we still missed it"), never an opaque one — which is what keeps Pillar 1's indifference feeling like a living world instead of a punishment. The target emotional arc: **competence → occasional humbling → recurrence restores trust → competence again.**

**Tone constraints this fantasy imposes (binding on all player-facing text and UI):** never punitive language — no "YOU MISSED IT" banners, no loss-framed system messages; departures are stated as world facts, not player failures. And a design test for future tide features: if a proposal optimizes the docking event for spectacle players are expected to gather for (fanfares, forced camera pans, presence bonuses), it serves the wrong fantasy — the communal shore-watch vigil is a key-art and trailer beat, never a mechanic. Rewarding synchronous presence punishes exactly the uneven schedules this game is built for.

Reference anchors: Lineage 2's world-that-moves-without-you (competence over time, not spectacle in the moment); harbor towns — things arriving and leaving on schedules older than you.

Detailed Design

Core Rules

States and Transitions

Mechanical states per socket — exactly two; presentation states are derived labels:

State Interval Enterable Harvestable Visible
Absent[φ+W, φ+C)NoNoNo (empty water; cosmetic drift-in during final PreloadLeadTime)
Docked[φ, φ+W)YesYesYes

Presentation sub-states (render-only, derived from progress p = (t−φ)/W; curve ownership: art bible §4.3):

Label Window Meaning
Arriving (cosmetic)PreloadLeadTime before φLand offshore, drifting in; not enterable
Amber ramp → holdarrival window, then held near-peakWarm tint per §4.3
Violet rampfinal 40% of the drift-out window before φ+WCooling tint per §4.3
Drift-out (cosmetic)EvictionGrace after φ+WNon-interactable land visually departing; tint held at full violet (F7, B2 ruling)

Transitions fire host-side via NextBoundary(seed, t) (compute the next transition instant, schedule one wake-up — no polling); ±5 s event tolerance is acceptable; suspend/wake recovers by fresh evaluation.

Interactions with Other Systems

System Consumes from Tide Scheduler Provides back Interface owner
Save & PersistencecycleIndex(socket, t) + current epochIndex for diff keys; validity predicate for reconcile-on-loadStored diffs (epochIndex, socket, cycleIndex, nodeId) — epoch-qualified per review M2 (Edge Case 14)Scheduler owns cycleIndex/epochIndex derivation + reconcile predicate; Persistence owns storage format
Land Dock/UndockDocked-edge transitions (instantiate/teardown triggers); templateForDocking (deck output); variation seed (seed, socket, cycleIndex); PreloadLeadTime advance noticeLifecycle events other systems subscribe toScheduler owns transition detection + selection; Dock/Undock owns spawn/despawn execution and seed→layout translation
HarvestingCurrent cycleIndex (never recomputed independently); Docked gate on harvest legality — re-sampled at the completion tick of every harvest action, never cached from initiation (review H1): multi-tick actions crossing φ+W fail; the scheduler does not interrupt in-flight actions — the consumer checksDiffs written under scheduler-reported keysScheduler exclusively owns cycle math
CombatDocked gate re-sampled at action resolution (same H1 rule); soft-close event — combat discards all in-flight combat state per combat GDD Rule 13 (departure never converts to a death)—Scheduler owns the clock; Combat owns combat-state consequences. No other coupling (Enemy AI scaling routes via Land Dock/Undock)
Tide-Tint RenderPre-computed tint floats per land (_TideAmount-ready) — never raw t/φ—Scheduler owns ramp progress; render owns color mapping (§4.3)
UI/HUD (compass glint)Same derived floats + Docked booleans per socket—UI owns visualization/range culling
Enemy AINothing — deliberate non-dependency (spawn scaling routes through Land Dock/Undock)——

Implementation notes flagged → ADR (not GDD scope): TideConfig as an explicit function parameter sourced from data (enables save-compat versioning of tuning changes); NGO time-replication mechanics; stable template ID space (never hash % poolSize).

Formulas

All schedule math uses System.Math with long (integer UTC seconds) and double (intensities only). Banned in schedule math: System.Random, GetHashCode(), UnityEngine.Mathf, float, transcendental functions. FloorDiv/FloorMod are explicit floor-division (C#'s % truncates toward zero and is wrong for negative operands). Any change to the hash algorithm or its constants is a save-breaking change and must be versioned.

F0. StableHash64 — canonical hash (foundation for all others)

StableHash64(seed, parts...) = SplitMix64Combine(seed, parts)

Pinned to the spike's exact algorithm (prototypes/tide-scheduler-spike-2026-08-25/Program.cs):

h = seed XOR 0x9E3779B97F4A7C15
for each part p in parts (order-significant):
    h = h XOR (ulong(p) + 0x9E3779B97F4A7C15 + (h << 6) + (h >> 2))
    h = h * 0xBF58476D1CE4E5B9
    h = h XOR (h >> 31)
return h

Variables:

Variable Symbol Type Range Description
World seedseedulong[0, 2^64)Fixed at world creation, never changes
Hash inputspartslong[]anyOrder-significant; callers must always pass ≥1 part
Resulthulong[0, 2^64)Uniform (splitmix64 avalanche)

Output Range: full ulong, uniform. Callers never pass empty parts (would degenerate to seed XOR constant and let two call sites collide). Example: StableHash64(123456789, [2, 5]) → a fixed 64-bit value, identical on every platform, session, and runtime.

Salt constants (review L1, locked 2026-08-26): the string salts written in F1/F4/F5 are notational aliases for fixed long constants passed as ordinary parts: SALT_SOCKET_OFFSET = 1, SALT_DECK = 2, SALT_RESEAM = 3, SALT_SEAM_OVERLAP = 4, SALT_VARIATION = 5. Salts are never derived from strings at runtime — string encoding is unspecified across runtimes and any change would be save-breaking.

F1. socketPhaseOffset

φ(s) = slotOf(seed, s, N) · (C / N)

slots    = [0 .. N-1]
shuffled = SeededFisherYates(slots, StableHash64(seed, ["socket_offset_assignment"]))
φ(s)     = shuffled[s] * (C / N)

Variables:

Variable Symbol Type Range Description
Socket indexsint[0, N)Durable socket ID → index mapping owned outside this formula
Socket countNint1–8 (MVP 1, Tier 1 3–4)Must evenly divide C (see Tuning Knobs)
Cycle lengthClong (s)259,200Shared by all sockets
Resultφ(s)long (s)[0, C)Exact multiple of C/N

Output Range: [0, C); all offsets distinct (permutation), so no two sockets are ever in phase and simultaneous dock-starts are structurally impossible. Example (Tier 1, N=4): C/N = 64,800. If shuffled = [2,0,3,1]: φ = [129,600, 0, 194,400, 64,800]. MVP (N=1): trivially φ = 0. Constraint: N must divide C evenly — 259,200 = 2⁷·3⁴·5² divides cleanly by 1–6 and 8, never use N=7 without re-tuning C.

F2. dockedState, cycleIndex, progress

dockedState(seed, s, t, config) = FloorMod(t − φ(s), C) < W cycleIndex(s, t, config) = FloorDiv(t − φ(s), C) progress(s, t, config) = FloorMod(t − φ(s), C) / W — defined only while docked

Variables:

Variable Symbol Type Range Description
World timetlong (s, UTC)≥ 0Host-authoritative, monotonic-clamped
Phase offsetφ(s)long (s)[0, C)From F1
Dock windowWlong (s)158,400—
Cycle lengthClong (s)259,200—
Docked flagdockedStatebool—Land present and enterable now
Cycle indexcycleIndexlongany — negative before the first cycle boundary (Edge Case 7); monotonic non-decreasingDiff-key component; never resets within an epoch (Edge Case 14)
Progressprogressdouble[0, 1)0 = just docked; → 1 = about to undock

Output Range: dockedState clean boolean; cycleIndex monotonically non-decreasing (monotonic clamp guarantees this). API rule: return (bool docked, double progress) as a pair so no consumer reads progress for an Absent socket. Example: φ=0, t=600,000: 600,000 mod 259,200 = 81,600 < 158,400 → docked, cycleIndex = 2, progress = 81,600/158,400 ≈ 0.515.

F3. Global docking sequence — dockingIndex

dockingIndex(s, cycleIndex_s) = cycleIndex_s · N + slotOf(seed, s, N)

Because offsets are exact multiples of C/N, dock-starts occur globally at every multiple of slotWidth = C/N — one docking every C/N seconds, forever, ties structurally impossible. Inverse direction:

globalSlot(t)        = FloorDiv(t, slotWidth)
socketForSlot(g)     = inverse(shuffled)[FloorMod(g, N)]
cycleIndexForSlot(g) = FloorDiv(g, N)
dockingIndex         = globalSlot

Variables:

Variable Symbol Type Range Description
Per-socket cycle indexcycleIndex_slongany (Edge Case 7)From F2
Slot widthslotWidthlong (s)C/NTime between consecutive global dock-starts
ResultdockingIndexlongany — may be negative in the first C after world-gen (Edge Case 7); strictly increasingChronological ordinal of this docking across ALL sockets — the index into the global deck

Output Range: one integer per C/N-second interval, unbounded (long). Defensive tiebreak (unreachable by construction, stated for future config changes): if two dock-starts ever coincide, lower socket index wins. Example (N=4, shuffled=[2,0,3,1]): global order = socket 1, 3, 0, 2, repeating each cycle. dockingIndex 0 → socket 1 cycle 0; dockingIndex 4 → socket 1 cycle 1.

F4. templateForDocking — the global deck

templateForDocking(dockingIndex) = deck_r[FloorMod(dockingIndex, deckSize)] where r = FloorDiv(dockingIndex, deckSize) and deck_r = ApplySeamRules(SeededFisherYates(pool, StableHash64(seed, [r, "deck"])), r)

Seeded Fisher–Yates — RNG pinned per review L2 (locked 2026-08-26): each draw is an independent hash, `j = FloorMod(StableHash64(seed, [SALT_DECK, r, i]), i + 1). The negligible modulo bias at deckSize ≤ 8` is explicitly accepted. Never System.Random, never a stateful stream:

deck = copy(pool)
for i from deckSize-1 down to 1:
    j = FloorMod(StableHash64(seed, [SALT_DECK, r, i]), i + 1)   // inclusive
    swap(deck[i], deck[j])

Negative rounds (review B1): r and dockingIndex may be negative during the first C after world-gen; all deck math uses FloorDiv/FloorMod and is defined for negative inputs. Seam rules apply identically across the r = −1 → 0 boundary (AC-28b).

Seam rule (a) — no consecutive repeat:

if deck_{r+1}[0] == deck_r[deckSize-1]:
    swapIndex = 1 + (StableHash64(seed, [r+1, "reseam"]) mod (deckSize-1))
    swap(deck_{r+1}[0], deck_{r+1}[swapIndex])

Seam rule (b) — no simultaneous duplicates across the round seam, over the overlap window M = FloorDiv(W·N, C) (max concurrently-live dockings drawn from adjacent rounds). Deterministic and order-independent:

tail = last M cards of deck_r ; head = first M cards of deck_{r+1}
for each conflict (tail[i] == head[j]) in canonical order (j asc, then i asc):
    if still conflicting:
        candidates  = indices k ≥ M where deck_{r+1}[k] ∉ tail
        chosenIndex = candidates[StableHash64(seed, [r+1, "seamoverlap", j]) mod |candidates|]
        swap(deck_{r+1}[j], deck_{r+1}[chosenIndex])

Swap targets come from outside the overlap window, so resolving one conflict can never create another within this seam — the pass terminates without retry loops. Precision (review H3, locked 2026-08-26): candidates is recomputed at each iteration against the current mutated deck_{r+1} — a card is never swapped into position twice. Non-emptiness is guaranteed, not assumed: at most M cards of deck_{r+1} positions k ≥ M can appear in tail, so |candidates| ≥ deckSize − 2M ≥ 1 by the strict deckSize > 2M guard (AC-14b asserts this at every iteration). A swap may place the moved card in round r+1's own tail window — that is legal; the next seam's pass handles it under the same guarantee.

Variables:

Variable Symbol Type Range Description
Template poolpoolTemplateId[]MVP 2–3, Tier 1 6–8Stable ID space — never hash % poolSize
Deck sizedeckSizeint`pool`Must satisfy deckSize > 2M (guard)
Round indexrlongany — negative during the first C (Edge Case 7)Which shuffle of the deck
Overlap windowMintFloorDiv(W·N, C)MVP: 0 (no-op); Tier 1 (N=4): 2
ResulttemplateForDockingTemplateId∈ poolThe land for this docking

Output Range: always a valid pool member; every template exactly once per round; never the same land twice in a row; never two copies simultaneously docked. Example (Tier 1): M = FloorDiv(158,400·4, 259,200) = 2 — the last 2 cards of round r and first 2 of round r+1 must be disjoint. At MVP M = 0: the check degenerates safely, no special case needed. Guard: deckSize > 2M must hold or seam resolution could run out of candidates; true for all locked configs, stated in Tuning Knobs.

F5. variationSeed

variationSeed(seed, s, cycleIndex) = StableHash64(seed, [s, cycleIndex, "variation"])

Variables:

Variable Symbol Type Range Description
Socketsint[0, N)Per-socket — variation is "this land, this visit"
Cycle indexcycleIndexlongany (Edge Case 7)From F2 (NOT the global dockingIndex)
ResultvariationSeedulong[0, 2^64)Handed opaquely to Land Dock/Undock

Output Range: full ulong, uniform. Distinctly salted from template selection — changing variation logic can never perturb which template docks, and vice versa (stated as a rule, not an accident). The scheduler's contract ends at handing over the seed; layout interpretation is owned downstream. Example: socket 1, cycle 2 → StableHash64(seed, [1, 2, "variation"]).

F6. NextBoundary

NextBoundary(seed, t) = min(nextStart(t), nextEnd(t))

nextStart(t) = (FloorDiv(t, slotWidth) + 1) · slotWidth
nextEnd(t)   = min over sockets s of:
                 e = φ(s) + FloorDiv(t − φ(s), C)·C + W
                 if e ≤ t: e += C

Variables:

Variable Symbol Type Range Description
Query instanttlong (s)≥ 0—
ResultNextBoundarylong (s, UTC)(t, t + C]Soonest instant any socket's state flips

Output Range: strictly > t, bounded by t + C (every socket docks and undocks once per cycle). Drives the schedule-one-wake-up evaluation pattern — no polling. PreloadLeadTime never appears here: preload consumers subtract it downstream (NextBoundary(t) − PreloadLeadTime), keeping the Absent|Docked mechanical contract closed. Example: socket 1 (φ=0) docked at t=600,000 → its nextEnd = 676,800; if the global nextStart = 648,000 (next multiple of 64,800), then NextBoundary = 648,000.

F7. Tint ramp inputs — amberIntensity, violetIntensity

Sub-windows of progress p ∈ [0,1) (new tuning knobs; art bible §4.3 owns the color mapping — the scheduler owns only these floats):

arrivalWindowFrac = 0.15 (first ~6.6 h of W) · driftOutWindowFrac = 0.25 (last 11 h of W — raised from 0.20 per review M4 so visible violet 0.4·frac·W = 4.4 h meets the ≥4 h evening-session guarantee)

// Amber: ramps in over first 60% of the arrival window, holds to its end, then neutral
if p < arrivalWindowFrac:
    amberIntensity = Clamp01((p / arrivalWindowFrac) / 0.6)
else: amberIntensity = 0

// Violet: neutral until drift-out begins; ramps 0→1 over the final 40% of the drift-out window
if p ≥ 1 − driftOutWindowFrac:
    localP = (p − (1 − driftOutWindowFrac)) / driftOutWindowFrac
    violetIntensity = Clamp01((localP − 0.6) / 0.4)
else: violetIntensity = 0

Variables:

Variable Symbol Type Range Description
Progresspdouble[0, 1)From F2; only evaluated while docked
Arrival windowarrivalWindowFracdouble (knob)0.15 (test 0.10–0.25)Fraction of W with any amber
Drift-out windowdriftOutWindowFracdouble (knob)0.25 (test 0.15–0.30)Fraction of W with any violet; visible violet = 0.4·frac·W must stay ≥ 4 h
AmberamberIntensitydouble[0, 1]Shader-ready (_TideAmount amber)
VioletvioletIntensitydouble[0, 1]Shader-ready (_TideAmount violet)

Output Range: both clamped [0,1]; ~60% of every docking is tint-neutral (the fantasy's "normal sky" majority). Violet peaks exactly at undock. Absent sockets are never evaluated — with exactly one exception (review B2, locked 2026-08-26): **during EvictionGrace the render layer receives violetIntensity = 1.0 held constant with Docked = false** — the only case a non-Docked socket carries tint, so the departure reads continuously through the drift-out instead of cutting to no-signal at the emotional peak. The API surface is unchanged (AC-27 intact); after teardown, no values. Example: p=0.05 → amber Clamp01((0.05/0.15)/0.6) = 0.556, violet 0. p=0.5 → both 0 (steady, the common case). p=0.99 → violet Clamp01(((0.24/0.25)−0.6)/0.4) = 0.9, nearly full; 1.0 at undock, held through EvictionGrace.

F8. Recurrence bounds — the player-facing guarantee

*(Corrected 2026-08-26 per independent review H2: the previous single bound stated the round length as a worst-case guarantee; the honest worst case is just under two rounds. The deck algorithm is unchanged — spike-validated; a tightening seam rule was considered and rejected to avoid new termination proofs. Revisit at Tier 1 if the tail stings — Open Question 5.)*

roundLength = deckSize · (C / N) — time to deal the full deck once recurrenceWorstCase = (2·deckSize − 1) · (C / N) — land dealt first in round r, last in round r+1 recurrenceExpected ≈ roundLength

Variables:

Variable Symbol Type Range Description
Deck sizedeckSizeintMVP 2–3, Tier 1 6–8—
Socket countNintMVP 1, Tier 1 3–4—
Round lengthroundLengthlong (s)> 0Expected wait after a miss; the deck is fully dealt once per round
Worst caserecurrenceWorstCaselong (s)> 0Hard bound on the wait for any specific land — this is the promise number

Output Range: both scale as deckSize / N — growing templates and sockets together keeps waits flat-to-improving. Example (MVP, N=1, deckSize=3): roundLength `3 · 259,200 = 777,600 s = 9 days; worst case 5 · 259,200 = 1,296,000 s = 15 days` — the honest player-facing promise: *"a missed land is typically back in about 9 days — never more than 15."* Example (Tier 1, N=4, deckSize=8): roundLength `8 · 64,800 = 518,400 s = 6 days; worst case 15 · 64,800 = 972,000 s = 11.25 days` — Tier 1 has more templates yet recurs faster, because global dockings happen 4× as often.

Edge Cases

Time and clock

Player-in-world

Configuration changes (rulings locked 2026-08-26)

Degenerate configurations (config-load validation, fail loudly)

Dependencies

Upstream (this system depends on): none. The Tide Scheduler is Foundation-layer by design — pure, engine-free C# with no gameplay inputs. One soft runtime dependency: the Netcode Session delivers the host's authoritative t to clients (Core Rule 1). The scheduler's math doesn't depend on netcode existing (it runs identically in EditMode tests and a single-player session); only the distribution of t rides the session layer.

Downstream (systems that depend on this one) — hard unless noted; interfaces specified in Interactions:

Dependent Hard/Soft Interface (summary)
Save & PersistenceHardEpoch-qualified (epochIndex, socket, cycleIndex, nodeId) diff keys; reconcile validity predicate
Land Dock/UndockHardTransition edges, templateForDocking, variationSeed, preload notice
HarvestingHardcycleIndex (sole source), Docked legality gate (re-sampled at completion tick — H1)
CombatHardDocked re-sampled at action resolution; soft-close ordering (combat GDD Rule 13)
Tide-Tint Render PipelineSoft (presentation)amberIntensity/violetIntensity floats only
UI/HUD (compass glint)Soft (presentation)Same floats + Docked booleans
Crafting & GearSoft (timestamp)cycleIndex asked for craftedAtCycle on the maker's mark — never gating (crafting Rule 5; row added 2026-08-27, cross-GDD W5)
Base BuildingSoft (static facts)Socket positions for its F4 exclusion zones; each socket's authored depositPoint (Rule 9) validated by BB AC-63b — no runtime coupling (row added 2026-08-27, cross-GDD W5)
Enemy AINone — deliberate non-dependencyProsperity scaling routes through Land Dock/Undock

Bidirectional-consistency obligations: every dependent GDD/quick-spec now exists and lists Tide Scheduler as upstream — verified in the cross-GDD review of 2026-08-27 (status sentence updated same day, W6). Each dependent must consume the interfaces exactly as specified here (in particular: Harvesting must ask for cycleIndex, never recompute it; render/UI must never receive raw t).

Forward references (owned elsewhere, noted here so they aren't lost): whether player-placed structures can exist on drift lands and what departure does to them → Base Building GDD; the host last-known-position snapshot assumption (Edge Case 8) → netcode ADR; TideConfig-as-parameter and config versioning → architecture ADR.

Tuning Knobs

Not tuning knobs (rules, locked by this GDD): the recurrence guarantee (global deck, once per round, no consecutive repeats, no simultaneous duplicates), the monotonic clock, purity of selection, the sharp φ+W cutoff, and the no-compensation ban. Changing any of these is a design change requiring GDD revision + CD review, not a config edit.

Knob Default Safe range Too low Too high Interacts with
W dock window158,400 s (44 h)24–72 hLands miss whole evenings; adults can't visit at all — tide feels punishingNear-permanent lands; departure stakes evaporateC, M = floor(W·N/C), tint windows (absolute hours scale with W)
G gap100,800 s (28 h)12–48 hWorld never breathes; absence loses meaningLong empty shores at MVP's single socket — dead-world sessionsC = W + G; MVP N=1 feels G directly
N sockets1 (MVP) / 3–4 (T1)1–6, 8 — must divide C; never 7One-lane world (fine for MVP)Content sprawl; recurrence outpaces authoringslotWidth = C/N, M, recurrenceBound; change = hard epoch (Edge Case 14)
deckSize (pool)2–3 (MVP) / 6–8 (T1)> 2M guardHyper-predictable rotationRecurrence waits grow — missed lands stay gone too longroundLength = deckSize·C/N (expected, target ≤ ~9 days); recurrenceWorstCase = (2·deckSize−1)·C/N (the promise number, ≤ ~15 days MVP)
PreloadLeadTime600 s300–1,800 sAddressables load risks a hitch at dockDrift-in land loiters offshore implausibly longCosmetic drift-in duration; never in NextBoundary
EvictionGrace20 s10–60 sTeardown races the deposit sequenceSoft-closed land lingers, inviting "why can't I harvest" confusionTechnical only — never a gameplay window
arrivalWindowFrac0.150.10–0.25Amber blink-and-missUrgency wallpaper; neutral majority lostSum with driftOutWindowFrac ≤ 1.0
driftOutWindowFrac0.250.15–0.30Short sessions never see the warningHalf the docking reads as "leaving"Same guard; visible violet = 0.4·frac·W must be ≥ 4 h (4.4 h at defaults — raised from 0.20 per review M4, whose 3.5 h violated this guidance)

Knob-interaction warnings: W and G tune the game's real-life rhythm — retune them against the friend group's actual schedule after the MVP playtest, not in the abstract. Raising N without raising deckSize makes recurrence very fast and repetitive; raising deckSize without N makes missed lands sting — move them together. Two numbers to watch (F8): expected roundLength = deckSize·C/N (target ≈ ≤ 9 days) and recurrenceWorstCase = (2·deckSize−1)·C/N — the honest promise number (≤ ~15 days at MVP). Additionally, keep W ≥ C/N at any N ≥ 2 or the never-all-empty invariant is surrendered (Rule 4, AC-42b). All values live in TideConfig (data-driven, validated at load per Edge Cases 17–18); tuning changes take effect per Edge Case 13's round-boundary rule.

Visual/Audio Requirements

The scheduler's visual contract is fully owned by art bible §4.3 (tide-state palette curves) and the Tide-Tint Render Pipeline: this system only supplies amberIntensity/violetIntensity floats and the Docked boolean. Visual requirements it imposes: the cosmetic drift-in/drift-out presentations (Rules 8–9) must read as the land sailing, never as a loading state — no progress bars, no spinners at the socket; and no numeric countdown may ever exist (enforced at the API surface, AC-27). Audio (per concept, desirable not MVP-blocking): docking and departure stingers — adaptive, tone-safe (arrival = invitation, departure = farewell, never alarm), triggered off the same transition events Land Dock/Undock consumes; ambient beds may shift subtly with tint state. Threat-land arrival gets no special sting beyond the standard docking sting — danger reads on the land itself (crimson), not in the announcement.

📌 *Asset Spec note: stingers and drift-in presentation become concrete asset lines when /asset-spec runs after the GDD set is complete.*

UI Requirements

One HUD element consumes this system: the compass tide-glint (art bible §7) — per-socket glint driven by the derived floats and Docked booleans, warm glint for arriving/docked, cool for departing. Binding constraints on any UI consuming tide data: never a numeric timer (impossible by API), never punitive copy (Player Fantasy tone rules), departure messaging states world facts only. Open UI question routed to the UI/HUD quick-spec: whether the game offers a soft deck-memory aid (e.g., a log of which lands have appeared this round) or leaves deck-counting to player memory and note-taking — the Lineage 2 anchor leans toward not handing it over, preserving "you learned this."

📌 UX Flag — Tide Scheduler: this system contributes to the compass/HUD. In Pre-Production, run /ux-design for the HUD before writing epics; stories should cite design/ux/hud.md, not this GDD.

Acceptance Criteria

[UNIT] = EditMode NUnit over the pure functions (no engine dependency — this is the project's first mandatory unit-test suite). [INTEGRATION] = NGO session / Unity runtime. [MANUAL] = human judgment. All criteria use locked TideConfig defaults unless the vector specifies otherwise.

Determinism & Purity

Schedule Correctness

Deck & Recurrence

Boundaries & Eviction

Persistence

Config Validation (load-time, fail loudly)

Performance (AC-44/45 hard gates — TD ruling 2026-08-27, ADR-0001; AC-46 advisory)

Multiplayer Consistency

Minimum smoke set (run first)

All five are [UNIT], run in seconds, no engine boot — a true pre-commit smoke gate.

Deliberately not criteria (and why)

Open Questions

# Question Owner Target
1Edge Case 8: host "last received position" snapshot under owner-auth movement — confirm interfaceNetcode ADRAfter friend-group playtest
2~~Perf budgets AC-44/46 — advisory → hard gate?~~ RESOLVED 2026-08-27 (ADR-0001, confirmed by /architecture-review 2026-08-28): AC-44/45 hard CI gates in the Editor profile, AC-46 advisory. Note: Advance now runs once per host tick (ADR-0001 §4 as amended), so the ±5 s transition tolerance is effectively ±(5 s + 1 tick).technical-directorclosed
3Deck-memory HUD aid: in-game round log vs. player memoryUI/HUD quick-specSystems Design phase
4Do 44 h / 28 h fit the group's real schedule?MVP friend-group playtestPost-MVP tuning pass
5Recurrence worst case (~2 rounds): add a tightening seam rule when deckSize grows to 6–8, or does the honest 2-round bound play fine? (H2 fix chose honest restatement over new algorithm complexity)game-designerTier 1 planning