clone

an endless marble-race generator in the browser; the url is the seed, so every track can be shared

Ring size from URL (user): ?peers=N sizes the marble ring (0 disables, cap 24)

Dami committed e76a6b4
slotOffsets(n) spreads ~1/3 trailing / rest leading inside the live band;
SLOT_OFFSETS stays as slotOffsets(6) default. Verified in browser at
peers=12 and peers=0.
commit
e76a6b4af1f27ae5fd8ebecde0146f310d4d2301
parent
34bc421ab35b4c4bfe784d673b8691af6adf4b42
author
Dami <dami.etoile@gmail.com>
4 files changed, +104 -25
Msrc/client/endless.ts8++++-
Msrc/client/peers.ts6+++-
Msrc/client/peersCore.ts58++++++++++++++++++++++++------
Mtests/peers_test.ts57+++++++++++++++++++++++------
M src/client/endless.ts +7 -1
M src/client/peers.ts +5 -1
M src/client/peersCore.ts +47 -11
@@ -18,13 +18,44 @@
1818 */1919 import { rngFamily } from "../gen/rng.ts";2020 21-/** Signed arc-length offsets (world units) the ring keeps populated. */22-export const SLOT_OFFSETS: readonly number[] = [-13, -7, 6, 12, 19, 27];23-2421 /** Live band relative to the player: outside this, a peer despawns. */2522 export const BEHIND_LIMIT = 18;2623 export const AHEAD_LIMIT = 38;2724 25+/** Default ring size (overridable per session via ?peers=N). */26+export const DEFAULT_PEER_COUNT = 6;27+28+/**29+ * Signed arc-length slot offsets for a ring of `n` peers: roughly a third30+ * ride behind, the rest lead, spread evenly inside the live band with31+ * margin so fresh spawns aren't born on the despawn edge.32+ */33+export function slotOffsets(n: number): number[] {34+ const count = Math.max(0, Math.min(24, Math.floor(n)));35+ if (count === 0) return [];36+ const nBehind = count >= 3 ? Math.floor(count / 3) : count === 2 ? 1 : 0;37+ const nAhead = count - nBehind;38+ const out: number[] = [];39+ const bLo = -BEHIND_LIMIT + 4;40+ const bHi = -7;41+ for (let k = 0; k < nBehind; k++) {42+ out.push(43+ Math.round(nBehind === 1 ? bHi : bLo + (bHi - bLo) * (k / (nBehind - 1))),44+ );45+ }46+ const aLo = 6;47+ const aHi = AHEAD_LIMIT - 6;48+ for (let k = 0; k < nAhead; k++) {49+ out.push(50+ Math.round(nAhead === 1 ? aLo : aLo + (aHi - aLo) * (k / (nAhead - 1))),51+ );52+ }53+ return out;54+}55+56+/** The default ring's offsets (kept as a const for tests/back-compat). */57+export const SLOT_OFFSETS: readonly number[] = slotOffsets(DEFAULT_PEER_COUNT);58+2859 /** Seconds of spawn scale-in / despawn scale-out. */2960 export const IN_TIME = 0.45;3061 export const OUT_TIME = 0.35;
@@ -88,15 +119,20 @@ export interface RingEvents {
88119 export class PeerRingCore {89120 /** One entry per slot; null = open. */90121 readonly peers: (Peer | null)[];122+ readonly offsets: readonly number[];91123 private readonly cooldowns: number[];92124 private readonly counters: number[];93125 94- constructor(private readonly seed: string) {95- this.peers = SLOT_OFFSETS.map(() => null);96- this.counters = SLOT_OFFSETS.map(() => 0);126+ constructor(127+ private readonly seed: string,128+ offsets: readonly number[] = SLOT_OFFSETS,129+ ) {130+ this.offsets = offsets;131+ this.peers = offsets.map(() => null);132+ this.counters = offsets.map(() => 0);97133 // Staggered opening: slots come alive over the first moments instead98- // of six marbles popping in on one frame.99- this.cooldowns = SLOT_OFFSETS.map((_, i) =>134+ // of the whole ring popping in on one frame.135+ this.cooldowns = offsets.map((_, i) =>100136 rngFamily(seed, `peer-open/${i}`).float() * 2101137 );102138 }
@@ -121,7 +157,7 @@ export class PeerRingCore {
121157 ): RingEvents {122158 const events: RingEvents = { spawned: [], removed: [] };123159 124- for (let slot = 0; slot < SLOT_OFFSETS.length; slot++) {160+ for (let slot = 0; slot < this.offsets.length; slot++) {125161 const peer = this.peers[slot];126162 if (!peer) {127163 this.cooldowns[slot] -= dt;
@@ -193,11 +229,11 @@ export class PeerRingCore {
193229 const lo = band[0] + 1;194230 const hi = band[1] - 1;195231 if (hi - lo < 4) return null;196- let s = playerS + SLOT_OFFSETS[slot];232+ let s = playerS + this.offsets[slot];197233 s = Math.min(hi, Math.max(lo, s));198234 // Never materialize on top of the player.199235 if (Math.abs(s - playerS) < 3) {200- s = playerS + (SLOT_OFFSETS[slot] >= 0 ? 3 : -3);236+ s = playerS + (this.offsets[slot] >= 0 ? 3 : -3);201237 if (s < lo || s > hi) return null;202238 }203239
M tests/peers_test.ts +45 -12
@@ -17,10 +17,12 @@ import { assert, assertEquals, assertNotEquals } from "@std/assert";
1717 import {1818 AHEAD_LIMIT,1919 BEHIND_LIMIT,20+ DEFAULT_PEER_COUNT,2021 OUT_TIME,2122 type Peer,2223 PeerRingCore,2324 SLOT_OFFSETS,25+ slotOffsets,2426 } from "../src/client/peersCore.ts";2527 2628 const FLAT = (_s: number) => 0;
@@ -30,30 +32,60 @@ const DT = 1 / 60;
3032 function run(3133 ring: PeerRingCore,3234 seconds: number,33- playerS: () => number,35+ playerS: (t: number) => number,3436 nowMs: () => number,3537 band: readonly [number, number] = BAND,3638 ): { spawned: Peer[]; removed: Peer[] } {3739 const all = { spawned: [] as Peer[], removed: [] as Peer[] };3840 for (let t = 0; t < seconds; t += DT) {39- const ev = ring.update(DT, playerS(), band, nowMs(), FLAT);41+ const ev = ring.update(DT, playerS(t), band, nowMs(), FLAT);4042 all.spawned.push(...ev.spawned);4143 all.removed.push(...ev.removed);4244 }4345 return all;4446 }4547 48+/** A player travelling at the peers' mean pace — fill assertions must not49+ * race the outermost slot against the despawn edge. */50+const cruise = (from: number) => (t: number) => from + 2.2 * t;51+52+Deno.test("peers: slotOffsets sizes the ring from config (?peers=N)", () => {53+ assertEquals(slotOffsets(0), []);54+ assertEquals(slotOffsets(-3), []);55+ assertEquals(SLOT_OFFSETS, slotOffsets(DEFAULT_PEER_COUNT));56+ for (const n of [1, 2, 3, 6, 10, 16, 24, 99]) {57+ const offs = slotOffsets(n);58+ assertEquals(offs.length, Math.min(24, n), `count for n=${n}`);59+ for (const o of offs) {60+ assert(o !== 0, "no slot sits on the player");61+ assert(62+ o > -BEHIND_LIMIT + 2 && o < AHEAD_LIMIT - 2,63+ `offset ${o} too close to the despawn edge (n=${n})`,64+ );65+ }66+ assert(offs.some((o) => o > 0), `no leading slot for n=${n}`);67+ if (n >= 3) assert(offs.some((o) => o < 0), `no trailing slot for n=${n}`);68+ // A sized ring actually fills to its size.69+ const ring = new PeerRingCore("size-seed", offs);70+ let t2 = 0;71+ for (let t = 0; t < 3; t += DT) {72+ ring.update(DT, 100 + 2.2 * (t2 += DT), BAND, 0, FLAT);73+ }74+ assertEquals(ring.liveCount, offs.length, `fill for n=${n}`);75+ }76+});77+4678 Deno.test("peers: ring fills every slot after opening stagger", () => {4779 const ring = new PeerRingCore("det-seed");48- run(ring, 3, () => 100, () => 0);80+ run(ring, 3, cruise(100), () => 0);4981 assertEquals(ring.liveCount, SLOT_OFFSETS.length);5082 });5183 5284 Deno.test("peers: identity is deterministic for (seed, slot, n, stamp)", () => {5385 const a = new PeerRingCore("det-seed");5486 const b = new PeerRingCore("det-seed");55- const evA = run(a, 3, () => 100, () => 12345);56- const evB = run(b, 3, () => 100, () => 12345);87+ const evA = run(a, 3, cruise(100), () => 12345);88+ const evB = run(b, 3, cruise(100), () => 12345);5789 assertEquals(evA.spawned.length, evB.spawned.length);5890 for (let i = 0; i < evA.spawned.length; i++) {5991 assertEquals(evA.spawned[i].identity, evB.spawned[i].identity);
@@ -64,8 +96,8 @@ Deno.test("peers: identity is deterministic for (seed, slot, n, stamp)", () => {
6496 Deno.test("peers: spawn time shapes identity", () => {6597 const a = new PeerRingCore("det-seed");6698 const b = new PeerRingCore("det-seed");67- const evA = run(a, 3, () => 100, () => 0);68- const evB = run(b, 3, () => 100, () => 60_000);99+ const evA = run(a, 3, cruise(100), () => 0);100+ const evB = run(b, 3, cruise(100), () => 60_000);69101 // Same slots and counts, different stamps: identities must diverge70102 // somewhere (all-equal would mean time is not in the seed material).71103 const same = evA.spawned.every((p, i) =>
@@ -91,13 +123,14 @@ Deno.test("peers: no spawn on top of the player, all inside the band", () => {
91123 92124 Deno.test("peers: drifting out of the live band despawns and reopens", () => {93125 const ring = new PeerRingCore("det-seed");94- run(ring, 3, () => 100, () => 0);126+ run(ring, 3, cruise(100), () => 0);95127 assertEquals(ring.liveCount, SLOT_OFFSETS.length);96128 // Teleport the player far forward: every peer is now beyond BEHIND_LIMIT.97- const ev = run(ring, OUT_TIME + 0.1, () => 100 + BEHIND_LIMIT + 200, () => 0);129+ const jump = 100 + 2.2 * 3 + BEHIND_LIMIT + 200;130+ const ev = run(ring, OUT_TIME + 0.1, cruise(jump), () => 0);98131 assert(ev.removed.length >= SLOT_OFFSETS.length, "peers did not despawn");99132 // Slots must refill (cooldown ≤ 3.5s + scale-in).100- run(ring, 5, () => 100 + BEHIND_LIMIT + 200, () => 0);133+ run(ring, 5, cruise(jump), () => 0);101134 assertEquals(ring.liveCount, SLOT_OFFSETS.length, "slots did not refill");102135 });103136
@@ -108,7 +141,7 @@ Deno.test("peers: behaviour envelopes hold across many draws", () => {
108141 // Cycle many generations by teleporting the player each round.109142 for (let round = 0; round < 12; round++) {110143 const at = 50 + round * 100;111- const ev = run(ring, 6, () => at, () => (clock += 1000), [0, 5000]);144+ const ev = run(ring, 6, cruise(at), () => (clock += 1000), [0, 5000]);112145 seen.push(...ev.spawned);113146 }114147 assert(seen.length >= 24, `too few spawns to judge: ${seen.length}`);
@@ -133,7 +166,7 @@ Deno.test("peers: behaviour envelopes hold across many draws", () => {
133166 134167 Deno.test("peers: relative drift revolves the ring (fast peers pull ahead)", () => {135168 const ring = new PeerRingCore("drift-seed");136- run(ring, 3, () => 100, () => 5000, [0, 10_000]);169+ run(ring, 3, cruise(100), () => 5000, [0, 10_000]);137170 const speeds = ring.peers.flatMap((p) => (p ? [p.behaviour.base] : []));138171 assert(speeds.length === SLOT_OFFSETS.length);139172 // Character spread exists → drift is guaranteed over time.