Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions out_run_clone/BUILD.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# 🏎️ OUT RUN — Arcade Clone Build Plan

> A faithful browser-based recreation of Sega's 1986 Out Run arcade game using HTML5 Canvas and vanilla JavaScript. No frameworks, no dependencies — just pure pseudo-3D driving bliss.

This document is the design/build plan for the `out_run_clone/` project. The current implementation covers the pseudo-3D road engine (curves + hills), player physics with gear shifting, procedural roadside sprites and traffic with collision detection, the full 15-stage branching pyramid with fork choices, a HUD, title/radio-select/game-over/course-clear screens, and Web-Audio-based engine sound, procedural radio music, and SFX — all with keyboard and touch controls.

See the project README for how to run it. The sections below are the original design reference used while building it.

## Tech Stack
- **Rendering**: HTML5 `<canvas>` (2D context)
- **Logic**: Vanilla JavaScript (ES modules)
- **Styling**: Vanilla CSS
- **Audio**: Web Audio API (procedural — no external audio files)
- **Assets**: Procedurally drawn canvas sprites (no external image files)

## Architecture

```
out_run_clone/
├── index.html # Entry point
├── index.css # Global styles, CRT/retro effects
├── js/
│ ├── main.js # Bootstrap, game loop, state machine
│ ├── constants.js # All tuning constants in one place
│ ├── render/
│ │ ├── road.js # Pseudo-3D road projection & drawing
│ │ ├── background.js # Parallax sky/horizon/ground layers
│ │ ├── sprites.js # Procedural sprite drawing/scaling/positioning
│ │ └── hud.js # Speedometer, timer, score, gear indicator
│ ├── game/
│ │ ├── player.js # Player car: physics, steering, acceleration
│ │ ├── traffic.js # AI traffic cars: spawning, movement, lanes
│ │ ├── collision.js # Collision detection (car↔car, car↔roadside)
│ │ ├── stages.js # Stage pyramid, theming, track generation
│ │ └── camera.js # Camera height/depth, crash screen-shake
│ ├── audio/
│ │ └── audio.js # Engine sound, procedural radio music, SFX
│ └── utils/
│ ├── math.js # Interpolation, easing, projection, seeded RNG
│ └── input.js # Keyboard/touch input handler
└── BUILD.md # This file
```

## Controls

| Key | Action |
|---|---|
| `↑` / `W` | Accelerate |
| `↓` / `S` | Brake |
| `←` / `A` | Steer left / choose left fork |
| `→` / `D` | Steer right / choose right fork |
| `Space` | Shift gear (toggle Low ↔ High) |
| `Enter` | Start / select |
| `1` / `2` / `3` | Quick-select radio station |

Touch controls (steer, gas, brake, gear, start) appear automatically on coarse-pointer (mobile/tablet) devices.

## Stage Pyramid

15 stages across 5 rows, forking left/right at the end of each non-goal stage (adjacent parents share a child, matching the original arcade map):

```
Row 1: Coconut Beach
Row 2: Gateway · Devil's Canyon
Row 3: Desert · Alps · Cloudy Mountain
Row 4: Wilderness · Old Capital · Wheat Field · Seaside Town
Row 5: Vineyard(A) · Death Valley(B) · Desolation Hill(C) · Autobahn(D) · Lakeside(E)
```

Reaching a goal stage's end completes the run; running out of time coasts the car to a stop and ends the game.

## Stretch ideas not yet implemented
- Gamepad support
- Night mode / weather effects
- Two-road (wide highway) rendering
- Replay system / online leaderboard
105 changes: 105 additions & 0 deletions out_run_clone/index.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
* { box-sizing: border-box; }

html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background: #000;
overflow: hidden;
font-family: "Courier New", monospace;
-webkit-user-select: none;
user-select: none;
}

#game-frame {
position: relative;
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #000;
}

#game-canvas {
width: min(100vw, 133.33vh);
height: min(75vw, 100vh);
aspect-ratio: 4 / 3;
image-rendering: optimizeSpeed;
display: block;
background: #000;
}

#crt-overlay {
position: absolute;
inset: 0;
pointer-events: none;
background:
repeating-linear-gradient(
to bottom,
rgba(0, 0, 0, 0.18) 0px,
rgba(0, 0, 0, 0.18) 1px,
transparent 2px,
transparent 3px
);
mix-blend-mode: multiply;
opacity: 0.55;
}

#crt-overlay::after {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(ellipse at center, rgba(255,255,255,0) 55%, rgba(0,0,0,0.35) 100%);
}

#touch-controls {
display: none;
position: fixed;
inset: 0;
pointer-events: none;
z-index: 10;
}

@media (pointer: coarse) {
#touch-controls { display: block; }
}

.touch-col {
position: absolute;
bottom: 18px;
display: flex;
gap: 12px;
pointer-events: auto;
}
.touch-col.left { left: 18px; align-items: flex-end; }
.touch-col.right { right: 18px; flex-direction: column; align-items: flex-end; gap: 10px; }

.touch-btn {
width: 68px;
height: 68px;
border-radius: 50%;
border: 2px solid rgba(255,255,255,0.6);
background: rgba(20,20,30,0.55);
color: #fff;
font-size: 20px;
font-weight: 700;
font-family: inherit;
touch-action: manipulation;
}
.touch-btn.small { width: 58px; height: 58px; font-size: 12px; background: rgba(255,120,40,0.5); }
.touch-btn:active { background: rgba(255,255,255,0.35); }

