A third-person immersive sim stealth game, built solo in Unity. You play a fragile spy in a Gothic-Victorian city where information is the weapon and being seen is the fail state. This is a case study about scope: which systems earned depth, which were refused, and the rule used to decide.
Immersive sims earn their reputation through systemic depth — the reason to play is that systems collide in ways nobody scripted. One developer cannot build breadth. Every system has to justify its slot, and "it would be cool" is not a justification.
Four pillars, plus an explicit priority order for when they conflict. Scope arguments stop being about taste and start resolving by rule. Everything that survives gets tuned with real numbers, exposed on ScriptableObjects so tuning never needs code.
Depth concentrated in cover, lockpicking, guard perception, and sound propagation — the four that carry the stealth loop. Everything else is built to the minimum that serves them. 18 named systems, 15 complete.
The vertical slice's systems are complete and tuned. Combat, dialogue, and the Canvas UI pass remain. Steam Next Fest demo targeted for Feb 2027, full release Q2 2027.
Most projects list pillars. Fewer say what happens when two of them want opposite things. That second part is where the pillars actually do work — without it, a pillar list is decoration you can argue your way around.
Read as 1 > 2 > 4 > 3. Vulnerability wins outright: any feature that lets the player shortcut stealth is cut regardless of how good it feels in isolation. Information-richness beats exploration convenience: a long detour to reach a document is preferred over auto-collecting it.
Fun, genre-adjacent, and a direct shortcut past the stealth layer. Cut without debate because Pillar 1 outranks everything it would have served.
Would have smoothed exploration and cost the game its central act — going to the thing, and reading it. Exploration convenience yields to information.
Cover is the primary defensive tool, and the system I'd point at first. Not because of the raycasts — because of the feature it deliberately doesn't have.
Walk toward a wall and the player snaps into cover after 4 frames of sustained contact — about 66 ms at 60 fps. There is no cover button. The whole point is to remove a button press from the moment where the player's attention is most expensive. The 4-frame requirement is what stops single-frame flicker from a glancing wall.
Camera-relative input is projected onto the wall's tangent, so the control mapping falls out of the geometry instead of being authored per wall:
wallTangent = Cross(CoverNormal, Vector3.up).normalized worldInput = camRight × input.x + camForward × input.y tangentSlide = Dot(worldInput, wallTangent)
A wall facing the camera slides with A/D. A wall on the player's left slides with W/S. Any arbitrary angle works with no branches, which is the difference between a system and a pile of cases.
| Parameter | Value | Purpose |
|---|---|---|
| Walk speed in cover | 1.5 m/s | Slower than normal walk; sprint disabled |
| Magnetic standoff | 0.42 m | Controller radius + 0.1, corrective nudge at speed 10 |
| Rotation slerp | 10 | Player rotates to face away from the wall |
| Footstep interval | 0.45 m | Lateral travel; feeds AI hearing at 1 m radius |
| Exit threshold | dot > 0.65 | ~49° into the wall — deliberate exits only |
| Snap-in delay | 4 frames | ≈66 ms at 60 fps; kills flicker |
Nearly every third-person cover system lets you round an outer corner with a button — the character slides around, the camera follows, you're on the new wall. It feels good. It was built and it is not in the game.
In DSP the system commits to one wall. At an outer corner the peek camera looks around it, but the body stays clamped at the edge. To get onto the next wall the player has to push away from the current one and re-engage — a deliberate act, taken while exposed.
Because free auto-wrap glides the player blindly into space they haven't looked at yet. That is a shortcut past observation, and Pillar 1 outranks the comfort it would buy.
Inside corners had the opposite problem — the fan would alternate between two surfaces and
jitter. That's fixed by normal-locking (NormalLockDot), which keeps the player
committed to the original wall. Same principle: one wall at a time, always known.
No cover-to-cover dash. No scripted corner turn. No vaulting over cover. Peek uses
FindObjectsByType to save and restore the scene camera, which allocates.
Single-surface tracking only, so genuinely complex geometry can still break it. These are
known and written down rather than discovered later.
A pick-and-turn minigame across five tiers. The design problem isn't the minigame, it's the progression: character skill has to matter without ever letting a maxed player skip the thing they're maxed at. Drag the slider and watch the sweet spot widen — then look at how little it widens on a Master lock.
// pick degradation — runs every frame while tension is applied damage = baseBreakSpeed × breakMultiplier × skillReduction × dt breakMultiplier = lerp(0.3, 1.0, errorNormalised) // 0.3 at the edge of the sweet spot skillReduction = lerp(1.0, 0.5, PlayerSkillLevel) // halves at max Finesse // cylinder rotation limit — quadratic falloff, so near-misses still turn rotationError = clamp01((angularError − halfWidth) / (90 − halfWidth)) maxRotation = (1 − rotationError²) × 90°
| Tier | Arc @ skill 0 | Arc @ skill 1 | Break speed | Worst case | XP |
|---|---|---|---|---|---|
| Simple | 30° | 38° | 15 | 6.7 s | 10 |
| Standard | 20° | 28° | 22 | 4.5 s | 15 |
| Advanced | 12° | 20° | 32 | 3.1 s | 20 |
| Expert | 6° | 14° | 45 | 2.2 s | 30 |
| Master | 3° | 11° | 65 | 1.5 s | 50 |
Pick HP is a flat 100 at every tier. Difficulty scales through break speed, never durability — so a hard lock punishes imprecision rather than patience, and the player's relationship with a lock is always "am I on the spot", not "do I have enough resource".
Finesse adds 4° per side. At maximum, a Master lock goes from a 3° arc to an 11° arc — 3% of the dial. A fully-levelled player still has to find the sweet spot by hand, still breaks picks, still feels the lock.
Because progression here is parameter-space, not permission. Stats make existing mechanics easier to execute; they never unlock a bypass. That's the line between an immersive sim and an RPG with a lockpicking skill.
A guard's eye is two cones, not one. That single split is what produces the genre's signature moment: the cone sweeps toward you, and you freeze instead of running.
Below a light level of 0.2 the guard cannot see the player at all, at any range. Above it, light is remapped to 0–1 and raised to an exponent of 2.0, so 50% light gives 25% detection speed. The quadratic is doing real work: it makes the difference between "dim" and "dark" feel like a cliff rather than a slope, which is what makes extinguishing a single candle a tactical act.
All five inputs — distance, angle, light, movement, posture — multiply into one
combinedMult, which maps linearly onto a fill time between 0.5 s (worst case)
and 5 s (best case). Every threshold lives on a GuardData ScriptableObject, so
a Nervous Guard in the Catacombs is an asset, not a subclass.
| Scenario | Time to Alert |
|---|---|
| Standing, full light, centre of cone, close range | ~0.5 s |
| Crouching, dim light (0.4), edge of cone, far range | ~5 s+ |
| Moving, peripheral cone only | ~12 s |
| Still, below the light threshold, any range | never |
Sound is global where vision is local, and that asymmetry is what makes the two systems reward different play. Every noise the player makes runs through the same four-term model before a guard ever hears it. Build a scenario below.
Masking removes metres from the sound before dispatch. A crouched player (1.5 m) in rain (−4 m) produces a radius of zero and the event is never dispatched at all. A running player still leaks 8 m. Falloff stays distance-based and consistent.
Masking is sampled where the sound is made. The player is rewarded for positioning themselves next to the organ before acting — not for the guard happening to stand near it.
The cheap version of this system is a boolean: line of sight to the listener, or silence. It's faster, and it would have made stone walls into perfect soundproofing.
Instead every material is a multiplier — stone 0.2×, wood 0.5×, curtain 0.8× — and a pistol shot at a 50 m radius still punches meaningful suspicion through cathedral stone.
Because "firing a weapon is always a disaster" is a contract with the player. A hard block would make guards deaf behind cover and quietly turn the pistol into a safe option in the exact rooms where it should be the worst one.
The honest limitation: occlusion is a single raycast, so a thin pillar between source and listener blocks as if it were a wall. Multi-sampling to head, torso and feet fixes it at 3× the raycast cost, and it's deferred until vertical-slice tuning shows it actually matters on a Steam Deck.
Read row → column. The point of the table isn't the individual cells; it's the two columns on the right. Almost every player action, whatever system it starts in, eventually expresses itself as a change in one guard's suspicion value.
| Cover | Interact | Sound | Light | Journal | Inventory | Progression | AI Senses | AI State | |
|---|---|---|---|---|---|---|---|---|---|
| Player Movement | emits | reads | emits | reads | · | · | · | · | · |
| Cover | · | blocks | reads | · | · | · | · | blocks LOS | · |
| Lockpicking | · | · | emits* | · | · | uses | → Finesse | · | · |
| Interaction | · | · | emits | toggles | · | · | · | · | · |
| Documents / Obs. | · | · | · | · | writes | reads | → Acuity | · | · |
| Sound Propagation | · | · | · | · | · | · | · | reads | · |
| Guard Coordinator | · | · | shouts | · | · | · | · | · | writes bb |
* Lockpick noise is wired but not yet emitting — the OnPickBroken event exists
and Sound Propagation will subscribe at ~6 m. Empty cells are either unwired or not
meaningful; they're left blank rather than invented.
Kept because a solo project fails from the production side more often than the design side, and because a mitigation you wrote down before the risk landed is worth more than one you improvised after.
| Risk | Severity | Mitigation |
|---|---|---|
| Solo dev burnout | High | Single-doc workflow; aggressive asset reuse; cut ruthlessly |
| Player can't find clues to progress | High | Clue-density targets per area; always at least one golden-path clue per critical lock |
| Scope creep on narrative | High | Vertical slice locked; other regions reuse its structural template |
| OnGUI → Canvas migration slips | Medium | Rendering decoupled in every system; can ship beta on placeholder UI |
| Sound propagation perf on Steam Deck | Medium | Start with single-ray occlusion; profile early |
| Save/load complexity | Medium | Definitions already live on ScriptableObjects; only runtime instances need serialising |
| AI feels "solved" once routes are known | Medium | Coordinator's dynamic role assignment reshuffles each encounter |
A running record of what was decided and why. Reversals aren't deleted — they're struck through with the reason. Ten entries selected from the log; the rule is that every one has to name the pillar or constraint it serves.
EnemyController is deliberately excluded from save data so a reload never drops you back into an alert spiral.
Mirrored from the repo's README.md, June 2026. Listed in full — including
what isn't built — because "done" is only a useful word if it's used precisely.
Four pillars told me what the game was. The priority order told me what to cut. The second one did all the work, and it's the part most pillar lists skip.
Max Finesse moves a Master lock from 3° to 11° of a 360° dial. Designing the cap first and the curve second is what kept progression from eating the mechanic it rewards.
Single-ray occlusion, no corner wrap, no vaulting. Each was a known trade at the time, recorded with its reason. None of them turned into a surprise six months later.