Back to the log
[project]2026.01.089 min readdrawn by Muhammed Musthafa S · Founder & Lead Developer

A Browser FPS With Zero Assets

No models, no textures, no audio files. Every surface, weapon and gunshot in an 8,500-line Three.js horror shooter is generated by code at load time.

There is not a single .png, .glb, .mp3, or .wav in Erebus Protocol. No downloaded models, no texture packs, no sound library, no fonts beyond the ones the browser already has. Every surface, every weapon, every enemy, every gunshot and every footstep is generated by code at load time.

It is a first-person shooter set 3,940 metres below the Ross Ice Shelf, built in Three.js, about 8,500 lines of TypeScript. The constraint was self-imposed and it turned out to be the most productive design decision in the project.

Why refuse assets

The honest reason is that I cannot model or draw. The useful reason is what the constraint does to your schedule.

Asset pipelines are where solo game projects die. You need a model, so you find one, and it has the wrong scale, so you fix the scale, and its materials don't match your lighting, so you fix that, and now it is 4MB and the page takes eleven seconds to load on a phone. Multiply by every object in the game. Most of that work is not game development — it is asset wrangling, and it produces nothing you can play.

Procedural generation moves all of it into the codebase, where it is a function with parameters. Want the corridor walls grimier? Change a number, hit save, the walls are grimier. There is no round trip through another tool and no file to keep in sync.

It also means the entire game is a few hundred kilobytes of JavaScript. It loads over a bad connection, in a tab, immediately.

TIP — The constraint that pays for itself

"No external assets" sounds like it makes things harder. It makes the hard parts unavoidable and deletes the tedious parts entirely. You cannot procrastinate on gameplay by shopping for models, because there are no models to shop for.

Level geometry: one data structure, two consumers

The station is roughly twelve rooms across three zones, and every surface in it is an axis-aligned box.

That is a real limitation — no ramps, no curved walls, no arbitrary angles — and it buys something worth far more. The same array of boxes drives both what you see and what you collide with.

type Box = { x: number; y: number; z: number; w: number; h: number; d: number; mat: MaterialId };

// One list. Rendering merges it into geometry; collision indexes it directly.
const room: Box[] = [
  { x: 0, y: 0, z: 0, w: 12, h: 0.2, d: 8, mat: 'floorPlate' },
  { x: 0, y: 3, z: 0, w: 12, h: 0.2, d: 8, mat: 'ceilingPanel' },
  // ...walls
];

The classic bug in a hand-built level is the invisible wall — geometry and collision drifting apart, so the player walks through a pillar or gets stuck on nothing. That bug is structurally impossible here. There is no second representation to drift.

On the rendering side, all boxes sharing a material get merged into a single buffer geometry, so a room draws in roughly one call per material instead of one per box. On the collision side, the same boxes are AABBs, and AABB tests are a handful of comparisons. The cheapest collision primitive and the cheapest draw call, from the same source of truth.

Every wall you can see is a wall you can touch, because it is literally the same object.

Textures are canvases

There are no image files, so materials are drawn at startup into offscreen 2D canvases and uploaded as textures.

The recipe for a convincing industrial surface is not complicated:

  1. Fill a base colour
  2. Add per-pixel noise so it isn't flat
  3. Draw structure — panel seams, rivets, grating, hazard stripes
  4. Add streaks and stains with a few semi-transparent gradients
  5. Upload as a CanvasTexture with repeat wrapping

The thing that sells it is step 2. A perfectly flat colour reads instantly as untextured; the same colour with a couple of percent of noise reads as painted metal. Human vision is enormously sensitive to the absence of noise, which is why the cheapest possible variation buys the largest perceptual jump.

Rust, ice-rimed steel, hydroponics glass, and reactor hazard plating are all the same function with different parameters. Adding a new surface is a call, not a file.

Audio synthesized from oscillators

No sound files either. Every sound is Web Audio primitives.

A gunshot is a short burst of filtered noise with a fast attack and a decay envelope, plus a low sine thump for body. A rifle differs from a shotgun in envelope length, filter cutoff, and how many noise bursts overlap. The reload is a couple of short clicks with different filter settings. Ambient station drone is two detuned oscillators an interval apart, running forever, plus occasional filtered noise for air handling.

function report(ctx: AudioContext, dur: number, cutoff: number) {
  const src = ctx.createBufferSource();
  src.buffer = noiseBuffer(ctx, dur);          // white noise
  const lp = ctx.createBiquadFilter();
  lp.type = 'lowpass';
  lp.frequency.value = cutoff;                  // shotgun low, pistol high
  const gain = ctx.createGain();
  gain.gain.setValueAtTime(1, ctx.currentTime);
  gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + dur);
  src.connect(lp).connect(gain).connect(ctx.destination);
  src.start();
}