.touch-btn.start {
position: absolute;
top: 18px;
right: 18px;
width: auto;
height: 44px;
border-radius: 8px;
padding: 0 16px;
font-size: 14px;
pointer-events: auto;
background: rgba(255, 210, 40, 0.5);
}
31 changes: 31 additions & 0 deletions out_run_clone/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" />
<title>OUT RUN — Arcade Clone</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23c81010'/%3E%3Ctext x='16' y='23' font-size='20' text-anchor='middle' fill='%23fff'%3E%F0%9F%8F%8E%EF%B8%8F%3C/text%3E%3C/svg%3E" />
<link rel="stylesheet" href="index.css" />
</head>
<body>
<div id="game-frame">
<canvas id="game-canvas" width="1024" height="768"></canvas>
<div id="crt-overlay"></div>
</div>

<div id="touch-controls">
<div class="touch-col left">
<button id="btn-left" class="touch-btn" aria-label="Steer left">◀</button>
<button id="btn-right" class="touch-btn" aria-label="Steer right">▶</button>
</div>
<div class="touch-col right">
<button id="btn-gear" class="touch-btn small" aria-label="Shift gear">GEAR</button>
<button id="btn-brake" class="touch-btn" aria-label="Brake">BRK</button>
<button id="btn-accel" class="touch-btn" aria-label="Accelerate">GAS</button>
</div>
<button id="btn-start" class="touch-btn start" aria-label="Start / Select">ENTER</button>
</div>

<script type="module" src="js/main.js"></script>
</body>
</html>
131 changes: 131 additions & 0 deletions out_run_clone/js/audio/audio.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Web Audio API based sound: a speed-mapped engine drone, tiny procedural
// radio "music" loops (no external audio files), and short SFX blips.

import { RADIO_STATIONS } from '../constants.js';

const SCALES = {
latin: [0, 3, 5, 7, 10, 12, 15, 19],
jazz: [0, 2, 3, 7, 9, 12, 14, 19],
synth: [0, 2, 4, 7, 9, 12, 16, 19],
};

export class AudioManager {
constructor() {
this.ctx = null;
this.engineOsc = null;
this.engineGain = null;
this.musicTimer = null;
this.stationIndex = 0;
this.muted = false;
}

ensureContext() {
if (this.ctx) return;
const Ctx = window.AudioContext || window.webkitAudioContext;
this.ctx = new Ctx();
this._setupEngine();
}

_setupEngine() {
const ctx = this.ctx;
this.engineOsc = ctx.createOscillator();
this.engineOsc.type = 'sawtooth';
this.engineGain = ctx.createGain();
this.engineGain.gain.value = 0.0;
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 800;
this.engineOsc.connect(filter);
filter.connect(this.engineGain);
this.engineGain.connect(ctx.destination);
this.engineOsc.frequency.value = 60;
this.engineOsc.start();
}

setEngineSpeed(speedPercent) {
if (!this.ctx || this.muted) return;
const freq = 60 + speedPercent * 260;
this.engineOsc.frequency.setTargetAtTime(freq, this.ctx.currentTime, 0.05);
this.engineGain.gain.setTargetAtTime(0.05 + speedPercent * 0.05, this.ctx.currentTime, 0.05);
}

stopEngine() {
if (this.engineGain) this.engineGain.gain.setTargetAtTime(0, this.ctx.currentTime, 0.1);
}

playRadio(index) {
this.ensureContext();
this.stationIndex = index;
this._stopMusic();
if (this.muted) return;
const station = RADIO_STATIONS[index];
const scale = SCALES[station.style];
const root = 220;
const stepMs = 60000 / station.tempo / 2;
let step = 0;
this.musicTimer = setInterval(() => {
const note = scale[Math.floor(Math.random() * scale.length) % scale.length];
const octave = (step % 8 < 6) ? 0 : 12;
this._pluck(root * Math.pow(2, (note + octave) / 12), stepMs / 1000 * 1.6, step % 4 === 0 ? 0.09 : 0.05);
step++;
}, stepMs);
}

_stopMusic() {
if (this.musicTimer) clearInterval(this.musicTimer);
this.musicTimer = null;
}

_pluck(freq, duration, gainAmount) {
if (!this.ctx) return;
const ctx = this.ctx;
const osc = ctx.createOscillator();
osc.type = 'triangle';
osc.frequency.value = freq;
const gain = ctx.createGain();
gain.gain.setValueAtTime(gainAmount, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + duration);
}

sfx(name) {
this.ensureContext();
if (this.muted) return;
const ctx = this.ctx;
if (name === 'crash') {
const bufferSize = ctx.sampleRate * 0.35;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / bufferSize);
const src = ctx.createBufferSource();
src.buffer = buffer;
const gain = ctx.createGain();
gain.gain.value = 0.5;
src.connect(gain);
gain.connect(ctx.destination);
src.start();
} else if (name === 'checkpoint') {
this._pluck(880, 0.12, 0.2);
setTimeout(() => this._pluck(1320, 0.15, 0.2), 90);
} else if (name === 'gear') {
this._pluck(200, 0.06, 0.15);
} else if (name === 'countdown') {
this._pluck(1000, 0.08, 0.2);
} else if (name === 'select') {
this._pluck(660, 0.08, 0.15);
} else if (name === 'start') {
this._pluck(440, 0.1, 0.2);
setTimeout(() => this._pluck(660, 0.12, 0.2), 100);
setTimeout(() => this._pluck(880, 0.18, 0.2), 200);
}
}

toggleMute() {
this.muted = !this.muted;
if (this.muted) this._stopMusic();
return this.muted;
}
}
Loading