Neon Doom Pit is a small browser FPS prototype built with Vite, TypeScript, and three.js. The game keeps a deliberately compact scope: one arena, short wave-based combat, pointer-lock controls, hitscan shooting, simple enemy AI, and a HUD/overlay flow that fits fast iteration.
It started as a quick experiment in what a simple prompt to a coding agent could produce, then turned into a small game worth polishing and sharing.
- TypeScript
- Vite
- three.js
PointerLockControlspnpm
- Code license: MIT
- Included sprite assets: derived from Kenney packs and kept in
apps/web/public/sprites/ - Kenney asset license: CC0 / public domain
- GitHub Pages:
https://paulatkins88.github.io/neon-doom-pit/
GitHub Pages remains a static-only client host. The multiplayer build now targets a full-stack host so the authoritative room server can run alongside the browser app.
pnpm dev: run the local development serverpnpm dev:server: run the authoritative multiplayer room serverpnpm build: build the web client and verify the server production compilepnpm preview: preview the production build locallypnpm start: start the app server that serves the built web client and WebSocket endpoint on one port
For multiplayer backend changes, pnpm build now also verifies the server bundle.
- Click the overlay button or canvas to enter pointer lock
- Move with
W,A,S,D - Hold
Shiftto sprint - Click to fire
- Press
Rto reload - Survive three waves to clear the arena
The codebase is intentionally split into a few small layers rather than a large engine.
apps/web- browser client app built with Vite
apps/server- authoritative multiplayer room server
packages/shared- shared ids, snapshots, and protocol contracts
apps/web/src/main.ts- boots the game runtime
apps/web/src/core/Game.ts- top-level coordinator for boot, loop, input wiring, wave flow, and restart/game-over transitions
apps/web/src/core/GameState.ts- mutable run/session state used by the loop
apps/web/src/core/contracts.ts- shared interfaces like
ColliderandDamageable
- shared interfaces like
apps/web/src/core/matchSnapshot.ts- browser-side adapter that exposes the local runtime as a typed match snapshot
apps/web/src/world/GameWorld.ts- owns the scene, arena bounds, colliders, and spawn point data
apps/web/src/world/ArenaFactory.ts- builds static arena geometry, lighting, altar, exit marker, guide path, and decorative sprite props
apps/web/src/render/assets.ts- shared texture loading and caching for sprite assets
apps/web/src/render/billboards.ts- camera-facing billboard helper for pickups, projectiles, monsters, and props
apps/web/src/entities/GameObject.ts- base class for meaningful runtime objects
apps/web/src/entities/Actor.ts- base class for damageable objects with health and radius
apps/web/src/entities/Player.ts- player movement, combat state, reloads, and first-person gun mesh
apps/web/src/entities/Pickup.ts- floating pickup entity and collision logic
apps/web/src/entities/Projectile.ts- projectile lifetime and hit logic
apps/web/src/entities/monsters/Monster.ts- shared enemy behavior base
apps/web/src/entities/monsters/*.ts- concrete monster subclasses for each enemy type
apps/web/src/entities/monsters/MonsterFactory.ts- typed factory for monster creation
apps/web/src/systems/InputSystem.ts- browser input listeners and tracked key state
apps/web/src/systems/HudSystem.ts- DOM/HUD/overlay updates and flashes
apps/web/src/systems/CombatSystem.ts- hitscan shooting and projectile collision resolution
apps/web/src/systems/WaveSystem.ts- wave composition, respawns, and victory transition timing
apps/web/src/config/gameConfig.ts- arena and player constants
apps/web/src/config/monsterConfigs.ts- typed monster stats and visuals
apps/web/src/config/spriteConfig.ts- sprite file paths, sizing, and billboard tuning for pickups, monsters, props, and projectiles
apps/web/src/utils/math.ts- small math/collision helpers
packages/shared/src/*- shared multiplayer contracts used by both browser and server apps
- Keep the architecture pragmatic. This is not a general-purpose engine.
- Treat meaningful runtime actors as
GameObject-style entities. - Prefer composition and focused helpers before introducing broad abstraction layers.
- Preserve game feel unless a change is necessary for correctness or maintainability.
- Keep static decorative geometry in the world layer unless it gains interactive behavior.
- Prefer typed config and small subclass overrides over large conditional blocks.
Current monster design is intentionally extension-friendly without overengineering.
- Shared base behavior lives in
Monster - Type-specific behavior lives in subclasses such as
GruntMonsterandSpitterMonster - Shared reusable movement/visual helpers live in
apps/web/src/entities/monsters/behaviors.ts - Future status effects can use
MonsterEffectHook
When adding a new enemy type:
- Add its typed config in
apps/web/src/config/monsterConfigs.ts - Create a subclass in
apps/web/src/entities/monsters/ - Override only the movement/attack behavior that actually differs
- Register it in
MonsterFactory.ts - Add it to
WaveSystemif it should spawn in normal waves
- Read the relevant runtime files first
- Keep edits small and local
- Reuse existing config/constants and factory patterns
- Prefer
pnpm buildafter non-trivial changes - For gameplay changes, manually test with
pnpm devwhen practical
To exercise the co-op flow locally:
- Start the room server with
pnpm dev:server - Start the web client with
pnpm dev - Open the game in two browser tabs or windows
- Create a room in one client and join it from the second client with the room code
- Use reconnect after closing one tab to verify the grace-window flow
The browser client targets ws://localhost:2567 by default. Override the port with VITE_MULTIPLAYER_SERVER_PORT if needed.
For production, prefer VITE_MULTIPLAYER_SERVER_URL when the socket server lives on a different origin. If the browser app and multiplayer server share the same host and port, the client reuses window.location.host automatically.
Issue #21 is addressed with a single-service deployment shape that fits Railway cleanly.
- one Railway web service
apps/serverruns as the Node process- the server serves the built
apps/web/distfiles over HTTP - the same public Railway domain handles both browser requests and WebSocket upgrades
This avoids cross-origin WebSocket config, keeps only one public URL, and matches Railway's single exposed PORT model.
- Root directory: repository root
- Build command:
pnpm install --frozen-lockfile && pnpm build - Start command:
pnpm start
Railway provides PORT automatically. No custom port env var is required for the default single-service setup.
VITE_MULTIPLAYER_SERVER_URL- only set this if the browser client should connect to a different WebSocket origin
VITE_MULTIPLAYER_SERVER_PORT- local/dev override when the server is not on the browser origin
- path:
/health - expected response:
200with{"ok":true}
After the first Railway deploy, verify:
- The homepage loads from the Railway domain.
- Pointer lock still works after entering the game.
- Create room succeeds.
- Join room succeeds from a second browser/tab.
- Start run succeeds and both players receive live updates.
- Reconnect works within the grace window after closing a tab.
- Expired reconnect/session flows show the expected failure UI.
GitHub Pages should be treated as static-only. It is no longer the authoritative multiplayer deployment target.
Manual coverage for the current MVP should include:
- Room flow: create room, join room, start run, reconnect after disconnect, reconnect after expiry, session replacement from a second tab
- Sync flow: both players can move, rotate, shoot, reload, pick up items, kill monsters, and advance waves while seeing the same authoritative outcomes
- Failure flow: invalid room code, missing room, expired room, expired session token, and disconnected server all produce clear UI feedback
- Cleanup flow: no ghost remote players, projectiles, monsters, or pickups remain after reconnect, room expiry, game over, or starting a new room
- Latency tolerance: remote actors stay readable under moderate added latency, while local rubber-banding remains acceptable for the current no-prediction MVP
Current intentional limits for the MVP:
- maximum of 2 players per room
- authoritative snapshots run at a fixed 20 Hz tick
- client-side interpolation is lightweight and there is no full prediction/reconciliation path yet
- reconnect is user-driven rather than fully automatic
- payload validation is intentionally shallow and focused on the shared protocol contract
Reusable prompt templates for OpenCode, Claude, Copilot Chat, or custom slash commands live in:
docs/prompt-pack.md
OpenCode command files live in:
.opencode/commands/
Included commands:
/add-monster/add-weapon/rebalance/add-pickup/edit-arena/fix-gameplay/refactor-safe/add-monster-effect/sync-docs/plan-feature
Minimum:
pnpm build
For gameplay-heavy changes, also verify manually:
- game boots
- pointer lock works
- player can move and shoot
- HUD updates correctly
- enemies spawn, attack, and die
- wave progression still completes
For multiplayer changes, also verify manually:
- room create, join, reconnect, and expiry flows
- remote player, projectile, monster, and pickup sync
- session replacement from another tab/browser
- no stale replicated actors remain after disconnect or room change
Included sprites in apps/web/public/sprites/ were derived from open-license Kenney packs during development, primarily Space Shooter Redux and Particle Pack.
Those Kenney packs are distributed under CC0, which makes them a good fit for a public demo repository.
- weapon abstraction for multiple guns
- pickups and power-ups as additional
GameObjecttypes - richer monster effects using
MonsterEffectHook - multiple arenas or encounter layouts via additional world builders
