Skip to content

Commit 5d5d797

Browse files
crs48claude
andcommitted
feat(generation): event-based patterns, boost-aware pacing, colored cubes
Patterns are now deliberate events — slalom clusters, gates, spirals, rails, weave pinches — separated by blank beats instead of per-cell density dice, so each obstacle reads as an intentional moment. Event gaps stretch with the boost multiplier at generation time, giving the player constant reaction time at any speed, and the telegraph horizon scales with speed too. Every event gets a palette color shared by its cubes, the telegraph trail leading to them, and the debug overlay. Also fixes the longitudinal grid lines (a stale scratch-vector reference collapsed them into duplicate ring segments). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 93a053c commit 5d5d797

13 files changed

Lines changed: 498 additions & 195 deletions

File tree

.claude/launch.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"version": "0.0.1",
3+
"configurations": [
4+
{
5+
"name": "vite-dev",
6+
"runtimeExecutable": "npm",
7+
"runtimeArgs": ["run", "dev"],
8+
"port": 5173
9+
},
10+
{
11+
"name": "vite-preview",
12+
"runtimeExecutable": "npm",
13+
"runtimeArgs": ["run", "preview"],
14+
"port": 4173
15+
}
16+
]
17+
}

src/game/run.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// hands the result to the render layer.
44

