SOURCE / PINNED RELEASE
Made of little things.
Rail Game
- Release
- ccdd5b913413…
- Author-recorded commit
- ddf0122b6acc…
- License
- LICENSE
- Author’s source reference
- nostr://npub1n8ga89w8h6tvwamxusfyzexw8gjy84yxu9rxgnmk955cxtml4ujswzxydd/wss%3A%2F%2Fgit.napplet.soy%2F/n-91ec90c6fdf
Archive hash verified: dad7b678e25f96a3…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
export type Phase = 'ready' | 'playing' | 'paused' | 'won' | 'lost';
export type Kind = 'enemy' | 'barrier' | 'recruit' | 'multiply' | 'damage' | 'rapid' | 'shield' | 'boss';
export interface Entity {
id: number; kind: Kind; x: number; z: number; hp: number; maxHp: number;
value: number; width: number; hit: number; alive: boolean;
}
export interface Bullet { x: number; z: number; damage: number; hostile: boolean; vx: number }
export interface Particle { x: number; z: number; y: number; vx: number; vz: number; vy: number; life: number; color: string }
export interface GameEvent { text: string; tone: 'good' | 'bad' | 'neutral'; sound: 'pickup' | 'hit' | 'kill' | 'win' | 'shot' }
export interface Stage { name: string; subtitle: string; length: number; speed: number; bossHp: number }
export const STAGES: Stage[] = [
{ name: 'The causeway', subtitle: 'Build an army across four combat sectors.', length: 900, speed: 10, bossHp: 6000 },
{ name: 'Red freight', subtitle: 'Break through the freight-yard formations.', length: 1080, speed: 10.5, bossHp: 8000 },
{ name: 'Last platform', subtitle: 'The old terminus is only the beginning.', length: 1260, speed: 11, bossHp: 10000 },
{ name: 'Iron crossing', subtitle: 'Pick your route through staggered barricades.', length: 1440, speed: 11.5, bossHp: 12500 },
{ name: 'Ember viaduct', subtitle: 'Hold the line against deeper enemy ranks.', length: 1620, speed: 12, bossHp: 15000 },
{ name: 'Siege express', subtitle: 'Multiply your reserves. Keep the guns firing.', length: 1800, speed: 12.5, bossHp: 18000 },
{ name: 'Blackrock pass', subtitle: 'A long assault through the heaviest formations.', length: 1980, speed: 13, bossHp: 21000 },
{ name: 'The citadel', subtitle: 'Eleven sectors. One final warden. Bring everyone.', length: 2160, speed: 13.5, bossHp: 25000 },
];
export const STARTING_SQUAD = 12;
export const MAX_SQUAD = 250;
export const FIRE_INTERVAL = .22;
export const RAPID_FIRE_INTERVAL = .09;
export const clamp = (n: number, min: number, max: number): number => Math.min(max, Math.max(min, n));
/** UI-independent, fixed-step simulation. World z is distance ahead of the squad. */
export class RailGame {
phase: Phase = 'ready';
stage = 0;
distance = 0;
elapsed = 0;
x = 0;
squad = STARTING_SQUAD;
peakSquad = STARTING_SQUAD;
damage = 1;
rapid = 0;
shield = false;
score = 0;
kills = 0;
invincible = 0;
entities: Entity[] = [];
bullets: Bullet[] = [];
particles: Particle[] = [];
events: GameEvent[] = [];
reducedMotion = false;
private shotTimer = 0;
private bossTimer = 1.8;
private nextId = 1;
constructor(stage = 0) { this.prepare(stage); }
prepare(stage: number): void {
this.stage = clamp(Math.floor(stage), 0, STAGES.length - 1);
this.phase = 'ready'; this.distance = 0; this.elapsed = 0; this.x = 0;
this.squad = STARTING_SQUAD; this.peakSquad = STARTING_SQUAD; this.damage = 1; this.rapid = 0; this.shield = false;
this.score = 0; this.kills = 0; this.invincible = 0;
this.shotTimer = 0; this.bossTimer = 1.8; this.nextId = 1;
this.entities = []; this.bullets = []; this.particles = []; this.events = [];
this.buildStage();
}
get definition(): Stage { return STAGES[this.stage]; }
get boss(): Entity | undefined { return this.entities.find(e => e.kind === 'boss' && e.alive); }
get progress(): number { return Math.min(1, this.distance / this.definition.length); }
start(): void { if (this.phase === 'ready' || this.phase === 'paused') this.phase = 'playing'; }
pause(): void { if (this.phase === 'playing') this.phase = 'paused'; }
private add(kind: Kind, lane: number, z: number, value = 0, hp = 1): void {
this.entities.push({ id: this.nextId++, kind, x: lane * 2, z, hp, maxHp: hp, value,
width: kind === 'boss' ? 2 : kind === 'enemy' ? .3 : 1.65, hit: 0, alive: true });
}
private wave(lane: number, z: number, hp: number, rows = 6 + Math.min(3, Math.floor(this.stage / 2))): void {
for (let row = 0; row < rows; row++) {
for (let column = -2; column <= 2; column++) {
this.add('enemy', lane + column * .17, z + row * 1.7, 0, hp);
}
}
}
private buildStage(): void {
const s = this.stage;
this.add('recruit', 0, 28, 24);
this.wave(0, 50, 4 + s);
this.wave(-1, 54, 4 + s, 3); this.wave(1, 58, 4 + s, 3);
this.add('recruit', -1, 68, 24); this.add('multiply', 1, 68, 2);
// Four to eleven authored sectors, with rotated routes and alternating
// crossfire, barricade and reinforcement patterns. No empty stretched track.
for (let sector = 0; sector < 4 + s; sector++) {
const z = 108 + sector * 180;
const lane = (sector + s) % 3 - 1;
const flank = (sector + s + 1) % 3 - 1;
const spare = (sector + s + 2) % 3 - 1;
const hp = 7 + s * 3 + sector * 2;
this.add('recruit', lane, z, 24 + s * 3 + sector * 2);
this.add('multiply', flank, z, sector % 3 === 2 ? 3 : 2);
this.wave(lane, z + 24, hp);
this.wave(flank, z + 30, hp);
this.add('barrier', spare, z + 52);
this.add('damage', flank, z + 65);
this.add('recruit', lane, z + 65, 30 + s * 4);
this.wave(spare, z + 88, hp + 3);
this.wave(flank, z + 94, hp + 2);
if (sector % 3 === 1) this.wave(lane, z + 88, hp + 2);
this.add('rapid', lane, z + 121);
this.add('shield', spare, z + 121);
this.add('barrier', flank, z + 141);
if (sector % 3 === 2) this.add('barrier', spare, z + 141);
this.wave(lane, z + 151, hp + 4);
}
// The final resupply gives a depleted squad a fighting chance at the arena.
this.add('recruit', 0, this.definition.length - 62, 60 + s * 5);
this.add('damage', 0, this.definition.length - 44);
this.add('rapid', 0, this.definition.length - 26);
this.add('boss', 0, this.definition.length + 20, 0, this.definition.bossHp);
}
/** A bounded formation keeps every soldier and projectile inside the chosen lane. */
formation(index: number): { x: number; z: number } {
const columns = Math.min(14, Math.ceil(Math.sqrt(this.squad * 1.6)));
const row = Math.floor(index / columns);
const count = Math.min(columns, this.squad - row * columns);
return { x: ((index % columns) - (count - 1) / 2) * .1, z: 1.4 - row * .32 };
}
private emit(text: string, tone: GameEvent['tone'], sound: GameEvent['sound']): void {
this.events.push({ text, tone, sound });
}
private burst(x: number, z: number, color: string, count = 14): void {
if (this.reducedMotion) return;
for (let i = 0; i < count && this.particles.length < 420; i++) {
const a = i * 2.39996;
this.particles.push({ x, z, y: .5, vx: Math.cos(a) * 1.8, vz: Math.sin(a) * 2,
vy: 2 + (i % 3), life: .5 + (i % 4) * .12, color });
}
}
private hurt(loss: number): void {
if (this.invincible > 0) return;
this.invincible = .65;
if (this.shield) {
this.shield = false; this.emit('Shield absorbed the hit', 'neutral', 'pickup');
this.burst(this.x, 0, '#83caff'); return;
}
const removed = Math.min(loss, this.squad);
this.squad -= removed;
this.emit(`−${removed} soldiers`, 'bad', 'hit');
this.burst(this.x, 0, '#ff897e');
if (this.squad <= 0) this.phase = 'lost';
}
private collect(entity: Entity): void {
switch (entity.kind) {
case 'recruit': this.squad = Math.min(MAX_SQUAD, this.squad + entity.value); this.emit(`+${entity.value} soldiers`, 'good', 'pickup'); break;
case 'multiply': this.squad = Math.min(MAX_SQUAD, this.squad * entity.value); this.emit(`Squad ×${entity.value}`, 'good', 'pickup'); break;
case 'damage': this.damage = Math.min(4, this.damage + 1); this.emit(`Damage ×${this.damage}`, 'good', 'pickup'); break;
case 'rapid': this.rapid = 10; this.emit('Rapid fire · 10 seconds', 'good', 'pickup'); break;
case 'shield': this.shield = true; this.emit('Shield equipped', 'good', 'pickup'); break;
case 'enemy': this.hurt(3 + this.stage); return;
case 'barrier': this.hurt(Math.max(6 + this.stage, Math.ceil(this.squad * .3))); return;
default: return;
}
this.peakSquad = Math.max(this.peakSquad, this.squad);
this.score += 50;
this.burst(entity.x, 0, '#78edbb');
}
update(dt: number, direction = 0, targetX?: number): void {
if (this.phase !== 'playing') return;
dt = clamp(dt, 0, .05);
this.elapsed += dt;
this.invincible = Math.max(0, this.invincible - dt);
this.rapid = Math.max(0, this.rapid - dt);
if (targetX !== undefined) {
this.x += clamp(targetX - this.x, -7 * dt, 7 * dt);
} else { this.x += direction * 4.6 * dt; }
this.x = clamp(this.x, -2.25, 2.25);
const travel = Math.min(this.definition.speed * dt, this.definition.length - this.distance);
this.distance += travel;
for (const e of this.entities) {
if (!e.alive) continue;
e.z -= travel; e.hit = Math.max(0, e.hit - dt);
if (e.kind === 'boss' && this.progress >= 1) {
e.x = Math.sin(this.elapsed * .8) * 1.65;
}
if (e.z <= .35 && e.kind !== 'boss') {
if (Math.abs(e.x - this.x) < e.width / 2 + .17) this.collect(e);
e.alive = false;
if (this.phase !== 'playing') return;
}
}
this.shotTimer -= dt;
if (this.shotTimer <= 0) {
this.shotTimer += this.rapid > 0 ? RAPID_FIRE_INTERVAL : FIRE_INTERVAL;
for (let i = 0; i < this.squad; i++) {
const offset = this.formation(i);
this.bullets.push({ x: this.x + offset.x, z: offset.z + .9, damage: this.damage, hostile: false, vx: 0 });
}
this.events.push({ text: '', tone: 'neutral', sound: 'shot' });
}
const boss = this.boss;
if (boss && this.progress >= 1) {
this.bossTimer -= dt;
if (this.bossTimer <= 0) {
this.bossTimer = Math.max(.9, 1.7 - this.stage * .12);
const speed = 15 + this.stage * 2;
const flightTime = (boss.z - 1) / speed;
for (let shot = -1 - Math.min(3, this.stage); shot <= 1 + Math.min(3, this.stage); shot++) {
const vx = (this.x + shot * .55 - boss.x) / flightTime;
this.bullets.push({ x: boss.x, z: boss.z - 1, damage: 6 + this.stage * 2, hostile: true, vx });
}
}
}
// Restrict dense volleys to nearby targets, in front-to-back order. A shot can
// stop searching as soon as the next target lies beyond its swept segment.
const targets = this.entities.filter(e => e.alive && e.z >= -.65 && e.z <= 106 &&
(e.kind === 'enemy' || e.kind === 'boss' || e.kind === 'barrier')).sort((a, b) => a.z - b.z);
for (const b of this.bullets) {
const previousZ = b.z;
b.z += (b.hostile ? -(15 + this.stage * 2) : 58) * dt;
b.x += b.vx * dt;
if (b.hostile) {
if (b.z <= .3) {
if (Math.abs(b.x - this.x) < .52) this.hurt(b.damage);
b.z = -100;
if (this.squad <= 0) return;
}
continue;
}
// Nearest intersecting target blocks the shot; swept z prevents tunnelling.
let target: Entity | undefined;
for (const e of targets) {
if (e.z - .65 > b.z) break;
if (!e.alive) continue;
if (Math.abs(e.x - b.x) > e.width / 2 + .07) continue;
if (e.z + .65 < previousZ || e.z - .65 > b.z) continue;
target = e;
break;
}
if (!target) continue;
b.z = 1000;
if (target.kind === 'barrier') continue;
if (target.kind === 'boss' && this.progress < 1) continue;
target.hp -= b.damage; target.hit = .09;
if (target.hp <= 0) {
target.alive = false; this.kills++; this.score += target.kind === 'boss' ? 1000 : 100;
this.burst(target.x, target.z, target.kind === 'boss' ? '#ffcd72' : '#ff897e', target.kind === 'boss' ? 50 : 8);
if (target.kind === 'boss') {
this.phase = 'won'; this.score += this.squad * 25;
this.emit('Stage cleared', 'good', 'win');
break;
}
this.events.push({ text: '', tone: 'neutral', sound: 'kill' });
}
}
this.bullets = this.bullets.filter(b => b.z > -8 && b.z < 105 && Math.abs(b.x) < 5);
for (const p of this.particles) {
p.x += p.vx * dt; p.z += p.vz * dt - travel; p.y += p.vy * dt; p.vy -= 10 * dt; p.life -= dt;
}
this.particles = this.particles.filter(p => p.life > 0);
}
}
