Reporting a compounding set of issues found while shipping a Bloom Engine game to the web via perry compile --target web. Each silently returns undefined or a no-op instead of erroring, so they all manifest as "feature broken, no console error" — hours of bisecting each.
Perry version: 0.5.146
Repro scope: a 2269-line single-file TypeScript game using bloom/core, bloom/shapes, bloom/text, bloom/textures. Generated WASM boots, bloom_web (wgpu) renders, but the game state silently no-ops in several places.
1. String methods not routed through __classDispatch
data.charCodeAt(i) returns undefined on web. __classDispatch(str, "charCodeAt", args) falls through classMethodTable (strings have no __class__) and __uiMethodMap (not a UI method) → return undefined. Any parser loop using charCodeAt / indexOf / slice / split / startsWith on a regular string silently breaks.
Works on native (compiled differently).
Workaround: post-process generated HTML to inject a typeof objVal === 'string' fast-path at the top of __classDispatch with cases for the common methods.
2. Imported const-object fields resolve to undefined
import { Key } from "bloom/core"; // Key.ENTER = 265 in bloom/core/keys.ts
isKeyPressed(Key.ENTER); // bloom FFI sees TAG_UNDEFINED
At runtime the FFI function is invoked with BigInt 0x7FFC_0000_0000_0001 (= TAG_UNDEFINED). Workaround: inline every Key.X as a local const K_X = 265;. Works on native.
3. Array.push runs but .length stays 0
const xs: number[] = [];
xs.push(1);
xs.push(2);
console.log(xs.length); // → 0 on web, 2 on native
I believe this is the same NaN-canonicalization root cause as #5 — getHandle(handle) in rt.array_push / rt.array_length receives a canonicalized NaN Number, isPointer returns false, silent no-op.
Workaround in game code: index-based assignment + manual counter.
4. Math.sin, Math.cos, Math.tan, Math.atan2, Math.asin, Math.acos, Math.atan, Math.exp, Math.sign, Math.trunc missing from rt / __memDispatch
Only math_floor / ceil / round / abs / sqrt / pow / random / log / min / max are shipped. Math.sin(x) * 0.3 returns undefined * 0.3 → NaN. All animations relying on trig silently go static.
5. NaN-box canonicalization across the JS function boundary (Firefox/SpiderMonkey)
This is the nastiest one. wrapForI64 converts WASM i64 BigInt args to f64 by bit-reinterpret (_u64[0] = a; return _f64[0]). On Firefox, SpiderMonkey's internal NaN-boxing collapses foreign NaNs to canonical 0x7FF8_0000_0000_0000 when the Number crosses a function argument boundary — stripping STRING_TAG/POINTER_TAG/INT32_TAG.
mem_call avoids this by passing via WASM memory, but the direct rt imports (string_len, isPointer, getHandle) go through the canonicalizing f64 path. Result: any .length on a runtime-created string or handle-based array returns 0 in Firefox.
Similarly, my __perryJsValueToBits helper initially went fromJsValue(v) → f64 → _u64[0]. The f64 NaN got canonicalized on the function return (even through Float64Array). Building the BigInt directly ((STRING_TAG << 48n) | BigInt(id)) works. Suggest Perry's __jsValueToBits (the one already in the generated HTML at __classDispatch time) is the correct template — but the rt.* imports called from WASM don't benefit from it because they receive f64, not BigInt.
Workaround used: patched wrapForI64 to pass BigInt unchanged for __rawBigint-flagged functions, plus made f64ToU64(x) BigInt-passthrough so the tag helpers work.
6. INT32-tagged integer constants break bloom FFI wasm-bindgen
const FILTER_NEAREST = 1 is stored NaN-boxed as 0x7FFE_0000_0000_0001. Passed to a wasm-bindgen-generated bloom_set_texture_filter(handle, filter) via wrapForI64, the _u64[0] = a; _f64[0] conversion yields a NaN Number; wasm-bindgen then throws TypeError: Cannot convert BigInt value to a number when it tries to coerce back to f64 for the WASM call.
Workaround: patched wrapForI64 to decode tag === 0x7FFEn BigInts to Number(a & 0xFFFFFFFFn).
Ask
Happy to send PRs for the self-contained HTML template (items 1, 4) if that's useful — the quirks in 2/3/5/6 feel more like codegen + runtime protocol decisions that you'd want to design yourself.
Reporting a compounding set of issues found while shipping a Bloom Engine game to the web via
perry compile --target web. Each silently returnsundefinedor a no-op instead of erroring, so they all manifest as "feature broken, no console error" — hours of bisecting each.Perry version: 0.5.146
Repro scope: a 2269-line single-file TypeScript game using bloom/core, bloom/shapes, bloom/text, bloom/textures. Generated WASM boots, bloom_web (wgpu) renders, but the game state silently no-ops in several places.
1. String methods not routed through
__classDispatchdata.charCodeAt(i)returnsundefinedon web.__classDispatch(str, "charCodeAt", args)falls throughclassMethodTable(strings have no__class__) and__uiMethodMap(not a UI method) →return undefined. Any parser loop usingcharCodeAt/indexOf/slice/split/startsWithon a regular string silently breaks.Works on native (compiled differently).
Workaround: post-process generated HTML to inject a
typeof objVal === 'string'fast-path at the top of__classDispatchwith cases for the common methods.2. Imported const-object fields resolve to
undefinedAt runtime the FFI function is invoked with BigInt
0x7FFC_0000_0000_0001(=TAG_UNDEFINED). Workaround: inline everyKey.Xas a localconst K_X = 265;. Works on native.3.
Array.pushruns but.lengthstays 0I believe this is the same NaN-canonicalization root cause as #5 —
getHandle(handle)inrt.array_push/rt.array_lengthreceives a canonicalized NaN Number,isPointerreturns false, silent no-op.Workaround in game code: index-based assignment + manual counter.
4.
Math.sin,Math.cos,Math.tan,Math.atan2,Math.asin,Math.acos,Math.atan,Math.exp,Math.sign,Math.truncmissing fromrt/__memDispatchOnly
math_floor / ceil / round / abs / sqrt / pow / random / log / min / maxare shipped.Math.sin(x) * 0.3returnsundefined * 0.3→NaN. All animations relying on trig silently go static.5. NaN-box canonicalization across the JS function boundary (Firefox/SpiderMonkey)
This is the nastiest one.
wrapForI64converts WASM i64 BigInt args to f64 by bit-reinterpret (_u64[0] = a; return _f64[0]). On Firefox, SpiderMonkey's internal NaN-boxing collapses foreign NaNs to canonical0x7FF8_0000_0000_0000when the Number crosses a function argument boundary — stripping STRING_TAG/POINTER_TAG/INT32_TAG.mem_callavoids this by passing via WASM memory, but the direct rt imports (string_len,isPointer,getHandle) go through the canonicalizing f64 path. Result: any.lengthon a runtime-created string or handle-based array returns 0 in Firefox.Similarly, my
__perryJsValueToBitshelper initially wentfromJsValue(v)→ f64 →_u64[0]. The f64 NaN got canonicalized on the function return (even throughFloat64Array). Building the BigInt directly ((STRING_TAG << 48n) | BigInt(id)) works. Suggest Perry's__jsValueToBits(the one already in the generated HTML at__classDispatchtime) is the correct template — but thert.*imports called from WASM don't benefit from it because they receive f64, not BigInt.Workaround used: patched
wrapForI64to pass BigInt unchanged for__rawBigint-flagged functions, plus madef64ToU64(x)BigInt-passthrough so the tag helpers work.6. INT32-tagged integer constants break bloom FFI wasm-bindgen
const FILTER_NEAREST = 1is stored NaN-boxed as0x7FFE_0000_0000_0001. Passed to a wasm-bindgen-generatedbloom_set_texture_filter(handle, filter)viawrapForI64, the_u64[0] = a; _f64[0]conversion yields a NaN Number; wasm-bindgen then throwsTypeError: Cannot convert BigInt value to a numberwhen it tries to coerce back to f64 for the WASM call.Workaround: patched
wrapForI64to decodetag === 0x7FFEnBigInts toNumber(a & 0xFFFFFFFFn).Ask
Happy to send PRs for the self-contained HTML template (items 1, 4) if that's useful — the quirks in 2/3/5/6 feel more like codegen + runtime protocol decisions that you'd want to design yourself.