55
import { resolveCollisionFrame } from "./collision";
6+
import { BOOST_MULTIPLIERS } from "./config";
67
import { maybeUpdateHighScore, scoreFromDistance } from "./scoring";
78
import { advancePlayer, createInitialGameState, type GameState } from "./state";
89
import {
@@ -45,7 +46,11 @@ export const updateRun = (
4546
): RunState => {
4647
const advancedPlayer = advancePlayer(state.game.player, { steer }, dtSeconds);
4748
const world = trimWorldBehind(
48-
ensureWorldAhead(state.world, advancedPlayer.distance),
49+
ensureWorldAhead(
50+
state.world,
51+
advancedPlayer.distance,
52+
BOOST_MULTIPLIERS[advancedPlayer.boostLevel],
53+
),
4954
advancedPlayer.distance,
5055
);
5156
const collisionState = framesNearDistance(world, advancedPlayer.distance).reduce<CollisionPass>(

src/game/world.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export type WorldFrame = {
2222
readonly section: Section;
2323
readonly sectionCell: CellIndex;
2424
readonly obstacleMask: LaneMask;
25+
readonly colorIndex: number;
2526
readonly boost?: BoostCell;
2627
};
2728

@@ -33,7 +34,7 @@ export const createWorld = (seed: number, startLane: Lane): World => ({
3334
sections: [generateSection({ id: 0, seed, startLane })],
3435
});
3536

36-
const appendSection = (world: World): World => {
37+
const appendSection = (world: World, speedFactor: number): World => {
3738
const last = world.sections.at(-1);
3839

3940
if (last === undefined) {
@@ -47,6 +48,7 @@ const appendSection = (world: World): World => {
4748
seed: world.seed,
4849
startLane,
4950
startDistance: last.endDistance,
51+
speedFactor,
5052
});
5153

5254
return {
@@ -55,15 +57,19 @@ const appendSection = (world: World): World => {
5557
};
5658
};
5759

58-
export const ensureWorldAhead = (world: World, distance: number): World => {
60+
export const ensureWorldAhead = (
61+
world: World,
62+
distance: number,
63+
speedFactor = 1,
64+
): World => {
5965
const requiredDistance = distance + VISIBLE_CELLS * 4;
6066
const last = world.sections.at(-1);
6167

6268
if (last !== undefined && last.endDistance >= requiredDistance) {
6369
return world;
6470
}
6571

66-
return ensureWorldAhead(appendSection(world), distance);
72+
return ensureWorldAhead(appendSection(world, speedFactor), distance, speedFactor);
6773
};
6874

6975
export const trimWorldBehind = (world: World, distance: number): World => ({
@@ -101,6 +107,7 @@ export const frameAtDistance = (
101107
section,
102108
sectionCell,
103109
obstacleMask,
110+
colorIndex: section.cellColors[sectionCell] ?? 0,
104111
...(boost === undefined ? {} : { boost }),
105112
};
106113
};

src/generation/patterns.ts

Lines changed: 98 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import { LANES } from "../tube/space";
1+
// Pattern events: instead of rolling density dice per cell, sections place
2+
// deliberate "events" — a gate, a slalom cluster, a rail — separated by
3+
// blank cells. The player always reads one intentional thing at a time, and
4+
// the section can stretch the gaps when it is generated at boost speed.
5+
26
import {
3-
clearLane,
47
corridorMask,
5-
hasLane,
68
invertLaneMask,
79
laneMask,
810
normalizeLane,
@@ -12,100 +14,118 @@ import {
1214
import type { Rng } from "./rng";
1315

1416
export const PATTERN_FAMILIES = [
15-
"semiRandom",
16-
"staggered",
17+
"slalom",
18+
"gates",
1719
"spiral",
18-
"line",
19-
"wall",
20+
"rail",
21+
"weave",
2022
] as const;
2123

2224
export type PatternFamily = (typeof PATTERN_FAMILIES)[number];
2325

24-
export type PatternInput = {
26+
export type PatternEvent = {
27+
// One mask per cell of the event's span, relative to the event's start.
28+
readonly masks: readonly LaneMask[];
29+
// Blank cells after the event at base speed; scaled up while boosted.
30+
readonly gapAfter: number;
31+
};
32+
33+
export type EventInput = {
2534
readonly rng: Rng;
2635
readonly family: PatternFamily;
27-
readonly cellIndex: number;
28-
readonly safeLane: Lane;
36+
readonly eventIndex: number;
37+
// Safe path from the event's first cell onward.
38+
readonly safeLanes: readonly Lane[];
2939
readonly safeWidth: number;
30-
readonly density: number;
31-
readonly sectionId: number;
40+
// 0 (warmup) … 1 (late game).
41+
readonly difficulty: number;
3242
};
3343

34-
const addRandomBlockers = (
35-
rng: Rng,
36-
availableMask: LaneMask,
37-
density: number,
38-
): LaneMask =>
39-
Array.from({ length: LANES }, (_, lane) => lane)
40-
.filter((lane) => hasLane(availableMask, lane))
41-
.reduce<LaneMask>(
42-
(mask, lane) => (rng.next() < density ? mask | laneMask(lane) : mask),
43-
0,
44-
);
45-
46-
const createSemiRandomMask = (input: PatternInput, availableMask: LaneMask): LaneMask =>
47-
addRandomBlockers(input.rng, availableMask, input.density);
48-
49-
const createStaggeredMask = (input: PatternInput, availableMask: LaneMask): LaneMask => {
50-
const side = Math.floor(input.cellIndex / 2) % 2 === 0 ? -1 : 1;
51-
const offsets = [side * 2, side * 3, -side * 5];
52-
53-
return offsets.reduce<LaneMask>((mask, offset) => {
54-
const lane = normalizeLane(input.safeLane + offset);
55-
return hasLane(availableMask, lane) ? mask | laneMask(lane) : mask;
56-
}, 0);
57-
};
44+
const lanesToMask = (lanes: readonly number[]): LaneMask =>
45+
lanes.reduce<LaneMask>((mask, lane) => mask | laneMask(normalizeLane(lane)), 0);
46+
47+
const safeLaneAt = (input: EventInput, offset: number): Lane =>
48+
input.safeLanes[Math.min(offset, input.safeLanes.length - 1)] ?? 0;
5849

59-
const createSpiralMask = (input: PatternInput, availableMask: LaneMask): LaneMask => {
60-
const direction = input.sectionId % 2 === 0 ? 1 : -1;
61-
const baseLane = normalizeLane(input.sectionId * 3);
62-
const leadLane = normalizeLane(baseLane + input.cellIndex * direction);
63-
const mask = [leadLane, leadLane + direction, leadLane + direction * 3]
64-
.reduce<LaneMask>((acc, lane) => acc | laneMask(lane), 0);
50+
// One small cluster right beside the corridor, alternating sides: cubes
51+
// flick past the player's shoulder and punish drifting.
52+
const slalomEvent = (input: EventInput): PatternEvent => {
53+
const side = input.eventIndex % 2 === 0 ? 1 : -1;
54+
const edge = input.safeWidth + 1;
55+
const safe = safeLaneAt(input, 0);
56+
const offsets = input.difficulty > 0.4 ? [edge, edge + 1] : [edge];
6557

66-
return mask & availableMask;
58+
return {
59+
masks: [lanesToMask(offsets.map((offset) => safe + side * offset))],
60+
gapAfter: input.difficulty > 0.7 ? 2 : 3,
61+
};
6762
};
6863

69-
const createLineMask = (input: PatternInput, availableMask: LaneMask): LaneMask => {
70-
const firstLine = normalizeLane(input.sectionId + Math.floor(input.cellIndex / 10));
71-
const secondLine = normalizeLane(firstLine + 6);
64+
// A full ring with a gap centered on the safe path: see the wall, find the
65+
// hole, steer to it.
66+
const gatesEvent = (input: EventInput): PatternEvent => {
67+
const gapHalf = input.difficulty > 0.5 ? 1 : 2;
68+
const safe = safeLaneAt(input, 0);
7269

73-
return [firstLine, secondLine].reduce<LaneMask>(
74-
(mask, lane) => (hasLane(availableMask, lane) ? mask | laneMask(lane) : mask),
75-
0,
76-
);
70+
return {
71+
masks: [invertLaneMask(corridorMask(safe, Math.max(gapHalf, input.safeWidth)))],
72+
gapAfter: 5 - Math.round(input.difficulty * 2),
73+
};
7774
};
7875

79-
const createWallMask = (input: PatternInput, safeMask: LaneMask): LaneMask => {
80-
if (input.cellIndex % 9 !== 0) {
81-
return createSemiRandomMask(input, invertLaneMask(safeMask)) & 0b001_001_001_001;
82-
}
76+
// A short two-lane-thick helix sweeping around the ring beside the corridor,
77+
// alternating sweep direction between events.
78+
const spiralEvent = (input: EventInput): PatternEvent => {
79+
const direction = input.eventIndex % 2 === 0 ? 1 : -1;
80+
const span = 5;
81+
const masks = Array.from({ length: span }, (_, step) => {
82+
const base = safeLaneAt(input, step) + direction * (input.safeWidth + 1 + step);
83+
return lanesToMask([base, base + direction]);
84+
});
85+
86+
return { masks, gapAfter: 4 };
87+
};
8388

84-
const widerSafeMask = corridorMask(input.safeLane, Math.max(input.safeWidth, 1));
85-
const wallMask = invertLaneMask(widerSafeMask);
86-
const reliefLane = normalizeLane(input.safeLane + (input.sectionId % 2 === 0 ? 4 : -4));
89+
// A solid two-lane ribbon riding one side of the corridor for several cells:
90+
// a wall that travels with you and frames the path.
91+
const railEvent = (input: EventInput): PatternEvent => {
92+
const side = input.eventIndex % 2 === 0 ? -1 : 1;
93+
const span = 8 + input.rng.nextInt(0, 4);
94+
const masks = Array.from({ length: span }, (_, step) => {
95+
const safe = safeLaneAt(input, step);
96+
return lanesToMask([
97+
safe + side * (input.safeWidth + 1),
98+
safe + side * (input.safeWidth + 2),
99+
]);
100+
});
101+
102+
return { masks, gapAfter: 4 };
103+
};
87104

88-
return clearLane(wallMask, reliefLane);
105+
// A pinch: cubes flanking both sides of the corridor at once — hold the line.
106+
const weaveEvent = (input: EventInput): PatternEvent => {
107+
const edge = input.safeWidth + 1;
108+
const safe = safeLaneAt(input, 0);
109+
const offsets =
110+
input.difficulty > 0.5 ? [edge, edge + 1, -edge, -edge - 1] : [edge, -edge];
111+
112+
return {
113+
masks: [lanesToMask(offsets.map((offset) => safe + offset))],
114+
gapAfter: 3,
115+
};
89116
};
90117

91-
export const createPatternMask = (input: PatternInput): LaneMask => {
92-
const safeMask = corridorMask(input.safeLane, input.safeWidth);
93-
const availableMask = invertLaneMask(safeMask);
94-
95-
const rawMask = (() => {
96-
switch (input.family) {
97-
case "semiRandom":
98-
return createSemiRandomMask(input, availableMask);
99-
case "staggered":
100-
return createStaggeredMask(input, availableMask);
101-
case "spiral":
102-
return createSpiralMask(input, availableMask);
103-
case "line":
104-
return createLineMask(input, availableMask);
105-
case "wall":
106-
return createWallMask(input, safeMask);
107-
}
108-
})();
109-
110-
return rawMask & availableMask;
118+
export const createPatternEvent = (input: EventInput): PatternEvent => {
119+
switch (input.family) {
120+
case "slalom":
121+
return slalomEvent(input);
122+
case "gates":
123+
return gatesEvent(input);
124+
case "spiral":
125+
return spiralEvent(input);
126+
case "rail":
127+
return railEvent(input);
128+
case "weave":
129+
return weaveEvent(input);
130+
}
111131
};

0 commit comments

Comments
 (0)