Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions docs/wiki/Architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# Architecture

## Two targets

- **`LULLKit/`** — a standalone Swift package (`swift-tools-version:5.9`,
iOS 17 / macOS 14). Domain models and the consent foundation: `Sensor`,
`ConsentLedger`, `EyeSession`, `Atmosphere`. No SwiftUI, no AVFoundation, no
UIKit — pure Swift, fully unit-testable without a simulator or device.
- **`app/`** — the SwiftUI iPhone app (the vertical slice, `THE EYE`). Depends
on `LULLKit` as a local Swift package. Built via a generated Xcode project
(`app/project.yml`, XcodeGen) — no `.xcodeproj` is committed.
- **`server/`** — referenced in the README as a later Vapor "haunt" backend.
Not present in the repo yet; see [Roadmap / Ideas](Roadmap-Ideas).

```mermaid
graph TD
subgraph LULLKit["LULLKit — pure Swift package"]
Sensor["Sensor (enum)"]
ConsentLedger["ConsentLedger (struct)"]
EyeSession["EyeSession (struct)"]
Atmosphere["Atmosphere (enum, pure fn)"]
end

subgraph appTarget["app — SwiftUI + AVFoundation"]
GameModel["GameModel (ObservableObject)"]
CameraController["CameraController : CameraGate"]
ConsentView
EyeView
RootView
AtmosphereBackground["AtmosphereBackground (SpriteView bridge)"]
AtmosphereScene["AtmosphereScene : SKScene (experiment branch)"]
end

GameModel --> EyeSession
GameModel --> ConsentLedger
GameModel --> CameraController
ConsentView --> GameModel
EyeView --> GameModel
EyeView -.reads narration.-> Atmosphere
ConsentView -.reads narration.-> Atmosphere
RootView --> GameModel
RootView --> ConsentView
RootView --> EyeView
RootView -.background layer.-> AtmosphereBackground
AtmosphereBackground --> AtmosphereScene
```

`AtmosphereScene`/`AtmosphereBackground` exist only on the
`experiment/spritekit-atmosphere` branch — see
[Atmosphere (experiment)](Atmosphere-Experiment). Everything else above is on
`main`.

## `ConsentLedger` — the consent state

From `LULLKit/Sources/LULLKit/Consent.swift`. A small value type, not a state
machine: a `Set<Sensor>` of what's currently granted.

- `mayUse(_:)` — is this sensor usable right now. Default (empty set) is
deny for everything.
- `grant(_:)` / `revoke(_:)` — per-sensor, explicit.
- `revokeAll()` — the panic switch: clears every grant at once.
- `activeSensors` — for an always-visible "LULL can see/hear you" indicator.

## `EyeSession` — the mechanic's state machine

From `LULLKit/Sources/LULLKit/Eye.swift`. `THE EYE`'s entire behavior is this
one `struct`, driven by `begin()`, `consent(_:)`, `advance(by:)`, and
`release()` — no camera, no UI, so it's fully unit-testable with fake time.

```mermaid
stateDiagram-v2
[*] --> dormant
dormant --> seekingConsent: begin()
seekingConsent --> denied: consent(false)
seekingConsent --> watching: consent(true)
watching --> noticing: advance(by:) once elapsed ≥ calmSeconds
noticing --> awake: advance(by:) once elapsed ≥ calmSeconds + noticingSeconds
watching --> released: release()
noticing --> released: release()
awake --> released: release()
seekingConsent --> released: release()
denied --> [*]
released --> [*]
```

`release()` is callable from **any** phase — it's the panic switch made
concrete for this mechanic. `wantsCamera` is a derived, read-only property
(`true` only in `watching`/`noticing`/`awake`) that the app is meant to
consult before running the camera — see
[The Safety Invariant](The-Safety-Invariant) for exactly how that's wired
(and where the guarantee is weaker than it sounds).

`advance(by:)` clamps `dt` to `[0, 5]` seconds per call specifically so a
backgrounded app resuming with a huge time delta can't skip straight to
`.awake` — pinned by `testBackgroundingCannotFastForwardTheHorror` in
`EyeSessionTests.swift`.

## `Atmosphere` — the narration layer

