paddla-engine 9.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +32 -0
  2. package/index.js +583 -0
  3. package/package.json +13 -0
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # paddla-engine
2
+
3
+ PADDLA physics arcade engine — UVS 1.0 compatible.
4
+
5
+ **Version:** 9.0.1
6
+ **Protocol:** [UVS v1](https://github.com/constarik/uvs)
7
+ **PRNG:** ChaCha20 (RFC 8439) + SHA-512
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install paddla-engine
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```js
18
+ const { createInitialState, tick, replay, ENGINE_VERSION } = require('paddla-engine');
19
+
20
+ const state = createInitialState(serverSeed, numBalls, betPerBall);
21
+ const events = tick(state, { x: 4.5, y: 2.0 });
22
+ ```
23
+
24
+ ## Exports
25
+
26
+ - `createInitialState(serverSeed, numBalls, betPerBall)`
27
+ - `tick(state, bumperTarget)`
28
+ - `replay(serverSeed, numBalls, inputLog, betPerBall)`
29
+ - `finishGame(state)`
30
+ - `UVS_PRNG` — ChaCha20-based PRNG
31
+ - `sha256Hex`, `sha512Hex`
32
+ - `ENGINE_VERSION`, `CONFIG`, `BUMPER`
package/index.js ADDED
@@ -0,0 +1,583 @@
1
+ // PADDLA Engine v9 - UVS 1.0 Compatible
2
+ // Provably fair via UVS protocol: github.com/constarik/uvs
3
+ // combinedSeed = SHA-512(serverSeed + ":" + clientSeed + ":" + nonce)
4
+ // PRNG: ChaCha20 (RFC 8439), key=combinedSeed[0..31], nonce=combinedSeed[32..43]
5
+
6
+ const ENGINE_VERSION = 9;
7
+
8
+ const _crypto = typeof window === 'undefined' ? require('crypto') : null;
9
+
10
+ // ===== SHA-256 pure JS (browser fallback) =====
11
+
12
+ const _SHA256_K = new Uint32Array([
13
+ 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
14
+ 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
15
+ 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
16
+ 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
17
+ 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
18
+ 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
19
+ 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
20
+ 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
21
+ ]);
22
+
23
+ function _sha256Bytes(msg) {
24
+ const bytes = typeof msg === 'string' ? new TextEncoder().encode(msg) : new Uint8Array(msg);
25
+ const bitLen = bytes.length * 8;
26
+ const padLen = (bytes.length + 9 + 63) & ~63;
27
+ const padded = new Uint8Array(padLen);
28
+ padded.set(bytes);
29
+ padded[bytes.length] = 0x80;
30
+ const dv = new DataView(padded.buffer);
31
+ dv.setUint32(padLen - 4, bitLen, false);
32
+ let h0=0x6a09e667,h1=0xbb67ae85,h2=0x3c6ef372,h3=0xa54ff53a;
33
+ let h4=0x510e527f,h5=0x9b05688c,h6=0x1f83d9ab,h7=0x5be0cd19;
34
+ const w = new Uint32Array(64);
35
+ for (let off = 0; off < padLen; off += 64) {
36
+ for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + i*4, false);
37
+ for (let i = 16; i < 64; i++) {
38
+ const s0=((w[i-15]>>>7)|(w[i-15]<<25))^((w[i-15]>>>18)|(w[i-15]<<14))^(w[i-15]>>>3);
39
+ const s1=((w[i-2]>>>17)|(w[i-2]<<15))^((w[i-2]>>>19)|(w[i-2]<<13))^(w[i-2]>>>10);
40
+ w[i]=(w[i-16]+s0+w[i-7]+s1)>>>0;
41
+ }
42
+ let a=h0,b=h1,c=h2,d=h3,e=h4,f=h5,g=h6,h=h7;
43
+ for (let i = 0; i < 64; i++) {
44
+ const S1=((e>>>6)|(e<<26))^((e>>>11)|(e<<21))^((e>>>25)|(e<<7));
45
+ const ch=(e&f)^(~e&g);
46
+ const t1=(h+S1+ch+_SHA256_K[i]+w[i])>>>0;
47
+ const S0=((a>>>2)|(a<<30))^((a>>>13)|(a<<19))^((a>>>22)|(a<<10));
48
+ const maj=(a&b)^(a&c)^(b&c);
49
+ const t2=(S0+maj)>>>0;
50
+ h=g;g=f;f=e;e=(d+t1)>>>0;d=c;c=b;b=a;a=(t1+t2)>>>0;
51
+ }
52
+ h0=(h0+a)>>>0;h1=(h1+b)>>>0;h2=(h2+c)>>>0;h3=(h3+d)>>>0;
53
+ h4=(h4+e)>>>0;h5=(h5+f)>>>0;h6=(h6+g)>>>0;h7=(h7+h)>>>0;
54
+ }
55
+ const r = new Uint8Array(32);
56
+ const rv = new DataView(r.buffer);
57
+ rv.setUint32(0,h0,false);rv.setUint32(4,h1,false);rv.setUint32(8,h2,false);rv.setUint32(12,h3,false);
58
+ rv.setUint32(16,h4,false);rv.setUint32(20,h5,false);rv.setUint32(24,h6,false);rv.setUint32(28,h7,false);
59
+ return r;
60
+ }
61
+
62
+ // ===== SHA-512 pure JS (browser fallback) =====
63
+
64
+ const _SHA512_K = [
65
+ 0x428a2f98d728ae22n,0x7137449123ef65cdn,0xb5c0fbcfec4d3b2fn,0xe9b5dba58189dbbcn,
66
+ 0x3956c25bf348b538n,0x59f111f1b605d019n,0x923f82a4af194f9bn,0xab1c5ed5da6d8118n,
67
+ 0xd807aa98a3030242n,0x12835b0145706fben,0x243185be4ee4b28cn,0x550c7dc3d5ffb4e2n,
68
+ 0x72be5d74f27b896fn,0x80deb1fe3b1696b1n,0x9bdc06a725c71235n,0xc19bf174cf692694n,
69
+ 0xe49b69c19ef14ad2n,0xefbe4786384f25e3n,0x0fc19dc68b8cd5b5n,0x240ca1cc77ac9c65n,
70
+ 0x2de92c6f592b0275n,0x4a7484aa6ea6e483n,0x5cb0a9dcbd41fbd4n,0x76f988da831153b5n,
71
+ 0x983e5152ee66dfabn,0xa831c66d2db43210n,0xb00327c898fb213fn,0xbf597fc7beef0ee4n,
72
+ 0xc6e00bf33da88fc2n,0xd5a79147930aa725n,0x06ca6351e003826fn,0x142929670a0e6e70n,
73
+ 0x27b70a8546d22ffcn,0x2e1b21385c26c926n,0x4d2c6dfc5ac42aedn,0x53380d139d95b3dfn,
74
+ 0x650a73548baf63den,0x766a0abb3c77b2a8n,0x81c2c92e47edaee6n,0x92722c851482353bn,
75
+ 0xa2bfe8a14cf10364n,0xa81a664bbc423001n,0xc24b8b70d0f89791n,0xc76c51a30654be30n,
76
+ 0xd192e819d6ef5218n,0xd69906245565a910n,0xf40e35855771202an,0x106aa07032bbd1b8n,
77
+ 0x19a4c116b8d2d0c8n,0x1e376c085141ab53n,0x2748774cdf8eeb99n,0x34b0bcb5e19b48a8n,
78
+ 0x391c0cb3c5c95a63n,0x4ed8aa4ae3418acbn,0x5b9cca4f7763e373n,0x682e6ff3d6b2b8a3n,
79
+ 0x748f82ee5defb2fcn,0x78a5636f43172f60n,0x84c87814a1f0ab72n,0x8cc702081a6439ecn,
80
+ 0x90befffa23631e28n,0xa4506cebde82bde9n,0xbef9a3f7b2c67915n,0xc67178f2e372532bn,
81
+ 0xca273eceea26619cn,0xd186b8c721c0c207n,0xeada7dd6cde0eb1en,0xf57d4f7fee6ed178n,
82
+ 0x06f067aa72176fban,0x0a637dc5a2c898a6n,0x113f9804bef90daen,0x1b710b35131c471bn,
83
+ 0x28db77f523047d84n,0x32caab7b40c72493n,0x3c9ebe0a15c9bebcn,0x431d67c49c100d4cn,
84
+ 0x4cc5d4becb3e42b6n,0x597f299cfc657e2an,0x5fcb6fab3ad6faecn,0x6c44198c4a475817n
85
+ ];
86
+
87
+ function _sha512Bytes(msg) {
88
+ const bytes = typeof msg === 'string' ? new TextEncoder().encode(msg) : new Uint8Array(msg);
89
+ const bitLen = BigInt(bytes.length * 8);
90
+ const padLen = (bytes.length + 17 + 127) & ~127;
91
+ const padded = new Uint8Array(padLen);
92
+ padded.set(bytes);
93
+ padded[bytes.length] = 0x80;
94
+ const dv = new DataView(padded.buffer);
95
+ dv.setBigUint64(padLen - 8, bitLen, false);
96
+ const M = 0xFFFFFFFFFFFFFFFFn;
97
+ let h0=0x6a09e667f3bcc908n,h1=0xbb67ae8584caa73bn,h2=0x3c6ef372fe94f82bn,h3=0xa54ff53a5f1d36f1n;
98
+ let h4=0x510e527fade682d1n,h5=0x9b05688c2b3e6c1fn,h6=0x1f83d9abfb41bd6bn,h7=0x5be0cd19137e2179n;
99
+ const w = new Array(80);
100
+ for (let off = 0; off < padLen; off += 128) {
101
+ for (let i = 0; i < 16; i++) w[i] = dv.getBigUint64(off + i*8, false);
102
+ for (let i = 16; i < 80; i++) {
103
+ const w15=w[i-15], w2=w[i-2];
104
+ const s0=((w15>>1n)|(w15<<63n))^((w15>>8n)|(w15<<56n))^(w15>>7n);
105
+ const s1=((w2>>19n)|(w2<<45n))^((w2>>61n)|(w2<<3n))^(w2>>6n);
106
+ w[i]=(w[i-16]+s0+w[i-7]+s1)&M;
107
+ }
108
+ let a=h0,b=h1,c=h2,d=h3,e=h4,f=h5,g=h6,h=h7;
109
+ for (let i = 0; i < 80; i++) {
110
+ const S1=((e>>14n)|(e<<50n))^((e>>18n)|(e<<46n))^((e>>41n)|(e<<23n));
111
+ const ch=(e&f)^(~e&g)&M;
112
+ const t1=(h+S1+ch+_SHA512_K[i]+w[i])&M;
113
+ const S0=((a>>28n)|(a<<36n))^((a>>34n)|(a<<30n))^((a>>39n)|(a<<25n));
114
+ const maj=(a&b)^(a&c)^(b&c);
115
+ const t2=(S0+maj)&M;
116
+ h=g;g=f;f=e;e=(d+t1)&M;d=c;c=b;b=a;a=(t1+t2)&M;
117
+ }
118
+ h0=(h0+a)&M;h1=(h1+b)&M;h2=(h2+c)&M;h3=(h3+d)&M;
119
+ h4=(h4+e)&M;h5=(h5+f)&M;h6=(h6+g)&M;h7=(h7+h)&M;
120
+ }
121
+ const r = new Uint8Array(64);
122
+ const rv = new DataView(r.buffer);
123
+ rv.setBigUint64(0,h0,false);rv.setBigUint64(8,h1,false);rv.setBigUint64(16,h2,false);rv.setBigUint64(24,h3,false);
124
+ rv.setBigUint64(32,h4,false);rv.setBigUint64(40,h5,false);rv.setBigUint64(48,h6,false);rv.setBigUint64(56,h7,false);
125
+ return r;
126
+ }
127
+
128
+ // ===== ChaCha20 pure JS (RFC 8439, browser fallback) =====
129
+
130
+ function _chacha20Block(key, nonce12, counter, out, outOff) {
131
+ // key: Uint8Array[32], nonce12: Uint8Array[12], counter: number
132
+ const s = new Uint32Array(16);
133
+ const dv = new DataView(key.buffer, key.byteOffset);
134
+ const nv = new DataView(nonce12.buffer, nonce12.byteOffset);
135
+ s[0]=0x61707865;s[1]=0x3320646e;s[2]=0x79622d32;s[3]=0x6b206574;
136
+ for (let i=0;i<8;i++) s[4+i]=dv.getUint32(i*4,true);
137
+ s[12]=counter>>>0;
138
+ s[13]=nv.getUint32(0,true);s[14]=nv.getUint32(4,true);s[15]=nv.getUint32(8,true);
139
+ const x = s.slice();
140
+ function qr(a,b,c,d){
141
+ x[a]=(x[a]+x[b])>>>0;x[d]=Math.imul(x[d]^x[a],1)<<16|(x[d]^x[a])>>>16;
142
+ x[c]=(x[c]+x[d])>>>0;x[b]=Math.imul(x[b]^x[c],1)<<12|(x[b]^x[c])>>>20;
143
+ x[a]=(x[a]+x[b])>>>0;x[d]=Math.imul(x[d]^x[a],1)<<8|(x[d]^x[a])>>>24;
144
+ x[c]=(x[c]+x[d])>>>0;x[b]=Math.imul(x[b]^x[c],1)<<7|(x[b]^x[c])>>>25;
145
+ }
146
+ for (let i=0;i<10;i++){
147
+ qr(0,4,8,12);qr(1,5,9,13);qr(2,6,10,14);qr(3,7,11,15);
148
+ qr(0,5,10,15);qr(1,6,11,12);qr(2,7,8,13);qr(3,4,9,14);
149
+ }
150
+ const ov = new DataView(out.buffer, out.byteOffset + outOff);
151
+ for (let i=0;i<16;i++) ov.setUint32(i*4,((x[i]+s[i])>>>0),true);
152
+ }
153
+
154
+ function _chacha20KeystreamPure(key32, nonce12, numBytes) {
155
+ const out = new Uint8Array(numBytes);
156
+ const blocks = Math.ceil(numBytes / 64);
157
+ const tmp = new Uint8Array(64);
158
+ for (let b=0;b<blocks;b++){
159
+ _chacha20Block(key32, nonce12, b, tmp, 0);
160
+ const end = Math.min(64, numBytes - b*64);
161
+ out.set(tmp.subarray(0, end), b*64);
162
+ }
163
+ return out;
164
+ }
165
+
166
+ // ===== Crypto helpers =====
167
+
168
+ function _bytesToHex(b) {
169
+ return Array.from(b).map(x=>x.toString(16).padStart(2,'0')).join('');
170
+ }
171
+
172
+ function sha256Hex(msg) {
173
+ if (_crypto) {
174
+ const data = typeof msg === 'string' ? msg : Buffer.from(msg);
175
+ return _crypto.createHash('sha256').update(data).digest('hex');
176
+ }
177
+ return _bytesToHex(_sha256Bytes(msg));
178
+ }
179
+
180
+ function sha512Hex(msg) {
181
+ if (_crypto) {
182
+ const data = typeof msg === 'string' ? msg : Buffer.from(msg);
183
+ return _crypto.createHash('sha512').update(data).digest('hex');
184
+ }
185
+ return _bytesToHex(_sha512Bytes(msg));
186
+ }
187
+
188
+ function _chacha20Keystream(key32buf, nonce12buf, numBytes) {
189
+ if (_crypto) {
190
+ const iv = Buffer.alloc(16);
191
+ iv.writeUInt32LE(0, 0);
192
+ nonce12buf.copy ? nonce12buf.copy(iv, 4) : iv.set(nonce12buf, 4);
193
+ const zeros = Buffer.alloc(numBytes);
194
+ const cipher = _crypto.createCipheriv('chacha20', key32buf, iv);
195
+ return cipher.update(zeros);
196
+ }
197
+ return _chacha20KeystreamPure(key32buf, nonce12buf, numBytes);
198
+ }
199
+
200
+ // ===== UVS_PRNG (UVS 1.0 compliant) =====
201
+
202
+ class UVS_PRNG {
203
+ constructor(combinedSeedHex) {
204
+ // combinedSeed = SHA-512(serverSeed + ":" + clientSeed + ":" + nonce)
205
+ // key = bytes 0-31, nonce12 = bytes 32-43
206
+ const buf = _crypto
207
+ ? Buffer.from(combinedSeedHex, 'hex')
208
+ : (()=>{ const b=new Uint8Array(64); for(let i=0;i<64;i++) b[i]=parseInt(combinedSeedHex.slice(i*2,i*2+2),16); return b; })();
209
+ const key32 = _crypto ? buf.slice(0,32) : buf.slice(0,32);
210
+ const nonce12 = _crypto ? buf.slice(32,44) : buf.slice(32,44);
211
+ // Pre-generate 256 bytes (64 uint32) — enough for any single tick
212
+ this._stream = _chacha20Keystream(key32, nonce12, 256);
213
+ this._pos = 0;
214
+ this._log = [];
215
+ }
216
+
217
+ nextUint32() {
218
+ if (this._pos + 4 > this._stream.length) {
219
+ throw new Error('UVS_PRNG: keystream exhausted — increase pre-generated size');
220
+ }
221
+ const val = _crypto
222
+ ? this._stream.readUInt32LE(this._pos)
223
+ : new DataView(this._stream.buffer).getUint32(this._pos, true);
224
+ this._pos += 4;
225
+ this._log.push(val);
226
+ return val;
227
+ }
228
+
229
+ nextDouble() {
230
+ // Two uint32 → double [0, 1) with 53-bit precision
231
+ const hi = this.nextUint32();
232
+ const lo = this.nextUint32();
233
+ return (hi * 0x100000000 + lo) / 0x10000000000000000;
234
+ }
235
+
236
+ consumed() {
237
+ return [...this._log];
238
+ }
239
+ }
240
+
241
+ // Per-tick combinedSeed: serverSeed is game secret, clientSeed encodes bumper position, nonce = tick
242
+ function _tickCombinedSeed(serverSeed, bumperX, bumperY, tick) {
243
+ const clientSeed = `${bumperX.toFixed(4)}:${bumperY.toFixed(4)}`;
244
+ return sha512Hex(`${serverSeed}:${clientSeed}:${tick}`);
245
+ }
246
+
247
+ // ===== UTILITIES =====
248
+
249
+ const FP_ROUND = 1e10;
250
+ function fpRound(v) { return Math.round(v * FP_ROUND) / FP_ROUND; }
251
+ function moneyRound(v) { return Math.round(v * 100) / 100; }
252
+ function dist(ax,ay,bx,by) { return Math.sqrt((bx-ax)**2+(by-ay)**2); }
253
+ function clamp(v,min,max) { return Math.max(min,Math.min(max,v)); }
254
+ function bytesToHex(b) { return _bytesToHex(b); }
255
+
256
+ // ===== CONFIG =====
257
+
258
+ const CONFIG = {
259
+ FIELD:9, BALL_R:0.2, SPEED:0.05, GOAL_R:1.02,
260
+ CENTER_R:0.225, CENTER_X:4.5, CENTER_Y:4.5, COUNTDOWN:45,
261
+ GOLDEN_CHANCE:0.01, EXPLOSIVE_CHANCE:1/75,
262
+ SPAWN_COOLDOWN:60, SPAWN_INTERVAL:60, MAX_ON_FIELD:10,
263
+ TIMEOUT_LIMIT:5, PROGRESSIVE_CAP:5, BET_PER_BALL:5, MAX_TICKS_PER_BALL:600
264
+ };
265
+
266
+ const BUMPER = {
267
+ RADIUS:0.4, MIN_Y:0.4, MAX_Y:3.5, MIN_X:1.5, MAX_X:7.5,
268
+ MAX_SPEED:0.15, START_X:4.5, START_Y:2.0
269
+ };
270
+
271
+ // ===== HELPERS =====
272
+
273
+ function isInLeftGoal(b) { return dist(b.x,b.y,0,0) < CONFIG.GOAL_R; }
274
+ function isInRightGoal(b) { return dist(b.x,b.y,CONFIG.FIELD,0) < CONFIG.GOAL_R; }
275
+ function isGoal(b) { return isInLeftGoal(b) || isInRightGoal(b); }
276
+ function isInCenter(b) { return dist(b.x,b.y,CONFIG.CENTER_X,CONFIG.CENTER_Y) < CONFIG.CENTER_R+CONFIG.BALL_R; }
277
+ function isInUpperHalf(b) { return b.y < CONFIG.FIELD/2; }
278
+
279
+ function createBumper() {
280
+ return { x:BUMPER.START_X, y:BUMPER.START_Y, targetX:BUMPER.START_X, targetY:BUMPER.START_Y };
281
+ }
282
+
283
+ function moveBumper(bumper) {
284
+ const dx=bumper.targetX-bumper.x, dy=bumper.targetY-bumper.y;
285
+ const d=Math.sqrt(dx*dx+dy*dy);
286
+ if (d > BUMPER.MAX_SPEED) {
287
+ bumper.x=fpRound(bumper.x+(dx/d)*BUMPER.MAX_SPEED);
288
+ bumper.y=fpRound(bumper.y+(dy/d)*BUMPER.MAX_SPEED);
289
+ } else { bumper.x=bumper.targetX; bumper.y=bumper.targetY; }
290
+ }
291
+
292
+ // ===== BALL CREATION =====
293
+
294
+ function createBall(rng, id) {
295
+ const x = 0.5 + rng.nextDouble() * 8;
296
+ const y = CONFIG.FIELD - 0.3;
297
+ const angle = (220 + rng.nextDouble() * 100) * Math.PI / 180;
298
+ const typeRoll = rng.nextDouble();
299
+ let type='normal', multiplier=1;
300
+ if (typeRoll < CONFIG.GOLDEN_CHANCE) { type='golden'; multiplier=3; }
301
+ else if (typeRoll < CONFIG.GOLDEN_CHANCE+CONFIG.EXPLOSIVE_CHANCE) { type='explosive'; }
302
+ return {
303
+ id, x, y,
304
+ dx: Math.cos(angle)*CONFIG.SPEED,
305
+ dy: Math.sin(angle)*CONFIG.SPEED,
306
+ value:9, ticksSinceCountdown:0, alive:true, type, multiplier
307
+ };
308
+ }
309
+
310
+ function randomizeBounce(ball, rng) {
311
+ const variation = (rng.nextDouble() - 0.5) * 0.1 * Math.PI;
312
+ const angle = Math.atan2(ball.dy, ball.dx) + variation;
313
+ const speed = Math.sqrt(ball.dx**2 + ball.dy**2);
314
+ ball.dx = fpRound(Math.cos(angle) * speed);
315
+ ball.dy = fpRound(Math.sin(angle) * speed);
316
+ }
317
+
318
+ function collideBallBumper(ball, bumper, rng) {
319
+ const d = dist(ball.x,ball.y,bumper.x,bumper.y);
320
+ const minDist = CONFIG.BALL_R+BUMPER.RADIUS;
321
+ if (d < minDist && d > 0) {
322
+ const nx=(ball.x-bumper.x)/d, ny=(ball.y-bumper.y)/d;
323
+ const dot=ball.dx*nx+ball.dy*ny;
324
+ ball.dx=fpRound(ball.dx-2*dot*nx); ball.dy=fpRound(ball.dy-2*dot*ny);
325
+ ball.x=fpRound(bumper.x+nx*minDist); ball.y=fpRound(bumper.y+ny*minDist);
326
+ randomizeBounce(ball, rng);
327
+ return true;
328
+ }
329
+ return false;
330
+ }
331
+
332
+ // ===== GAME STATE =====
333
+
334
+ function createInitialState(serverSeed, numBalls, betPerBall=5) {
335
+ const serverSeedHash = sha256Hex(serverSeed);
336
+ const sessionId = sha256Hex(`${serverSeedHash}:uvs-paddla:1`);
337
+ return {
338
+ // UVS session header
339
+ uvsHeader: {
340
+ type: 'uvs-header',
341
+ uvsVersion: 1,
342
+ sessionId,
343
+ serverSeedHash,
344
+ clientSeed: 'uvs-paddla', // bumper position encoded per-tick in combinedSeed
345
+ minNonce: 1,
346
+ params: { numBalls, betPerBall },
347
+ extensions: ['physics-arcade@1.0'],
348
+ timestamp: new Date().toISOString()
349
+ },
350
+ // Game state
351
+ serverSeed,
352
+ rng: null, // initialized per-tick in tick()
353
+ balls: [],
354
+ bumper: createBumper(),
355
+ tickCount: 0,
356
+ ballsSpawned: 0,
357
+ numBalls,
358
+ betPerBall,
359
+ spawnCooldown: 0,
360
+ progressive: 1,
361
+ timeoutCount: 0,
362
+ totalWin: 0,
363
+ finished: false,
364
+ nextBallId: 1,
365
+ inputLog: []
366
+ };
367
+ }
368
+
369
+ // ===== TICK =====
370
+
371
+ function tick(state, bumperTarget) {
372
+ if (state.finished) return [];
373
+ const events = [];
374
+
375
+ state.tickCount++;
376
+ if (state.spawnCooldown > 0) state.spawnCooldown--;
377
+
378
+ // Apply bumper input first
379
+ if (bumperTarget) {
380
+ state.bumper.targetX = clamp(bumperTarget.x, BUMPER.MIN_X, BUMPER.MAX_X);
381
+ state.bumper.targetY = clamp(bumperTarget.y, BUMPER.MIN_Y, BUMPER.MAX_Y);
382
+ }
383
+ moveBumper(state.bumper);
384
+
385
+ // UVS: fresh PRNG per tick, combinedSeed encodes tick + bumper position
386
+ const combinedSeed = _tickCombinedSeed(
387
+ state.serverSeed, state.bumper.x, state.bumper.y, state.tickCount
388
+ );
389
+ state.rng = new UVS_PRNG(combinedSeed);
390
+
391
+ // Log input for replay
392
+ state.inputLog.push({
393
+ tick: state.tickCount,
394
+ target: { x: state.bumper.targetX, y: state.bumper.targetY }
395
+ });
396
+
397
+ // Spawn
398
+ if (state.tickCount % CONFIG.SPAWN_INTERVAL === 0 &&
399
+ state.balls.length < CONFIG.MAX_ON_FIELD &&
400
+ state.spawnCooldown <= 0 &&
401
+ state.ballsSpawned < state.numBalls) {
402
+ const ball = createBall(state.rng, state.nextBallId++);
403
+ state.balls.push(ball);
404
+ state.ballsSpawned++;
405
+ state.spawnCooldown = CONFIG.SPAWN_COOLDOWN;
406
+ events.push({ type:'spawn', ball });
407
+ }
408
+
409
+ // Update balls
410
+ for (const b of state.balls) {
411
+ if (!b.alive) continue;
412
+ b.ticksSinceCountdown++;
413
+ b.x=fpRound(b.x+b.dx); b.y=fpRound(b.y+b.dy);
414
+ const R=CONFIG.BALL_R, F=CONFIG.FIELD;
415
+ let hitWall=false;
416
+ if (b.x-R<0) { b.x=R; b.dx=-b.dx; hitWall=true; }
417
+ if (b.x+R>F) { b.x=F-R; b.dx=-b.dx; hitWall=true; }
418
+ if (b.y-R<0) { b.y=R; b.dy=-b.dy; hitWall=true; }
419
+ if (b.y+R>F) { b.y=F-R; b.dy=-b.dy; hitWall=true; }
420
+ if (b.type==='normal' && b.ticksSinceCountdown>=CONFIG.COUNTDOWN && b.value>0) {
421
+ b.value--; b.ticksSinceCountdown=0;
422
+ if (b.value<=0) { b.alive=false; b.diedFromTimeout=true; events.push({type:'timeout',ball:b}); }
423
+ }
424
+ if (b.alive && hitWall) randomizeBounce(b, state.rng);
425
+ }
426
+
427
+ // Bumper collision
428
+ for (const b of state.balls) {
429
+ if (b.alive && collideBallBumper(b, state.bumper, state.rng))
430
+ events.push({ type:'bumperHit', ball:b });
431
+ }
432
+
433
+ // Center recharge
434
+ for (const b of state.balls) {
435
+ if (b.alive && isInCenter(b)) {
436
+ const dx=b.x-CONFIG.CENTER_X, dy=b.y-CONFIG.CENTER_Y;
437
+ const d=Math.sqrt(dx*dx+dy*dy);
438
+ if (d>0) { b.dx=(dx/d)*CONFIG.SPEED; b.dy=(dy/d)*CONFIG.SPEED; randomizeBounce(b,state.rng); }
439
+ if (b.type==='normal' && b.value<9) {
440
+ b.value=9; b.ticksSinceCountdown=0;
441
+ events.push({ type:'recharge', ball:b });
442
+ }
443
+ }
444
+ }
445
+
446
+ // Goals
447
+ for (const ball of state.balls) {
448
+ if (!ball.alive) continue;
449
+ if (isGoal(ball)) {
450
+ const bs=state.betPerBall/5;
451
+ const prize=moneyRound(ball.value*ball.multiplier*state.progressive*bs);
452
+ state.totalWin=moneyRound(state.totalWin+prize);
453
+ if (ball.type==='golden') state.timeoutCount=0;
454
+ if (state.progressive<CONFIG.PROGRESSIVE_CAP) state.progressive++;
455
+ events.push({ type:'goal', ball, prize, side:isInLeftGoal(ball)?'left':'right' });
456
+ ball.alive=false;
457
+ if (ball.type==='explosive') {
458
+ state.timeoutCount=0;
459
+ events.push({ type:'explosion', ball, x:ball.x, y:ball.y });
460
+ for (const o of state.balls) {
461
+ if (o.alive && o.id!==ball.id && isInUpperHalf(o)) {
462
+ const ep=moneyRound(o.value*o.multiplier*state.progressive*bs);
463
+ state.totalWin=moneyRound(state.totalWin+ep);
464
+ if (state.progressive<CONFIG.PROGRESSIVE_CAP) state.progressive++;
465
+ events.push({ type:'exploded', ball:o, prize:ep });
466
+ o.alive=false;
467
+ }
468
+ }
469
+ }
470
+ }
471
+ }
472
+
473
+ // Ball-ball collisions
474
+ for (let i=0;i<state.balls.length;i++) {
475
+ for (let j=i+1;j<state.balls.length;j++) {
476
+ const b1=state.balls[i], b2=state.balls[j];
477
+ if (!b1.alive||!b2.alive) continue;
478
+ if (dist(b1.x,b1.y,b2.x,b2.y)<CONFIG.BALL_R*2) {
479
+ const s1=b1.type!=='normal', s2=b2.type!=='normal';
480
+ if (s1&&s2) {
481
+ const dx=b2.x-b1.x, dy=b2.y-b1.y, d=Math.sqrt(dx*dx+dy*dy)||1;
482
+ const nx=dx/d, ny=dy/d, ov=CONFIG.BALL_R*2-d;
483
+ if (ov>0) { b1.x-=nx*ov*0.5; b1.y-=ny*ov*0.5; b2.x+=nx*ov*0.5; b2.y+=ny*ov*0.5; }
484
+ b1.dx=-nx*CONFIG.SPEED; b1.dy=-ny*CONFIG.SPEED;
485
+ b2.dx=nx*CONFIG.SPEED; b2.dy=ny*CONFIG.SPEED;
486
+ randomizeBounce(b1,state.rng); randomizeBounce(b2,state.rng);
487
+ continue;
488
+ }
489
+ if (s1) { b2.alive=false; const cp=moneyRound(state.betPerBall/5); state.totalWin=moneyRound(state.totalWin+cp); events.push({type:'collision',winner:b1,loser:b2,prize:cp}); continue; }
490
+ if (s2) { b1.alive=false; const cp=moneyRound(state.betPerBall/5); state.totalWin=moneyRound(state.totalWin+cp); events.push({type:'collision',winner:b2,loser:b1,prize:cp}); continue; }
491
+ if (b1.value===b2.value) {
492
+ const prize=moneyRound(b1.value*2*(state.betPerBall/5));
493
+ state.totalWin=moneyRound(state.totalWin+prize);
494
+ events.push({ type:'double', b1, b2, prize });
495
+ if (state.rng.nextDouble()<0.5) b2.alive=false; else b1.alive=false;
496
+ } else {
497
+ const cp=moneyRound(state.betPerBall/5); state.totalWin=moneyRound(state.totalWin+cp);
498
+ const loser=b1.value<b2.value?b1:b2, winner=b1.value<b2.value?b2:b1;
499
+ loser.alive=false;
500
+ const dx=winner.x-loser.x, dy=winner.y-loser.y, d=Math.sqrt(dx*dx+dy*dy)||1;
501
+ winner.dx=(dx/d)*CONFIG.SPEED; winner.dy=(dy/d)*CONFIG.SPEED;
502
+ randomizeBounce(winner,state.rng);
503
+ events.push({ type:'collision', winner, loser, prize:cp });
504
+ }
505
+ }
506
+ }
507
+ }
508
+
509
+ // Timeouts
510
+ for (const b of state.balls) {
511
+ if (!b.alive && b.diedFromTimeout) {
512
+ state.timeoutCount++;
513
+ if (state.timeoutCount>=CONFIG.TIMEOUT_LIMIT) {
514
+ state.progressive=1; state.timeoutCount=0;
515
+ events.push({ type:'progressiveReset' });
516
+ }
517
+ b.diedFromTimeout=false;
518
+ }
519
+ }
520
+
521
+ state.balls = state.balls.filter(b=>b.alive);
522
+
523
+ // Auto-collect special balls
524
+ if (state.balls.length>0 && !state.balls.some(b=>b.type==='normal')) {
525
+ for (const b of state.balls) {
526
+ if (b.alive) {
527
+ const prize=moneyRound(b.value*b.multiplier*state.progressive*(state.betPerBall/5));
528
+ state.totalWin=moneyRound(state.totalWin+prize);
529
+ if (state.progressive<CONFIG.PROGRESSIVE_CAP) state.progressive++;
530
+ events.push({ type:'autoCollect', ball:b, prize });
531
+ b.alive=false;
532
+ }
533
+ }
534
+ state.balls=[];
535
+ }
536
+
537
+ // End condition
538
+ if (state.ballsSpawned>=state.numBalls && state.balls.length===0) {
539
+ state.finished=true;
540
+ events.push({ type:'gameEnd', totalWin:state.totalWin });
541
+ }
542
+
543
+ return events;
544
+ }
545
+
546
+ // ===== REPLAY =====
547
+
548
+ function replay(serverSeed, numBalls, inputLog, betPerBall=5) {
549
+ const state = createInitialState(serverSeed, numBalls, betPerBall);
550
+ let inputIdx=0, safety=0;
551
+ const maxTicks = numBalls * CONFIG.MAX_TICKS_PER_BALL;
552
+ while (!state.finished && safety<maxTicks) {
553
+ let target=null;
554
+ if (inputIdx<inputLog.length && inputLog[inputIdx].tick===state.tickCount+1) {
555
+ target=inputLog[inputIdx].target; inputIdx++;
556
+ } else if (state.tickCount>0) {
557
+ target={ x:state.bumper.targetX, y:state.bumper.targetY };
558
+ }
559
+ tick(state,target); safety++;
560
+ }
561
+ return state;
562
+ }
563
+
564
+ // ===== FINISH =====
565
+
566
+ function finishGame(state) {
567
+ const target={ x:state.bumper.targetX, y:state.bumper.targetY };
568
+ let safety=0;
569
+ while (!state.finished && safety<100000) { tick(state,target); safety++; }
570
+ }
571
+
572
+ // ===== EXPORT =====
573
+
574
+ if (typeof module !== 'undefined' && module.exports) {
575
+ module.exports = {
576
+ ENGINE_VERSION,
577
+ CONFIG, BUMPER,
578
+ UVS_PRNG,
579
+ createInitialState, tick, replay, finishGame,
580
+ clamp, fpRound, bytesToHex,
581
+ sha256Hex, sha512Hex
582
+ };
583
+ }
package/package.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "paddla-engine",
3
+ "version": "9.0.1",
4
+ "description": "PADDLA physics arcade engine — UVS 1.0 compatible (ChaCha20 + SHA-512)",
5
+ "main": "index.js",
6
+ "license": "MIT",
7
+ "author": "Uncloned Math <uncloned.work>",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/constarik/paddla-engine.git"
11
+ },
12
+ "keywords": ["paddla", "uvs", "provably-fair", "chacha20", "gaming"]
13
+ }