Dreadnought Devlog #2: Building the Cone of Vision System
Horror is not about what the player sees. It’s about what they can’t.
Dreadnought’s defining mechanic is the cone of vision — a narrow flashlight arc, 72 degrees wide, that represents the only window D.R.E.D-9000 has on the world. Everything outside it is darkness. This post is about how we built that system, the rendering decisions behind it, and why the design choices that look wrong on paper turned out to be exactly right.
The Design Problem
The gold standard for 2D cone-of-vision horror is Darkwood. The design principle it established: controlled ignorance is more terrifying than revealed threat. Showing the player a monster is sometimes less scary than showing them evidence that a monster exists. The flashlight is not just a visibility mechanic — it’s a fear-delivery system.
That principle shaped every technical decision in Dreadnought’s vision system. The implementation had to do more than just draw a lit area. It had to feel like a flashlight in a dark space — uncertain, directional, battery-constrained, with soft edges that erode as resources run out.
Raycasting Against the Tile Grid
Dreadnought’s station geometry is a tile map: walls, floors, doors, and vents defined on a 32-pixel grid. The vision system works by casting rays outward from the player position at small angular increments across the cone’s arc.
For each ray, we step through world space in 2-pixel increments along the ray direction, checking whether the current position corresponds to a wall tile. When a wall is hit, the ray returns that distance. When nothing is hit within the flashlight range, the ray returns max range.
local function castRay(ox, oy, a, maxDist)
local dx = cos(a)
local dy = sin(a)
local step = 2
local d = 0
while d < maxDist do
d = d + step
if Station.isWall(Station.worldToTile(ox + dx * d, oy + dy * d)) then
return d
end
end
return maxDist
end
This runs 120 rays per frame, spanning the cone from angle - 36° to angle + 36°. The ray endpoints become the vertices of a filled polygon — the lit area.
120 rays is a deliberate tradeoff. Fewer rays produce visible gaps at the cone edges (the polygon looks jagged rather than smooth). More rays increase CPU cost. At 120, the polygon is smooth enough to read as a flashlight beam and fast enough to run at 60fps on target hardware alongside the rest of the game’s systems.
Stencil-Buffered Darkness
The vision polygon tells us what’s lit. The rendering challenge is making everything outside it dark without simply painting over the scene.
We use Love2D’s stencil buffer for this.
-- 1) Write lit area into stencil
love.graphics.stencil(function()
local verts = Vision.getPolygon(px, py, angle, batteryFrac)
if #verts >= 6 then
love.graphics.polygon("fill", verts)
end
love.graphics.circle("fill", px, py, BASE_AMBIENT * ambientMul)
end, "replace", 1)
-- 2) Draw darkness only where stencil == 0 (outside the light)
love.graphics.setStencilTest("equal", 0)
love.graphics.setColor(0, 0, 0, 0.96)
love.graphics.rectangle("fill", camX, camY, sw, sh)
love.graphics.setStencilTest()
The stencil pass marks the lit area. The subsequent draw fills everything outside it with near-black (alpha = 0.96 rather than 1.0 — a small but deliberate choice, keeping total blackness from being literally nothing). The game world renders underneath, visible only where the stencil says it should be.
The second shape in the stencil is the ambient circle: a small radius of low-light awareness centered on the player. This represents D.R.E.D-9000’s passive sensor suite — a tiny bubble of environmental awareness independent of the flashlight direction. Without it, looking away from a threat would mean zero information about anything near the player, which proved more frustrating than frightening in playtesting. The ambient circle is small enough to be useful without undermining the darkness.
Battery Degradation
The flashlight runs on battery. This isn’t a peripheral system — it’s the game’s central resource tension.
Battery fraction (0.0 to 1.0) feeds directly into both the range and the edge gradient of the vision polygon:
function Vision.applyDarkness(px, py, angle, camX, camY, batteryFrac)
batteryFrac = batteryFrac or 1
-- ... stencil fill with range = BASE_RANGE * batteryFrac ...
-- Soft edge: heavier vignette as battery fades
local fade = 0.3 + 0.4 * (1 - batteryFrac)
love.graphics.setColor(0, 0, 0, fade)
love.graphics.polygon("fill", verts)
end
At full battery, the cone reaches 280 pixels and has a light vignette (alpha = 0.3) at its edges, creating a natural penumbra. As battery drops toward zero, two things happen simultaneously: the range shrinks, and the edge vignette darkens (up to alpha = 0.7). The cone becomes shorter and its edges close in faster.
The practical effect is that battery management stops being abstract. You feel the flashlight dying in your hands. The world gets smaller. Threats that were at the edge of your light are now invisible again, and you know something was there.
The Tradeoffs
Arc Width
72 degrees total (36 each side from facing angle). This is narrow. It means aliens can comfortably be directly behind the player and completely invisible.
We tested wider arcs. At 90 degrees the mechanic still functions. At 120 degrees it starts to feel more like a spotlight than a flashlight and the horror contract breaks — too much of the room is lit at once. At 60 degrees the game becomes frustrating; the requirement to constantly check flanks kills the pacing.
72 degrees is the calibration point where the player has enough information to navigate, not enough to feel safe.
Scanlines vs. Texture
The cone is a filled polygon, not a textured sprite. There’s no light-beam texture, no volumetric fog drawn within the cone. We considered adding scanlines to the lit area as a CRT effect — and implemented it — but pulled it back to an option rather than a default. Scanlines within the cone create the impression of rendered content where there should be clean visibility, and they drew attention to the cone boundary in a way that made it feel mechanical rather than environmental.
Difficulty Modulation
On INSANE difficulty, the cone narrows further via a difficulty multiplier applied to both range and ambient radius. The effect compounds: shorter range AND a smaller ambient bubble means the player’s information is fundamentally worse than on NORMAL. Paired with faster enemies and reduced resources, INSANE is not a damage multiplier — it’s a visibility tax.
function Vision.setDiffMul(rMul, aMul)
diffRangeMul = rMul
diffAmbientMul = aMul
end
The shop upgrade system (the flashlight mod passive) widens the cone angle by a multiplier separate from difficulty. These two systems are independent — difficulty narrows via range and ambient, upgrades widen via cone angle. Players can partially compensate for INSANE’s visibility tax but cannot fully recover it, which is the intended tension.
Sound Fills the Gap
The vision system only handles what the player sees. The audio layer handles what they hear.
Dreadnought synthesizes all audio at runtime from waveforms — there are no external sound files. This matters for the vision system because it means enemy proximity can drive audio parameters directly, regardless of whether the enemy is in the cone.
When something is within sensor range but outside the cone, the audio system increases the modulation and shifts the frequency of the ambient sound profile in that quadrant. The player’s brain hears that something is there before they can confirm it visually. The reflex is to turn toward the sound, which changes the cone direction, which changes what they see. This rotation loop — hear, turn, see (or not see) — is the core gameplay interaction that the vision system exists to enable.
What We Learned
The stencil approach to darkness is clean and fast. The hard part isn’t the technical implementation — it’s the calibration. Every parameter in the vision system (cone angle, range, step size, ray count, ambient radius, vignette alpha) has been tuned against playtest sessions, and most of the numbers that shipped were not the numbers that were committed first.
Horror design is calibration design. The system only works if the player has just enough information to be afraid rather than either blundering blindly or feeling safe. That line is narrower than you’d expect, and finding it is empirical work.
Devlog #3 will cover the audio system — how Dreadnought synthesizes 80+ distinct sound effects and an adaptive ambient layer from first principles, with no audio files anywhere in the repository.