From `LULLKit/Sources/LULLKit/Atmosphere.swift`. A **pure function** from
`EyeSession.Phase` (+ an elapsed-time-derived "beat") to a `Line` of text
tagged with a `Voice` (`kafka` / `beckett` / `poe`). It holds no state,
touches no hardware, and reaches no sensor — the app renders whatever it
returns and adds nothing of its own. This is also what lets the narration be
unit-tested (register-per-phase, beat-wrapping, "denial is always merciful")
without any UI.

## `CameraController` — the real camera

From `app/Sources/CameraController.swift`. Wraps a single `AVCaptureSession`
on the front camera behind the `CameraGate` protocol
(`requestAccess() / start() / stop()`), so `EyeSession`'s logic never needs
to know AVFoundation exists — tests can substitute a fake `CameraGate`.
`CameraPreview` (a `UIViewRepresentable`) renders the live `AVCaptureSession`
into SwiftUI; `EyeView` then deliberately degrades it (desaturated, blurred,
opacity-limited, vignetted) — the raw feed is never shown clean.

## `GameModel` — the glue

From `app/Sources/GameModel.swift`. An `ObservableObject` that owns one
`EyeSession`, one `ConsentLedger`, and one `CameraController`, and wires them
to a real 0.5s `Timer`. It contains no decisions of its own — every branch of
logic (does the eye escalate, is a sensor allowed) lives in `LULLKit` and is
tested there; `GameModel` just calls into it with real time and a real
camera.

## View layer

- **`ConsentView`** — the in-app rationale (`Sensor.camera.rationale`) shown
*before* the OS permission prompt, with a real "Not tonight" decline.
- **`EyeView`** — the degraded camera preview plus narration, escalating
opacity/blur/vignette by `EyeSession.Phase`, and an always-present
"close the eye" control.
- **`RootView`** (in `LULLApp.swift`) — routes purely on `model.eye.phase`:
`dormant`/`seekingConsent` → `ConsentView`, `watching`/`noticing`/`awake` →
`EyeView`, `denied`/`released` → `EndingView`. The UI is a function of the
state machine, nothing more.
- **`EndingView`** (also in `LULLApp.swift`) — renders the terminal narration
line for `denied` or `released`.
77 changes: 77 additions & 0 deletions docs/wiki/Atmosphere-Experiment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Atmosphere (experiment)

