Skip to content
Merged
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
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,26 @@ npm run dev & # or any server URL as the second arg
node scripts/capture-evidence.mjs
```

## Headless playtest

`scripts/playtest.mjs` drives the **production build** with a simple but
honest bot: every 50 ms it reads the live game state, dispatches real
keyboard events, and tries to win. A scenario fails on a softlock (no forward
progress for 10 s), a page error, or a timeout; hand-built levels must reach
the victory state.

```sh
npm run preview -- --port 4173 --strictPort & # the bot expects a server
node scripts/playtest.mjs # all levels (0-4) + daily
node scripts/playtest.mjs 0 2 4 # specific hand-built levels
node scripts/playtest.mjs daily # daily challenge only
node scripts/playtest.mjs dusk-stress # Duskfen late-tide respawn stress
```

Per-scenario screenshots and jump-decision logs land in `/tmp/tinyrex-e2e/`
(`*.png`, `*.jumps.json`). The bot is intentionally naive — it exists to
catch uncompletable layouts, softlocks, and crashes, not to beat the game.

## Controls

| Action | Keyboard | Touch |
Expand Down Expand Up @@ -166,7 +186,7 @@ src/
sprite.ts Procedural drawing of Rex + all enemies.
game.ts State machine, fixed-timestep loop, HUD, menus.
touch-controls.ts DOM touch-button bindings.
level-data.ts LEVELS — the two hand-designed levels (Crystal Valley, Volcanic Depths).
level-data.ts LEVELS — the five hand-designed levels (Crystal Valley, Volcanic Depths, Frostpeak Pass, Molten Nest, Duskfen).
util.ts Small shared helpers (clamp, lerp, overlap, rng, fmtTime).
tests/ Vitest suite: physics, state machine, level-data integrity.
README.md This file.
Expand Down
1,291 changes: 1,291 additions & 0 deletions scripts/playtest.mjs

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions src/boss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,9 @@ export class MagmaKing {

fireAt(player: Player): void {
const g = this.level.game;
// The entry pocket is a deliberate recovery space before the fight. Do
// not aim a spread through the arena wall while Rex is still entering.
if (player.x > this.minX - 220 && player.x + player.w < this.minX - 40) return;
const bx = this.x + this.w / 2;
const by = this.y + 34;
const px = player.x + player.w / 2;
Expand Down
5 changes: 3 additions & 2 deletions src/cheats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ export const CHEATS: CheatDef[] = [
{ id: 'god', seq: ['left', 'left', 'right', 'right', 'up', 'up', 'primary'], windowMs: 900, cooldownMs: 3000 },
// Max hearts (one time)
{ id: 'maxhearts', seq: ['down', 'down', 'up', 'up', 'left', 'right', 'primary'], windowMs: 900, cooldownMs: 60000, once: true },
// Score surge: triple-tap jump
{ id: 'surge', seq: ['primary', 'primary', 'primary'], windowMs: 560, cooldownMs: 12000 },
// Score surge: down, down, jump. 'down' is never part of normal movement,
// so rapid jumping in play can no longer false-fire the +1000 surge.
{ id: 'surge', seq: ['down', 'down', 'primary'], windowMs: 900, cooldownMs: 12000 },
];

/**
Expand Down
74 changes: 59 additions & 15 deletions src/daily.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ export function generateDailyLevel(seed: number): LevelInfo {
if (i >= 1 && rnd() < 0.8) {
const pw = ri(110, 130);
const t1: PlatformDef = {
x: seg.x0 + ri(40, Math.max(41, w - pw - 40)),
// Leave a readable run-up after each pit before the first step.
x: seg.x0 + ri(140, Math.max(141, w - pw - 40)),
y: GY - ri(50, 80),
w: pw,
h: 24,
Expand All @@ -114,17 +115,48 @@ export function generateDailyLevel(seed: number): LevelInfo {
def.crystals.push({ x: t1.x + t1.w / 2, y: t1.y - 35 });
// Tier-2 platform above a tier-1 (step ≤110px)
if (rnd() < 0.45) {
const t2: PlatformDef = {
x: Math.max(seg.x0 + 20, Math.min(t1.x + ri(-120, 120), seg.x1 - 140)),
y: t1.y - ri(40, 70),
w: ri(100, 120),
h: 24,
type: 'stone',
};
def.platforms.push(t2);
def.crystals.push({ x: t2.x + t2.w / 2, y: t2.y - 35, bonus: rnd() < 0.35 });
// Keep the upper tier after the lower one. A tier that starts behind
// its approach slab can be mistaken for the primary target, causing
// a full jump from the floor to overshoot both platforms.
const t2Min = t1.x + t1.w + 20;
const t2Max = Math.min(t1.x + t1.w + 140, seg.x1 - 140);
if (t2Max >= t2Min) {
const t2: PlatformDef = {
x: ri(t2Min, t2Max),
y: t1.y - ri(40, 70),
w: ri(100, 120),
h: 24,
type: 'stone',
};
def.platforms.push(t2);
def.crystals.push({ x: t2.x + t2.w / 2, y: t2.y - 35, bonus: rnd() < 0.35 });
}
}
}
const floorHazard = def.hazards.find((h) =>
(h.type === 'spikes' || h.type === 'rocks') && h.x >= seg.x0 && h.x < seg.x1);
if (floorHazard) {
// A floor hazard directly below a tier platform can turn the intended
// landing into an unavoidable hit. Relocate it to open floor space so
// every generated hazard retains readable counterplay.
const candidates: number[] = [];
for (let sx = seg.x0 + 40; sx <= seg.x1 - floorHazard.w - 40; sx += 10) candidates.push(sx);
const originalX = floorHazard.x;
const open = candidates
.filter((sx) => !def.platforms.some((pl) =>
pl.y < GY && pl.x < sx + floorHazard.w + 12 && pl.x + pl.w > sx - 12))
.sort((a, b) => Math.abs(a - originalX) - Math.abs(b - originalX))[0];
if (open !== undefined) floorHazard.x = open;
else {
// A wide rockfall may have no clear floor slot in a compact segment.
// Omit it rather than making the only elevated route random damage.
const hazardIndex = def.hazards.indexOf(floorHazard);
if (hazardIndex >= 0) def.hazards.splice(hazardIndex, 1);
if (floorHazard.type === 'rocks') seg.hasRocks = false;
else seg.hasSpikes = false;
}
}

// Ground crystal (skip when spikes occupy the floor); every segment is
// guaranteed at least one crystal so no daily run is content-light.
const segCrystalsBefore = def.crystals.length;
Expand All @@ -138,13 +170,25 @@ export function generateDailyLevel(seed: number): LevelInfo {
// Enemy: one per segment, never in the first two
if (i >= 2) {
const roll = rnd();
if (roll < 0.35 && seg.tier1) {
def.enemies.push({ type: 'spitter', x: seg.tier1.x + seg.tier1.w / 2, y: seg.tier1.y - 38 });
} else if (roll < 0.55) {
if (roll < 0.55) {
def.enemies.push({ type: 'ptero', x: seg.x0 + w / 2, y: ri(250, 300), range: ri(100, 150) });
} else {
const patrol = ri(120, Math.min(250, w - 120));
const ex = seg.x0 + ri(60, Math.max(61, w - patrol - 60));
let patrol = ri(120, Math.min(250, w - 120));
let ex = seg.x0 + ri(60, Math.max(61, w - patrol - 60));
// Do not place a ground walker directly under the landing line from
// an elevated ledge. The player is still airborne while dropping to
// the lower floor, so this otherwise creates an unavoidable collision
// before the enemy-hop counterplay becomes available.
const elevatedEnds = def.platforms
.filter((pl) => pl.y < GY && pl.x >= seg.x0 && pl.x < seg.x1)
.map((pl) => pl.x + pl.w);
if (elevatedEnds.length) {
const clearStart = Math.max(...elevatedEnds) + 160;
if (clearStart + patrol > seg.x1 - 24 && clearStart < seg.x1 - 124) {
patrol = Math.max(120, seg.x1 - clearStart - 24);
}
if (clearStart + patrol <= seg.x1 - 24 && ex < clearStart) ex = clearStart;
}
const type = rnd() < 0.5 ? 'beetle' : 'trike';
def.enemies.push({ type, x: ex + patrol / 2, y: type === 'beetle' ? 432 : 424, minX: ex, maxX: ex + patrol });
}
Expand Down
5 changes: 4 additions & 1 deletion src/enemy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export class Enemy {
range: number;
/** Spitter: seconds until the next glob. */
fireCd: number = CFG.spitter.fireCd;
/** Optional per-placement range for readable, localised projectile threats. */
private readonly fireRange: number;
/** Spitter: 1 right after firing, eases to 0 (nozzle recoil/glow). */
charge = 0;
/** Spitter: faces the player. */
Expand All @@ -50,6 +52,7 @@ export class Enemy {
this.minX = d.minX !== undefined ? d.minX : d.x - 60;
this.maxX = d.maxX !== undefined ? d.maxX : d.x + 60;
this.dir = d.dir || 1;
this.fireRange = d.range ?? CFG.spitter.range;
this.ax = d.x;
this.ay = d.y;
this.range = d.range || 130;
Expand Down Expand Up @@ -159,7 +162,7 @@ export class Enemy {
const dx = pcx - ecx;
const dyFeet = player.y + 46 - (this.y + this.h);
const target = !player.dead && player.state !== 'victory' &&
Math.abs(dx) < CFG.spitter.range && Math.abs(dyFeet) < CFG.spitter.band;
Math.abs(dx) < this.fireRange && Math.abs(dyFeet) < CFG.spitter.band;
this.facing = dx >= 0 ? 1 : -1;
if (target) {
this.fireCd -= dt;
Expand Down
100 changes: 81 additions & 19 deletions src/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@ export class Game implements GameCtx {
lastRun: RunRecord | null = null;
/** Rank (1-based) of lastRun inside topRuns(10); null when it didn't place. */
lastRunRank: number | null = null;
/** Set when a cheat fires during the current run; cheat runs stay out of
* the Hall of Claws and don't overwrite the stored ghost track. */
private cheatUsedThisRun = false;
private ghost: GhostPlayer | null = null;
private ghostRec: GhostRecorder | null = null;
/** Cheat queue: apply max hearts once a player exists. */
Expand Down Expand Up @@ -373,6 +376,7 @@ export class Game implements GameCtx {
this.victoryT = 0;
this.pbAnnounced = false;
this.pbBaseline = this.best.score;
this.cheatUsedThisRun = false;
// Ghost race: record this run and replay the stored best alongside it
this.ghostRec = new GhostRecorder();
this.ghost = null;
Expand Down Expand Up @@ -437,6 +441,9 @@ export class Game implements GameCtx {
}

handleKey(k: GameKey): void {
// A keydown is a user gesture: unlock audio so menu SFX work even when
// the player never touches the mouse (idempotent + cheap when unlocked).
this.audio.unlock();
// Cheat codes: every press in the menu or in play feeds the detector.
if (this.state === 'menu' || this.state === 'playing') {
const fired = this.cheats.press(k, performance.now());
Expand Down Expand Up @@ -530,6 +537,7 @@ export class Game implements GameCtx {
/** Apply a matched cheat code (see CHEATS in src/cheats.ts). */
private applyCheat(id: CheatId): void {
this.audio.play('cheat');
if (this.state === 'playing') this.cheatUsedThisRun = true;
const p = this.player;
if (id === 'rainbow') {
this.rainbow = !this.rainbow;
Expand Down Expand Up @@ -795,34 +803,47 @@ export class Game implements GameCtx {
this.best = newBest;
}
// Ghost race: keep this run's track when it sets a new best score
// (cheat-assisted runs don't get to rewrite the ghost)
const rec = this.ghostRec;
this.ghostRec = null;
if (rec && isBestScore) {
if (rec && isBestScore && !this.cheatUsedThisRun) {
const track = rec.finish(this.score, this.elapsed);
if (track) {
track.date = this.daily ? dailySeed() : -1;
saveGhostTrack(this.daily ? -1 : this.levelIdx, track);
}
}
// Hall of Claws: carve this run into the local leaderboard
const hallRec: RunRecord = {
score: this.score,
time: this.elapsed,
level: this.daily ? 'Daily · ' + new Date().toLocaleDateString() : LEVELS[this.levelIdx].name,
difficulty: this.difficulty,
date: Date.now(),
};
addRun(hallRec);
// Object identity is lost through the localStorage round-trip, so match by content.
const rank = topRuns(10).findIndex(
(r) => r.score === hallRec.score && r.date === hallRec.date && r.level === hallRec.level,
);
this.lastRun = hallRec;
this.lastRunRank = rank >= 0 ? rank + 1 : null;
// Hall of Claws: carve this run into the local leaderboard. Cheat-assisted
// runs are excluded so the board stays honest.
if (!this.cheatUsedThisRun) {
const hallRec: RunRecord = {
score: this.score,
time: this.elapsed,
level: this.daily ? 'Daily · ' + new Date().toLocaleDateString() : LEVELS[this.levelIdx].name,
difficulty: this.difficulty,
date: Date.now(),
};
addRun(hallRec);
// Object identity is lost through the localStorage round-trip, so match by content.
const rank = topRuns(10).findIndex(
(r) => r.score === hallRec.score && r.date === hallRec.date && r.level === hallRec.level,
);
this.lastRun = hallRec;
this.lastRunRank = rank >= 0 ? rank + 1 : null;
} else {
this.lastRun = null;
this.lastRunRank = null;
}
// Lifetime stats
const s = getStats();
s.victories += 1;
s.crystals += this.crystalsGot;
// All-clear: every handcrafted level finished at least once (the daily
// challenge is excluded). A level's best time is non-null exactly once it
// has been completed, so that is the reliable "cleared" marker.
if (!this.daily) {
s.allClear = LEVELS.every((_, i) => getBest(i).time !== null);
}
Store.set('tinyrex_stats', s);
this.stats = s;
this.audio.stopMusic();
Expand Down Expand Up @@ -876,6 +897,15 @@ export class Game implements GameCtx {
this.addStatus('The tide is rising!', '#8fd0ff');
this.audio.play('tide');
}
// Tension cue: once the water has climbed 80% of its total rise, ping a
// second, more urgent status and flag the level so the render creeps a
// subtle blue glow in from the screen edges.
const t = lvl.tide;
const risen = (t.fromY - lvl.waterY) / Math.max(1, t.fromY - t.toY);
if (!lvl.tideTense && risen >= 0.8) {
lvl.tideTense = true;
this.addStatus('The water climbs…', '#8fd0ff');
}
}

update(dt: number): void {
Expand Down Expand Up @@ -1086,6 +1116,20 @@ export class Game implements GameCtx {
ctx.lineWidth = 2;
ctx.stroke();
}
// Tension: once the tide is 80% risen, a cold blue light creeps in from
// the screen edges (a steady glow under reduced motion).
if (this.level.tideTense) {
const pulse = this.reducedMotion ? 1 : 0.5 + 0.5 * Math.sin(this.time * 2.2);
const a = 0.1 + 0.1 * pulse;
const eg = ctx.createRadialGradient(
VW / 2, VH / 2, Math.min(VW, VH) * 0.32,
VW / 2, VH / 2, Math.max(VW, VH) * 0.72,
);
eg.addColorStop(0, 'rgba(80,150,220,0)');
eg.addColorStop(1, 'rgba(120,190,255,' + a.toFixed(3) + ')');
ctx.fillStyle = eg;
ctx.fillRect(0, 0, VW, VH);
}
}

// Vignette-ish bottom fade for depth
Expand Down Expand Up @@ -1680,6 +1724,18 @@ export class Game implements GameCtx {
ctx.fillText('Recounting the run…', VW / 2, VH * 0.3 + 46);
ctx.globalAlpha = 1;
}
// All-clear flourish: finishing the last fen earns a one-line coda.
if (this.stats.allClear && t > 0.9) {
ctx.globalAlpha = clamp((t - 0.9) / 0.4, 0, 0.95);
ctx.textAlign = 'center';
ctx.font = '800 20px ' + FONT_STACK;
ctx.lineWidth = 4;
ctx.strokeStyle = 'rgba(20,40,60,0.6)';
ctx.strokeText('✦ All fens crossed — the marsh is at peace ✦', VW / 2, VH * 0.3 + 76);
ctx.fillStyle = '#8fd0ff';
ctx.fillText('✦ All fens crossed — the marsh is at peace ✦', VW / 2, VH * 0.3 + 76);
ctx.globalAlpha = 1;
}
ctx.fillStyle = 'rgba(255,255,255,' + clamp(VICTORY_PANEL_T - t, 0, 1) * 0.5 + ')';
ctx.fillRect(0, 0, VW, VH);
return;
Expand Down Expand Up @@ -1873,13 +1929,19 @@ export class Game implements GameCtx {
this.drawTracked(ctx, lvl.subtitle, VW / 2, 156 + bounce * 0.4, 7, true);
ctx.fillStyle = '#ffe28a';
this.drawTracked(ctx, lvl.subtitle, VW / 2, 156 + bounce * 0.4, 7, false);
// Per-level records
// Per-level records (nudged down to make room for the all-clear badge)
const clearOffset = this.stats.allClear ? 16 : 0;
if (this.stats.allClear) {
ctx.font = '800 14px ' + FONT_STACK;
ctx.fillStyle = '#8fd0ff';
ctx.fillText('✦ All fens crossed', VW / 2, 182);
}
ctx.font = '600 13px ' + FONT_STACK;
ctx.fillStyle = 'rgba(255,255,255,0.85)';
ctx.fillText(
'Best Score ' + (this.best.score || '—') + ' · Best Time ' + (this.best.time === null ? '—' : fmtTime(this.best.time)),
VW / 2,
182,
182 + clearOffset,
);
// Lifetime stats
const since = this.stats.firstPlayed ? new Date(this.stats.firstPlayed).toLocaleDateString() : '—';
Expand All @@ -1888,7 +1950,7 @@ export class Game implements GameCtx {
ctx.fillText(
'PLAYS ' + this.stats.runs + ' · DEATHS ' + this.stats.deaths + ' · CRYSTALS ' + this.stats.crystals + ' · HEARTS ' + this.stats.hearts + ' · FOSSILS ' + this.fossilsFound.length + '/' + this.totalFossils() + ' · NOTES ' + this.notesFound.length + '/' + this.totalNotes() + ' · SINCE ' + since,
VW / 2,
205,
205 + clearOffset,
);
// Controls panel (left)
const cpx = 34, cpy = 232, cpw = 240, cph = 238;
Expand Down
2 changes: 1 addition & 1 deletion src/ghost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export interface GhostTrack {
/** Seconds between stored samples (~10 Hz). */
const SAMPLE_DT = 0.1;
/** Hard cap (~10 min at 10 Hz) so the persisted track stays small. */
const MAX_POINTS = 6000;
export const MAX_POINTS = 6000;
/** Tracks shorter than this are discarded (accidental taps, instant deaths). */
export const MIN_TRACK_POINTS = 4;

Expand Down
4 changes: 3 additions & 1 deletion src/goal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ export class Goal {
}

get rect(): { x: number; y: number; w: number; h: number } {
return { x: this.x - 34, y: this.y - 52, w: 68, h: 52 };
// Let a jumping Rex finish inside the nest's visible light pillar instead
// of requiring a precise landing on the small nest mound.
return { x: this.x - 60, y: this.y - 140, w: 120, h: 140 };
}

draw(ctx: CanvasRenderingContext2D, t: number): void {
Expand Down
Loading
Loading