Skip to content

Repository files navigation

TRAP PATH

There is always a path. But never the one you expect.

Play TRAP PATH

TRAP PATH is an original, responsive psychological platform game built for the browser. It places the player inside an abandoned experimental facility where floors collapse, exits lie, controls invert, gravity changes, and the camera occasionally becomes part of the trap.

The project uses React for the application shell and accessible interface, Phaser for the deterministic 60 FPS game simulation, and a strict TypeScript data model shared by the game and the in-browser level editor. Its art, level layouts, procedural textures, sound patches, writing, and identity were created for this project.

Highlights

  • Six handcrafted experiments with moving and falling platforms, hidden hazards, portal loops, false checkpoints, fake exits, gravity fields, camera lies, blackout zones, secrets, and environmental lore.
  • Walking, running, jumping, wall jumping, coyote time, jump buffering, keyboard, controller, and mobile touch input.
  • Responsive indie-game interface with persistent Dark and Light themes, an animated facility backdrop, level select, archive, settings, pause flow, HUD, completion summary, and leaderboard.
  • Local progress, settings, statistics, best times, recovered secrets, and automatic resume through a persisted Zustand store.
  • Procedurally generated pixel textures and synthesized sound effects, avoiding external copyrighted game assets.
  • A visual editor with grid snapping, click or drag placement, object dragging, inspection, deletion, undo/redo, validation, JSON import/export, autosave, and preview snapshots.
  • Offline-capable PWA shell and deployment configuration for Vercel, Netlify, and GitHub Pages.
  • Optional Supabase leaderboard and cloud save adapters with graceful local-only fallback.

Technology

Layer Technology
Application React 18, Vite 8, TypeScript
Game runtime Phaser 3 Arcade Physics
State Zustand with local-storage persistence
Interface Tailwind CSS, Framer Motion, Lucide
Cinematic animation GSAP
Audio Howler.js with generated WAV patches
Optional backend Supabase REST API
Quality ESLint 10, Prettier, Vitest 4, strict TypeScript

Getting started

Requirements

  • Node.js 20.19+ or 22.12+; CI and production examples use Node.js 22.
  • npm 10 or newer.
  • A modern browser with Canvas, Web Audio, and ES2020 support.

Install and run

npm ci
npm run dev

Vite serves the game at http://localhost:4173. Audio starts after the first pointer or keyboard interaction because browsers block unsolicited playback.

Copy the environment template only if cloud features are wanted:

cp .env.example .env.local

On PowerShell:

Copy-Item .env.example .env.local

The application works without any environment variables.

Commands

Command Purpose
npm run dev Start the Vite development server on port 4173
npm run build Run project TypeScript builds and create dist/
npm run preview Serve the production bundle locally
npm run typecheck Run strict TypeScript checks without emitting files
npm run lint Lint the entire repository with zero warnings allowed
npm test Run the Vitest suite once
npm run qa:smoke Exercise core desktop/mobile flows against a running build

Before shipping a change, run:

npm run lint
npm run typecheck
npm test
npm run build

Controls

Keyboard bindings can be changed from Settings. Arrow keys remain available as a secondary movement option.

The interface theme can be changed at Settings → Appearance → Light theme. The selected theme is saved automatically on the device; gameplay and simulation canvases stay dark for consistent trap visibility.

Action Keyboard default Controller Touch
Move A / D or arrows D-pad or left stick Left/right pads
Jump / wall jump Space or up arrow South face button Jump pad
Run Shift West face button or right trigger Hold run pad
Retry R UI action Retry button
Pause / resume Esc or P Pause UI Pause UI

Movement includes coyote time and jump buffering. A wall jump is available while airborne and touching either wall. Some experiments temporarily reinterpret direction or gravity; that is intentional.

Experiments

The level definitions live in src/game/levels.ts. Coordinates use the fixed 960×540 simulation height and a horizontally scrolling worldWidth. Every LevelObject.x and LevelObject.y is the center of the object, matching Phaser's default origin.

# Experiment Primary deception
1 The Courtesy Floor Falling surfaces and proximity spikes
2 Dead Load Moving machinery, switch bridge, false anchor
3 Echo Routing Reversed input and linked portal loop
4 Upward Fall Gravity inversion and ceiling traversal
5 Blind Boundary Blackout, camera lie, false completion
6 Null Protocol Combined systems and behavioral misdirection

To author a built-in experiment, add a LevelDefinition to LEVELS. Keep object IDs unique inside the level. A targetId must match another object ID when a switch, portal, or false door needs a destination. Avoid traps that depend on frame rate; use Phaser timers or delta time.

Level editor

Open Level Editor from the main menu.

  1. Choose an object in the palette, then click the canvas, or drag the palette item directly to the desired position.
  2. Return to Select / move, select an object, and drag it. Placement snaps to a 32-pixel grid.
  3. Use the Inspector for exact coordinates, dimensions, rotation, timing, movement, target links, messages, and behavioral flags.
  4. Use Preview to seal an immutable snapshot and invoke the optional gameplay preview callback.
  5. Export the validated document as JSON. Import accepts editor documents up to 1 MB and rejects malformed versions, unsupported types, duplicate IDs, invalid coordinates, and unsafe dimensions.