**Branch:** [`experiment/spritekit-atmosphere`](https://github.com/testtest126/LULL/tree/experiment/spritekit-atmosphere)
**PR:** [#1 — "Experiment: SpriteKit atmosphere layer"](https://github.com/testtest126/LULL/pull/1) (draft)

This is explicitly framed in the PR description as **"a prototype, not a
decision"** — evaluating whether SpriteKit earns its place as a dependency of
LULL's real fear mechanics, before committing to it.

## What's in it

- **`app/Sources/AtmosphereScene.swift`** — an `SKScene` that is pure
decoration: slow drifting fog (`SKEmitterNode`), faint upward dust motes, a
static vignette (a generated radial-gradient texture, no shader), a slow
breathing red glow ("pulse"), and a rare, brief flicker. It owns no state,
reads no sensor, and knows nothing about consent or `EyeSession` — per its
own doc comment, "delete this file and the app behaves exactly as it did
before." The palette is copied from `Theme.swift` by eye (not imported), so
the file stays independently liftable/deletable.
- **`app/Sources/AtmosphereBackground.swift`** — a `SpriteView` bridge that
puts `AtmosphereScene` into SwiftUI as a background layer, with no
reference to `GameModel`, consent, or the camera.
- **`app/Sources/LULLApp.swift`** — a one-line swap in `RootView`'s `ZStack`:
`Theme.ink.ignoresSafeArea()` → `AtmosphereBackground()` as the base layer,
sitting *behind* the existing consent/eye UI.
- **`app/project.yml`** — adds `INFOPLIST_KEY_UILaunchScreen_Generation: YES`.
Called out in the PR as unrelated to SpriteKit itself — a pre-existing gap
(the app was rendering letterboxed in the Simulator for lack of a launch
screen) that had to be fixed to see the atmosphere full-screen.

## What it explicitly is *not*

Per the PR description:

- No touches to `LULLKit`, `ConsentLedger`, `EyeSession`, or the camera
pipeline. `AtmosphereScene` holds no state and reaches no sensor.
- No new dependencies — SpriteKit is first-party (part of the OS SDK).
- Nothing removed — `ConsentView`, `EyeView`, and `GameModel` are untouched;
this is strictly an additive background layer.

## Verification claimed in the PR

- `swift test` in `LULLKit`: 16/16 green, unchanged (consistent with what
[Building & Running](Building-and-Running) documents for `main`).
- `xcodegen generate` + `xcodebuild` for `iphonesimulator`: builds clean.
- Run in the iPhone 17 Pro simulator: the atmosphere layer renders
full-screen behind the consent title, with a visibly breathing glow and
vignette.

## The honest take (from the PR author, in the PR itself)

The PR's own "Honest read" section is worth preserving verbatim in spirit,
because it's a well-reasoned argument *against* over-adopting the framework
it just introduced:

> Restrained particle/glow work like this is well within reach of
> `Canvas`/`TimelineView` + Core Animation, and SpriteKit's advantage would
> show up more once there's actual interactivity — things reacting to
> touch/face/time in real time, physics, sprite-sheet animation — rather than
> ambient decoration. Ambient-only, lean SwiftUI to keep the surface area
> small; but SpriteKit is a lot less awkward for this than expected, so if
> `THE EYE`'s escalation ever wants particle reactions tied to the camera
> signal, it's a reasonable investment.

In short: for purely decorative atmosphere (fog, dust, vignette, ambient
glow), plain SwiftUI/Core Animation likely does the job with less framework
surface area to maintain. SpriteKit's case gets stronger the moment the dread
needs to *react* — to the camera signal, to touch, to time, in a way that
benefits from a real scene graph and physics/particle system rather than
declarative animation.

## Status

Draft PR, not merged. Reading it as a decision record: the prototype proves
SpriteKit *can* sit cleanly behind the existing UI without touching game
logic, but whether it's worth adopting is explicitly left open pending
whether future mechanics need reactive behavior, not just ambience.
66 changes: 66 additions & 0 deletions docs/wiki/Building-and-Running.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Building & Running

## Requirements

- Xcode 15+ (iOS 17 deployment target)
- [XcodeGen](https://github.com/yonaskolb/XcodeGen) (`brew install xcodegen`)
- A **real iPhone**, not the Simulator, if you want to see `THE EYE` actually
work — the front camera does nothing in the Simulator.

## Generate and open the app project

The `.xcodeproj` is not committed — it's generated from `app/project.yml`,
the same way the rest of the repo treats derived artifacts:

```sh
brew install xcodegen # once
cd app
xcodegen # generates LULL.xcodeproj
open LULL.xcodeproj # then build + run on a real device
```

No XcodeGen available? Per `app/README.md`: create a new iOS App target in
Xcode 15+, add the files under `app/Sources/`, and add the local `LULLKit`
package as a dependency. The sources are plain SwiftUI — nothing exotic.

`app/project.yml` sets the important App-Review-facing bits directly:

- `PRODUCT_BUNDLE_IDENTIFIER`: `io.github.testtest126.lull`
- `INFOPLIST_KEY_NSCameraUsageDescription`: the honest camera rationale
string shown by the OS
- Deployment target: iOS 17.0

## What running it does

From `app/README.md`, in order:

1. **Asks in-app first.** `Sensor.camera.rationale` is shown before the OS
prompt. A "no" at either level is final — nothing watches.
2. **Opens the front camera**, degraded and never shown clean. No frame is
recorded; nothing leaves the device.
3. **Runs the clock**: `EyeSession` moves `calm → noticing → awake` on a
timer.
4. **"Close the eye"** is always present — it revokes consent and stops the
camera immediately (the panic switch).

## Testing `LULLKit`

`LULLKit` is a standalone Swift package and builds/tests independent of the
iOS app or a simulator:

```sh
cd LULLKit
swift test
```

As of this writing that's **16 tests** across three files — `ConsentTests`,
`EyeSessionTests`, `AtmosphereTests` — covering the consent invariants (see
[The Safety Invariant](The-Safety-Invariant)), the `EyeSession` phase machine
(including the anti-fast-forward clamp), and the `Atmosphere` narration
layer. Per the README and `CLAUDE.md`, this suite is meant to stay green from
commit one and every commit after — it's the project's "perft," the
executable proof that consent and the dread clock behave as designed.

> `swift test` requires a full Swift toolchain with XCTest available (ships
> with Xcode's command-line tools). A bare Swift toolchain without Xcode may
> not have `XCTest` on its module search path.
94 changes: 94 additions & 0 deletions docs/wiki/Concept-and-Design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Concept & Design

Source: [`README.md`](https://github.com/testtest126/LULL/blob/main/README.md),
[`docs/concept.md`](https://github.com/testtest126/LULL/blob/main/docs/concept.md),
[`CLAUDE.md`](https://github.com/testtest126/LULL/blob/main/CLAUDE.md).

## The premise

LULL poses as a sleep aid. You install it to help you fall asleep; it asks,
politely, for the usual permissions. For a few nights it behaves — soft
sounds, a breathing guide, a gentle log of your rest. Then it starts to
*remember things you didn't tell it*, to notice when you're watching, and to
forget that it's supposed to be a game.

The one-line pitch: not "a scary game on a phone" — **the scary thing is the
phone.** It's in your hand, it has a camera on your face and a mic in your
room, it knows it's late, and it can reach you after you've closed it.

## Why iPhone (the OD move)

The concept brief frames this as Kojima's fourth-wall instinct (Psycho
Mantis "reading" your memory card, MGS2 faking a crash) applied to a device
that's *already personal* in a way a console across the room never is. That
intimacy isn't a limitation to design around — per `docs/concept.md`, "it is
the entire game."

## Fear mechanics (iOS-native)

From the README / concept brief. **Only `THE EYE` is implemented** as of v0.1
— the rest are named design intent, not shipped features (see
[Roadmap / Ideas](Roadmap-Ideas)).

| Mechanic | The device turned against you | Status |
|---|---|---|
| **THE EYE** | The front camera — it watches you, and reacts to your face | Implemented (`THE EYE`, v0.1) |
| **THE ROOM** | The microphone — it hears the silence, and what breaks it | Named only |
| **THE REACH** | A notification at 3am, while the app is closed | Named only |
| **THE HOUR** | It plays differently when it's late and the room is still | Named only |
| **THE PULSE** | A heartbeat in your palm, through haptics, not quite yours | Named only |
| **BEHIND YOU** | Spatial audio, something just over your shoulder | Named only |

## Form

Short. One sitting. Escalating, with one clean break in the fourth wall — not
a sprawling epic. Dread over gore: the phone doesn't need blood. The concept
brief argues restraint scares harder than spectacle, and restraint is what a
solo build can afford to do impeccably.

## The voice: Kafka, Beckett, Poe

LULL narrates in three literary registers, one per act of the experience:

| Register | Governs | The feeling |
|---|---|---|
| **Franz Kafka** | The threshold — consent | A record opening in your name; a verdict withheld |
| **Samuel Beckett** | The lull — the calm & the endings | Waiting, the failing light, the nothing that is a mercy |
| **Edgar Allan Poe** | The watch — the escalation | The eye that will not blink; the heart beneath the floor |

This is a design language, not a citation. Every line in
[`Atmosphere.swift`](https://github.com/testtest126/LULL/blob/main/LULLKit/Sources/LULLKit/Atmosphere.swift)
is original prose written *in* each register — nothing is quoted, and no
author's name appears on screen, so the spell isn't broken. The registers live
in a pure, tested, sensor-free layer over the same phase machine that drives
the dread (`EyeSession.Phase`), so the writing is provable and structurally
cannot widen what the game is permitted to touch — see
[The Safety Invariant](The-Safety-Invariant) for what "cannot widen" means
precisely.

## The consent-first philosophy

Also documented in [The Safety Invariant](The-Safety-Invariant), but the
design rationale is worth stating plainly, in the project's own words
(`CLAUDE.md` / README):

- Every sensor is opt-in, explained, and revocable. Default is deny.
- Forbidden, forever: photos, contacts, location trails, health data, and
anything read without the player knowing.
- No genuine harm — uncanny, not traumatizing; unsettling, but never
deceptive about what's real. A fake "your data has leaked" is out; the
dread is fiction and stays legibly fiction.
- No PII in the repo, logs, or telemetry, ever.

The fear is meant to come from what the player *knowingly* hands over, turned
uncanny — not from what is taken. Privacy-first is framed as both the decent
choice and the only one App Review will pass.

## Lineage

The project explicitly reuses lessons (not process) from an earlier project,
[MateMate (chess)](https://github.com/testtest126/chess): SwiftUI craft, a
Vapor server pattern, "verify, don't assume" rigor, and a privacy-audited
foundation. `CLAUDE.md` is explicit that the *ceremony* around that project
(multi-agent coordination protocols) doesn't apply here — LULL is small, and
keeps the rigor while dropping the ritual.
Loading