Synthesized weapon audio will never sound as good as a recorded gunshot from a real foley library. It sounds deliberate, which for a sci-fi horror game about a station AI is not a compromise — the whole sonic world is synthetic on purpose. It also gives you a knob for everything, so a weapon's sound can be tuned in the same session as its fire rate.

Enemies that navigate instead of shoving

The default enemy AI is "move toward the player." It works in an open field and looks broken indoors, because the enemy walks into a wall and stays there, grinding sideways, while the player watches from around a corner.

Erebus builds a navigation graph as part of level construction — nodes at room centres and doorways, edges where a straight path exists between them. Enemies A* over that graph to get a sequence of waypoints, then use local steering to move between waypoints and avoid each other.

The result is enemies that leave a room through the door, come down the corridor, and enter your room. Not because anything simulated intelligence, but because the pathfinding respects the topology of the building.

Five enemy types share that movement layer and differ in stats and behaviour:

EnemyHPSpeedAttackRole
Drone464.2Ranged, 22mFlying harasser, hovers at head height
Stalker726.6Melee, 1.9mFast, closes distance, forces movement
Warden2402.7Ranged, 20mTank, controls space
Turret640Ranged, 26mStatic area denial
ORACLE core30002.2Ranged, 40mThree-phase boss

Those numbers are the game. Not the shaders, not the models — the relationship between stalker speed and player speed determines whether a corridor is tense or trivial. Tuning them is most of what "game design" turned out to mean in practice.

Post-processing does the atmosphere

Erebus is a horror game and horror is mostly a grade. A custom shader pass runs over the rendered frame and applies:

  • Chromatic aberration that intensifies as you take damage
  • Film grain and vignette, always on, subtle
  • Barrel lens warp, pushed further during the SYNC ability
  • A red pulse from the screen edges when hit
  • Desaturation with a cyan tint and scanlines while SYNC is active

The damage feedback is worth singling out. Health bars are information; the screen bending and reddening at the edges is sensation. Players react to the second one before they have read the first. When I tested with the post-processing off, the game did not feel easier — it felt like a diagram of a game.

React does the HUD and nothing else

The engine and React coexist by barely talking. The game loop runs in plain TypeScript on a canvas, at whatever framerate it can manage, touching no React state. When something HUD-relevant changes — health, ammo, objective, subtitle — it writes to a tiny external store. React subscribes to that store and re-renders only the components whose values actually changed.

The rule is absolute: the game loop never triggers a React render, and React never reaches into the game. A 60fps loop pushing state through React's reconciler every frame is a performance disaster; a HUD rebuilt in canvas imperative code is a maintenance disaster. Splitting at the store gets both halves working in the paradigm they're good at.

The story is text and timing

Eight collectible audio logs, radio chatter across a ten-stage scripted progression, two endings, and a twist about a character named Wren whose voice guides you through the station.

All of it is data — beats, triggers, subtitle lines — and all of it is delivered as synthesized voice-less text with radio static. There is no voice acting because there are no audio files. What there is instead is timing: when a line fires, how long it hangs, what you are doing while you read it.

It is the cheapest possible narrative implementation and it works, because in a game about being alone under two miles of ice, text on a radio channel is exactly the right medium.

What I would keep and what I would drop

Keep: the single-source geometry. It eliminated an entire class of bug permanently and I have not once had to debug a collision mismatch.

Keep: procedural textures and audio. Iteration speed on a solo project is everything, and a parameter beats a file every time.

Reconsider: axis-aligned only. It kept the collision trivial and it makes every room a box. A sloped floor or a curved corridor would change how the station reads, and the collision cost of supporting it in a few places would have been contained.

Drop: doing the post-processing stack before the AI. I spent a week on shaders while enemies were still walking into walls. The grade makes a good game feel great; it makes a broken game feel like a broken game with a nice filter.

Zero assets, one repository, a station under the ice, and an AI that sealed every bulkhead. It runs in a tab.

#Three.js#procedural generation#WebGL#game development#Web Audio

Enjoyed this entry?

Project

Outshorts

AI platforms, full-stack SaaS, custom systems, and ready-to-ship solutions — built by a studio that ships fast.

Title block

DRAWN BY
MUSTHAFA
SHEET
OUTSHORTS.IN
REV
2026

© 2026 OUTSHORTS — ALL SHEETS CURRENT