Editor shortcuts:

Action Shortcut
Undo Ctrl/Cmd + Z
Redo Ctrl/Cmd + Y or Ctrl/Cmd + Shift + Z
Delete selected object Delete or Backspace
Nudge selected object Arrow keys
Precision nudge Shift + Arrow
Return to selection tool Esc

The working draft autosaves under trap-path-editor-draft-v1. Clearing a draft is undoable. The exported shape is deliberately small:

{
  "version": 1,
  "name": "Observation Test",
  "worldWidth": 1600,
  "spawn": { "x": 96, "y": 450 },
  "objects": [
    {
      "id": "entry-floor",
      "type": "platform",
      "x": 256,
      "y": 516,
      "width": 512,
      "height": 48
    },
    {
      "id": "exit",
      "type": "door",
      "x": 1480,
      "y": 452,
      "width": 44,
      "height": 64
    }
  ]
}

EditorDocument intentionally contains geometry only. When promoting a community level into the campaign, wrap its fields in a LevelDefinition and add the world, title, objective, hint, lore, palette, and modifier metadata.

Architecture

src/
├── App.tsx                 React screen orchestration and service wiring
├── audio/                  Howler manager and procedural sound patches
├── components/             Menus, HUD, dialogs, archive, mobile controls
├── editor/                 Editor UI, history, validation, JSON model
├── game/
│   ├── GameCanvas.tsx      React ↔ Phaser lifecycle boundary
│   ├── TrapPathScene.ts    Simulation, physics, traps, generated textures
│   ├── input.ts            Keyboard/controller/touch input bridge
│   ├── levels.ts           Hand-authored immutable level definitions
│   └── events.ts           Typed scene-to-React events
├── hooks/                  Elapsed time and game-event subscriptions
├── services/               Local/cloud leaderboard and cloud save adapters
├── store/                  Persisted global game state
├── styles/                 Tailwind layers and atmospheric global effects
├── types/                  Shared strict domain contracts
├── ui/                     Reusable buttons, panels, fields, logo, helpers
└── utils/                  Pure formatting helpers and tests

React owns navigation, settings, overlays, persistence, and responsive layout. Phaser owns the canvas, physics, collision, camera, particles, and frame loop. The two communicate through a typed event emitter and a small input bridge. This boundary prevents the 60 FPS simulation from causing React render churn.

TrapPathScene creates a fresh Phaser instance for the selected experiment. Textures are generated once per scene palette, while frequently used particles and sounds are reused through Phaser and Howler. The canvas runs at 960×540 and uses Phaser.Scale.FIT to preserve gameplay geometry at any display size.

Save data and privacy

Campaign state persists locally as trap-path-save-v1. Local leaderboard entries use trap-path-local-scores; the anonymous cloud-save adapter also stores a random device ID in trap-path-device-id. Editor drafts have their own key and are not included in campaign saves.

Resetting progress removes campaign progression through the store. Browser site data controls remain the authoritative way to remove every local key and cached PWA asset.

No secret key belongs in a Vite environment variable. Every VITE_* value is embedded in the client bundle and must be safe to expose.

Optional Supabase setup

Set these build-time variables in .env.local and in the deployment provider:

VITE_SUPABASE_URL=https://YOUR_PROJECT.supabase.co
VITE_SUPABASE_ANON_KEY=YOUR_PUBLIC_ANON_KEY

Never use the Supabase service_role key in this project. The public anon key is safe to publish only when Row Level Security (RLS) and restrictive policies are enabled.

Public leaderboard

The current adapter reads public scores and submits anonymous scores. Run the following in the Supabase SQL editor:

create table if not exists public.trap_path_scores (
  id uuid primary key default gen_random_uuid(),
  callsign text not null check (char_length(callsign) between 1 and 16),
  level integer not null check (level between 1 and 999),
  elapsed_ms bigint not null check (elapsed_ms between 0 and 86400000),
  deaths integer not null check (deaths between 0 and 1000000),
  created_at timestamptz not null default now()
);

create index if not exists trap_path_scores_rank_idx
  on public.trap_path_scores (level, elapsed_ms, deaths);

alter table public.trap_path_scores enable row level security;

create policy "Scores are publicly readable"
  on public.trap_path_scores
  for select
  to anon, authenticated
  using (true);

create policy "Clients may submit bounded scores"
  on public.trap_path_scores
  for insert
  to anon, authenticated
  with check (
    char_length(callsign) between 1 and 16
    and level between 1 and 999
    and elapsed_ms between 0 and 86400000
    and deaths between 0 and 1000000
  );

Client-submitted times are inherently untrusted. For a competitive leaderboard, send completions to a rate-limited Edge Function, validate a signed replay or run token server-side, and let only that function insert scores.

Anonymous device saves

The included adapter can upsert a save by a random device ID without a login. That provides convenience, not identity or confidentiality: anyone who learns a device ID could read or replace that row. Game progress is not sensitive, but this model must not store personal data.

For a development/demo deployment compatible with the current adapter:

create table if not exists public.trap_path_saves (
  device_id text primary key check (char_length(device_id) between 20 and 80),
  save_data jsonb not null
    check (octet_length(save_data::text) <= 65536),
  updated_at timestamptz not null default now()
);

alter table public.trap_path_saves enable row level security;

create policy "Demo device saves are readable"
  on public.trap_path_saves
  for select
  to anon
  using (true);

create policy "Demo device saves can be created"
  on public.trap_path_saves
  for insert
  to anon
  with check (octet_length(save_data::text) <= 65536);

create policy "Demo device saves can be updated"
  on public.trap_path_saves
  for update
  to anon
  using (true)
  with check (octet_length(save_data::text) <= 65536);

For production, use Supabase Auth, create a user-owned table, send the user's access token instead of the anon token in Authorization, and update the adapter to address rows by user_id:

create table if not exists public.trap_path_user_saves (
  user_id uuid primary key references auth.users(id) on delete cascade,
  save_data jsonb not null
    check (octet_length(save_data::text) <= 65536),
  updated_at timestamptz not null default now()
);

alter table public.trap_path_user_saves enable row level security;

create policy "Players read their own save"
  on public.trap_path_user_saves
  for select
  to authenticated
  using ((select auth.uid()) is not null and (select auth.uid()) = user_id);

create policy "Players create their own save"
  on public.trap_path_user_saves
  for insert
  to authenticated
  with check ((select auth.uid()) is not null and (select auth.uid()) = user_id);

create policy "Players update their own save"
  on public.trap_path_user_saves
  for update
  to authenticated
  using ((select auth.uid()) is not null and (select auth.uid()) = user_id)
  with check ((select auth.uid()) is not null and (select auth.uid()) = user_id);

Remove the demo table or revoke its anon policies when moving to authenticated saves. Review the Supabase RLS guide before exposing any additional table.

PWA and offline behavior

The production build registers public/sw.js. The worker:

  • Pre-caches the application entry, manifest, offline page, and original SVG icons after a successful first load.
  • Uses network-first navigation so a deployment is not permanently hidden behind an old shell.
  • Uses stale-while-revalidate for same-origin scripts, styles, fonts, images, audio, and workers.
  • Never caches non-GET requests, range requests, cross-origin Supabase traffic, or /api/ responses.
  • Deletes older TRAP PATH caches during activation.

The service worker runs only in production. Test it with:

npm run build
npm run preview

Use a private window or clear Application → Storage in browser developer tools when testing install/update behavior repeatedly. PWA installation and service workers require HTTPS, except on localhost.

When changing the app-shell caching strategy, increment CACHE_VERSION in public/sw.js.

Deployment

Always add the optional Supabase variables in the provider dashboard before the build. They are build-time Vite variables, not runtime server secrets.

Vercel

  1. Import the repository as a Vite project.
  2. Leave the build command as npm run build and output directory as dist.
  3. Add environment variables if cloud features are enabled.
  4. Deploy.

vercel.json contains the SPA rewrite, immutable hashed-asset caching, and must-revalidate headers for the service worker and manifest.

Netlify

  1. Import the repository.
  2. Netlify reads netlify.toml: Node 22, npm run build, and dist.
  3. Add optional environment variables and deploy.

The catch-all 200 rewrite supports SPA navigation while Netlify's normal shadowing behavior continues to serve real static files such as sw.js.

GitHub Pages

  1. Push the project to a repository whose default branch is master.
  2. Open Settings → Pages → Build and deployment and choose GitHub Actions.
  3. Push to master or run Deploy to GitHub Pages manually.

.github/workflows/deploy-pages.yml installs with npm ci, builds with the repository name as Vite's base path, uploads dist, and deploys through the official Pages artifact flow. public/.nojekyll ensures files are served without Jekyll processing.

For a user/organization root site or a custom domain, change the workflow's BASE_PATH to /. See GitHub's custom workflow guide.

Accessibility and performance

  • Every menu and editor action is keyboard reachable and has a visible focus state or accessible label.
  • Settings include reduced motion, screen-shake control, high contrast, color remapping, remappable keys, and independent audio levels.
  • CSS also honors the operating system's prefers-reduced-motion preference.
  • The game uses a fixed-step Arcade Physics loop, pixel-aligned rendering, generated textures, lazy-loaded Phaser/editor chunks, and an isolated canvas to avoid unnecessary React work.
  • Touch targets account for mobile safe areas; the viewport keeps its 16:9 geometry and scales without changing collision coordinates.

For a fair level, test each trap with reduced motion enabled, keyboard-only navigation, a narrow mobile viewport, and at both 30 and 60 FPS.

Extending the project

  • Add a trap by extending TrapObjectType, adding its visual/physics construction in TrapPathScene, defining its editor palette defaults and validation, and then adding focused tests.
  • Add a campaign level in src/game/levels.ts; do not mutate existing definitions at runtime.
  • Add a service behind the interfaces in src/services/; gameplay must retain a local fallback.
  • Keep simulation work in Phaser and interface work in React. Communicate across the boundary with typed events rather than DOM queries or global mutable state.

The guiding rule is simple: surprises may be cruel, but collision, timing, input, and recovery must remain deterministic enough for the player to learn.

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages