create-bloop 0.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 (41) hide show
  1. package/dist/index.js +5029 -0
  2. package/package.json +23 -0
  3. package/templates/hello/index.html +16 -0
  4. package/templates/hello/package.json +20 -0
  5. package/templates/hello/src/config.ts +1 -0
  6. package/templates/hello/src/draw.ts +26 -0
  7. package/templates/hello/src/game.ts +28 -0
  8. package/templates/hello/src/main.ts +30 -0
  9. package/templates/hello/src/style.css +15 -0
  10. package/templates/hello/test/game.test.ts +19 -0
  11. package/templates/hello/tsconfig.json +26 -0
  12. package/templates/hello/vite.config.ts +5 -0
  13. package/templates/mario/.claude/prototype-next-steps.md +19 -0
  14. package/templates/mario/.claude/sidequests.md +10 -0
  15. package/templates/mario/index.html +17 -0
  16. package/templates/mario/package.json +22 -0
  17. package/templates/mario/public/sprites/MarioIdle.json +26 -0
  18. package/templates/mario/public/sprites/MarioIdle.png +0 -0
  19. package/templates/mario/public/sprites/MarioJump.json +26 -0
  20. package/templates/mario/public/sprites/MarioJump.png +0 -0
  21. package/templates/mario/public/sprites/MarioSkid.json +26 -0
  22. package/templates/mario/public/sprites/MarioSkid.png +0 -0
  23. package/templates/mario/public/sprites/MarioWalk.json +54 -0
  24. package/templates/mario/public/sprites/MarioWalk.png +0 -0
  25. package/templates/mario/src/chromatic-aberration.ts +107 -0
  26. package/templates/mario/src/config.ts +26 -0
  27. package/templates/mario/src/draw.ts +312 -0
  28. package/templates/mario/src/flipbook.ts +159 -0
  29. package/templates/mario/src/game.ts +171 -0
  30. package/templates/mario/src/main.ts +126 -0
  31. package/templates/mario/src/sprites.ts +14 -0
  32. package/templates/mario/src/style.css +7 -0
  33. package/templates/mario/src/systems/animation.ts +30 -0
  34. package/templates/mario/src/systems/collision.ts +41 -0
  35. package/templates/mario/src/systems/inputs.ts +66 -0
  36. package/templates/mario/src/systems/phase.ts +12 -0
  37. package/templates/mario/src/systems/physics.ts +22 -0
  38. package/templates/mario/src/tape-load.ts +165 -0
  39. package/templates/mario/tape-load.html +68 -0
  40. package/templates/mario/tsconfig.json +27 -0
  41. package/templates/mario/vite.config.ts +5 -0
@@ -0,0 +1,126 @@
1
+ import "./style.css";
2
+ import { Toodle } from "@bloopjs/toodle";
3
+ import { joinRollbackRoom, start } from "@bloopjs/web";
4
+ import { createChromaticAberrationEffect } from "./chromatic-aberration";
5
+ import { createDrawState, draw as drawFn } from "./draw";
6
+ import { game } from "./game";
7
+
8
+ // In dev, vite serves wasm from /bloop-wasm/. In prod, it's bundled at ./bloop.wasm
9
+ const wasmUrl = import.meta.env.DEV
10
+ ? new URL("/bloop-wasm/bloop.wasm", window.location.href)
11
+ : new URL("./bloop.wasm", import.meta.url);
12
+
13
+ let draw = drawFn;
14
+
15
+ const app = await start({
16
+ game,
17
+ engineWasmUrl: wasmUrl,
18
+ startRecording: false,
19
+ debugUi: {
20
+ initiallyVisible: false,
21
+ },
22
+ });
23
+
24
+ const canvas = app.canvas;
25
+ if (!canvas) throw new Error("No canvas element found");
26
+
27
+ const toodle = await Toodle.attach(canvas, {
28
+ filter: "nearest",
29
+ backend: "webgpu",
30
+ limits: { textureArrayLayers: 5 },
31
+ });
32
+
33
+ toodle.clearColor = { r: 0.36, g: 0.58, b: 0.99, a: 1 }; // Mario sky blue
34
+
35
+ // Load sprites
36
+ const spriteUrl = (name: string) =>
37
+ new URL(
38
+ `${import.meta.env.BASE_URL}sprites/${name}.png`,
39
+ window.location.href,
40
+ );
41
+
42
+ await toodle.assets.registerBundle("main", {
43
+ textures: {
44
+ marioIdle: spriteUrl("MarioIdle"),
45
+ marioWalk: spriteUrl("MarioWalk"),
46
+ marioJump: spriteUrl("MarioJump"),
47
+ marioSkid: spriteUrl("MarioSkid"),
48
+ },
49
+ autoLoad: true,
50
+ });
51
+
52
+ await toodle.assets.loadFont(
53
+ "ComicNeue",
54
+ new URL("https://toodle.gg/fonts/ComicNeue-Regular-msdf.json"),
55
+ );
56
+
57
+ const drawState = createDrawState(toodle);
58
+
59
+ requestAnimationFrame(function frame() {
60
+ draw(app.game, toodle, drawState);
61
+ requestAnimationFrame(frame);
62
+ });
63
+
64
+ let networkJoined = false;
65
+
66
+ // Debug: Press R to start recording mid-game
67
+ window.addEventListener("keydown", (e) => {
68
+ if (e.key === "r" && !app.sim.isRecording) {
69
+ app.sim.record();
70
+ console.log("Started recording at frame", app.sim.time.frame);
71
+ }
72
+ });
73
+
74
+ // Debug: Press G to toggle glitch effect
75
+ const glitchEffect = createChromaticAberrationEffect(toodle);
76
+ let glitchEnabled = false;
77
+ window.addEventListener("keydown", (e) => {
78
+ if (e.key === "g") {
79
+ glitchEnabled = !glitchEnabled;
80
+ toodle.postprocess = glitchEnabled ? glitchEffect : null;
81
+ }
82
+ });
83
+
84
+ game.system("title-input", {
85
+ update({ bag, inputs }) {
86
+ if (bag.phase !== "title") return;
87
+
88
+ if ((inputs.keys.enter.down || inputs.mouse.left.down) && !networkJoined) {
89
+ // Online multiplayer - wait for connection
90
+ bag.mode = "online";
91
+ bag.phase = "waiting";
92
+ networkJoined = true;
93
+
94
+ // Phase transitions are handled by the session-watcher system
95
+ joinRollbackRoom("mario-demo", app, {
96
+ onSessionStart() {
97
+ bag.phase = "playing";
98
+
99
+ app.sim.record(100_000);
100
+ console.log(
101
+ "Network session started, recording at frame",
102
+ app.sim.time.frame,
103
+ );
104
+ },
105
+ onSessionEnd() {
106
+ networkJoined = false;
107
+ },
108
+ });
109
+ }
110
+ },
111
+ });
112
+
113
+ // HMR support
114
+ if (import.meta.hot) {
115
+ import.meta.hot.accept("./game", async (newModule) => {
116
+ if (newModule?.game) {
117
+ await app.acceptHmr(newModule.game, { wasmUrl });
118
+ }
119
+ });
120
+
121
+ import.meta.hot.accept("./draw", async (newModule) => {
122
+ if (newModule?.draw && newModule?.createDrawState) {
123
+ draw = newModule.draw;
124
+ }
125
+ });
126
+ }
@@ -0,0 +1,14 @@
1
+ // TODO: allow loading animated sprite JSON from async boot
2
+ import marioIdleJson from "../public/sprites/MarioIdle.json";
3
+ import marioJumpJson from "../public/sprites/MarioJump.json";
4
+ import marioSkidJson from "../public/sprites/MarioSkid.json";
5
+ import marioWalkJson from "../public/sprites/MarioWalk.json";
6
+ import { AsepriteFlipbook } from "./flipbook";
7
+
8
+ /** Static flipbook data - frame definitions from Aseprite */
9
+ export const FLIPBOOKS = {
10
+ idle: AsepriteFlipbook(marioIdleJson),
11
+ run: AsepriteFlipbook(marioWalkJson),
12
+ jump: AsepriteFlipbook(marioJumpJson),
13
+ skid: AsepriteFlipbook(marioSkidJson),
14
+ };
@@ -0,0 +1,7 @@
1
+ * {
2
+ box-sizing: border-box;
3
+ }
4
+
5
+ body {
6
+ margin: 0;
7
+ }
@@ -0,0 +1,30 @@
1
+ import { reset, step } from "../flipbook";
2
+ import type { Player, Pose } from "../game";
3
+ import { PhaseSystem } from "./phase";
4
+
5
+ export const AnimationSystem = PhaseSystem("playing", {
6
+ update({ bag, time }) {
7
+ updatePlayerAnimation(bag.p1, time.dt);
8
+ updatePlayerAnimation(bag.p2, time.dt);
9
+ },
10
+ });
11
+
12
+ function updatePlayerAnimation(player: Player, dt: number) {
13
+ const newPose = determinePose(player);
14
+
15
+ if (newPose !== player.pose) {
16
+ player.pose = newPose;
17
+ reset(player.anims[player.pose]);
18
+ }
19
+
20
+ if (!player.anims[player.pose]) {
21
+ throw new Error(`No animation found for pose ${player.pose}`);
22
+ }
23
+ step(player.anims[player.pose], dt * 1000);
24
+ }
25
+
26
+ function determinePose(player: Player): Pose {
27
+ if (!player.grounded) return "jump";
28
+ if (player.vx !== 0) return "run";
29
+ return "idle";
30
+ }
@@ -0,0 +1,41 @@
1
+ import * as cfg from "../config";
2
+ import { PhaseSystem } from "./phase";
3
+
4
+ export const CollisionSystem = PhaseSystem("playing", {
5
+ update({ bag, time }) {
6
+ const block = bag.block;
7
+
8
+ for (const p of [bag.p1, bag.p2]) {
9
+ // Only check if player is moving upward (positive vy)
10
+ if (p.vy <= 0) continue;
11
+
12
+ // Check if player's head intersects with block
13
+ // Player's feet are at p.y, head is at p.y + PLAYER_HEIGHT
14
+ const playerTop = p.y + cfg.PLAYER_HEIGHT;
15
+ const playerLeft = p.x - cfg.PLAYER_WIDTH / 2;
16
+ const playerRight = p.x + cfg.PLAYER_WIDTH / 2;
17
+
18
+ // Block bottom is at BLOCK_Y, top is at BLOCK_Y + BLOCK_SIZE
19
+ const blockBottom = cfg.BLOCK_Y;
20
+ const blockLeft = block.x - cfg.BLOCK_SIZE / 2;
21
+ const blockRight = block.x + cfg.BLOCK_SIZE / 2;
22
+
23
+ // AABB collision - head hitting bottom of block
24
+ const hitX = playerRight > blockLeft && playerLeft < blockRight;
25
+ const hitY =
26
+ playerTop > blockBottom && playerTop < blockBottom + cfg.BLOCK_SIZE;
27
+
28
+ if (hitX && hitY && bag.coin.visible === false) {
29
+ // Bonk! Stop upward movement
30
+ p.vy = 0;
31
+ p.y = blockBottom - cfg.PLAYER_HEIGHT;
32
+
33
+ p.score += 1;
34
+
35
+ bag.coin.hitTime = time.time;
36
+ bag.coin.visible = true;
37
+ bag.coin.winner = p === bag.p1 ? 1 : 2;
38
+ }
39
+ }
40
+ },
41
+ });
@@ -0,0 +1,66 @@
1
+ import * as cfg from "../config";
2
+ import { PhaseSystem } from "./phase";
3
+
4
+ // Player 1 input: WASD
5
+ export const InputsSystem = PhaseSystem("playing", {
6
+ update({ bag, players, net }) {
7
+ const p1 = bag.p1;
8
+ const p2 = bag.p2;
9
+
10
+ // P1 Horizontal movement
11
+ p1.vx = 0;
12
+ if (players[0].keys.a.held) {
13
+ p1.x -= cfg.MOVE_SPEED;
14
+ p1.vx = -cfg.MOVE_SPEED;
15
+ p1.facingDir = -1;
16
+ }
17
+ if (players[0].keys.d.held) {
18
+ p1.x += cfg.MOVE_SPEED;
19
+ p1.vx = cfg.MOVE_SPEED;
20
+ p1.facingDir = 1;
21
+ }
22
+ // P1 Jump
23
+ const wantsJump = players[0].keys.w.down || players[0].mouse.left.down;
24
+ if (wantsJump && p1.grounded) {
25
+ p1.vy = cfg.JUMP_VELOCITY;
26
+ p1.grounded = false;
27
+ }
28
+
29
+ // P2 Horizontal movement
30
+ p2.vx = 0;
31
+ if (players[1].keys.a.held) {
32
+ p2.x -= cfg.MOVE_SPEED;
33
+ p2.vx = -cfg.MOVE_SPEED;
34
+ p2.facingDir = -1;
35
+ }
36
+ if (players[1].keys.d.held) {
37
+ p2.x += cfg.MOVE_SPEED;
38
+ p2.vx = cfg.MOVE_SPEED;
39
+ p2.facingDir = 1;
40
+ }
41
+ // P2 Jump
42
+ if ((players[1].keys.w.down || players[1].mouse.left.down) && p2.grounded) {
43
+ p2.vy = cfg.JUMP_VELOCITY;
44
+ p2.grounded = false;
45
+ }
46
+
47
+ if (!net.isInSession) {
48
+ // locally, control second player with ijkl
49
+ if (players[0].keys.j.held) {
50
+ p2.x -= cfg.MOVE_SPEED;
51
+ p2.vx = -cfg.MOVE_SPEED;
52
+ p2.facingDir = -1;
53
+ }
54
+ if (players[0].keys.l.held) {
55
+ p2.x += cfg.MOVE_SPEED;
56
+ p2.vx = cfg.MOVE_SPEED;
57
+ p2.facingDir = 1;
58
+ }
59
+ // Jump
60
+ if (players[0].keys.i.down && p2.grounded) {
61
+ p2.vy = cfg.JUMP_VELOCITY;
62
+ p2.grounded = false;
63
+ }
64
+ }
65
+ },
66
+ });
@@ -0,0 +1,12 @@
1
+ import type { GameSystem, Phase } from "../game";
2
+
3
+ export function PhaseSystem(phase: Phase, system: GameSystem): GameSystem {
4
+ const original = system.update;
5
+ return {
6
+ ...system,
7
+ update(ctx) {
8
+ if (ctx.bag.phase !== phase) return;
9
+ original?.(ctx);
10
+ },
11
+ };
12
+ }
@@ -0,0 +1,22 @@
1
+ import * as cfg from "../config";
2
+ import { PhaseSystem } from "./phase";
3
+
4
+ // Gravity for both players
5
+ export const PhysicsSystem = PhaseSystem("playing", {
6
+ update({ bag }) {
7
+ for (const p of [bag.p1, bag.p2]) {
8
+ if (!p.grounded) {
9
+ p.vy -= cfg.GRAVITY;
10
+ p.vy = Math.max(p.vy, -cfg.MAX_FALL_SPEED);
11
+ }
12
+
13
+ p.y += p.vy;
14
+
15
+ if (p.y <= cfg.GROUND_Y) {
16
+ p.y = cfg.GROUND_Y;
17
+ p.vy = 0;
18
+ p.grounded = true;
19
+ }
20
+ }
21
+ },
22
+ });
@@ -0,0 +1,165 @@
1
+ import "./style.css";
2
+ import { Toodle } from "@bloopjs/toodle";
3
+ import { start } from "@bloopjs/web";
4
+ import { createDrawState, draw } from "./draw";
5
+ import { game } from "./game";
6
+
7
+ const wasmUrl = import.meta.env.DEV
8
+ ? new URL("/bloop-wasm/bloop.wasm", window.location.href)
9
+ : new URL("./bloop.wasm", import.meta.url);
10
+
11
+ const DB_NAME = "mario-tapes";
12
+ const STORE_NAME = "tapes";
13
+ const TAPE_KEY = "last";
14
+
15
+ const statusEl = document.getElementById("status")!;
16
+ const inputEl = document.getElementById("tape-input") as HTMLInputElement;
17
+ const replayBtn = document.getElementById("replay-last") as HTMLButtonElement;
18
+
19
+ function openDB(): Promise<IDBDatabase> {
20
+ return new Promise((resolve, reject) => {
21
+ const request = indexedDB.open(DB_NAME, 1);
22
+ request.onerror = () => reject(request.error);
23
+ request.onsuccess = () => resolve(request.result);
24
+ request.onupgradeneeded = () => {
25
+ request.result.createObjectStore(STORE_NAME);
26
+ };
27
+ });
28
+ }
29
+
30
+ async function loadTape(bytes: Uint8Array, fileName: string) {
31
+ statusEl.textContent = "Loading tape...";
32
+
33
+ // Start the app with recording disabled (we're loading a tape)
34
+ const app = await start({
35
+ game,
36
+ engineWasmUrl: wasmUrl,
37
+ debugUi: true,
38
+ startRecording: false,
39
+ });
40
+
41
+ import.meta.hot?.accept("./game", async (newModule) => {
42
+ await app.acceptHmr(newModule?.game, {
43
+ wasmUrl,
44
+ files: ["./game"],
45
+ });
46
+ });
47
+
48
+ // Load the tape and pause playback
49
+ app.loadTape(bytes);
50
+ app.sim.pause();
51
+
52
+ const canvas = app.canvas;
53
+ if (!canvas) throw new Error("No canvas element found");
54
+
55
+ const toodle = await Toodle.attach(canvas, {
56
+ filter: "nearest",
57
+ backend: "webgpu",
58
+ limits: { textureArrayLayers: 5 },
59
+ });
60
+
61
+ toodle.clearColor = { r: 0.36, g: 0.58, b: 0.99, a: 1 };
62
+
63
+ // Load sprites
64
+ const spriteUrl = (name: string) =>
65
+ new URL(
66
+ `${import.meta.env.BASE_URL}sprites/${name}.png`,
67
+ window.location.href,
68
+ );
69
+
70
+ await toodle.assets.registerBundle("main", {
71
+ textures: {
72
+ marioIdle: spriteUrl("MarioIdle"),
73
+ marioWalk: spriteUrl("MarioWalk"),
74
+ marioJump: spriteUrl("MarioJump"),
75
+ marioSkid: spriteUrl("MarioSkid"),
76
+ },
77
+ autoLoad: true,
78
+ });
79
+
80
+ await toodle.assets.loadFont(
81
+ "ComicNeue",
82
+ new URL("https://toodle.gg/fonts/ComicNeue-Regular-msdf.json"),
83
+ );
84
+
85
+ const drawState = createDrawState(toodle);
86
+
87
+ requestAnimationFrame(function frame() {
88
+ draw(app.game, toodle, drawState);
89
+ requestAnimationFrame(frame);
90
+ });
91
+
92
+ statusEl.textContent = `Loaded tape: ${fileName}. Press Escape to toggle debug UI.`;
93
+
94
+ // Hide the file input and show the game
95
+ inputEl.style.display = "none";
96
+ replayBtn.style.display = "none";
97
+ document.querySelector(".container")!.remove();
98
+ }
99
+
100
+ async function saveTapeToStorage(
101
+ bytes: Uint8Array,
102
+ fileName: string,
103
+ ): Promise<void> {
104
+ const db = await openDB();
105
+ return new Promise((resolve, reject) => {
106
+ const tx = db.transaction(STORE_NAME, "readwrite");
107
+ tx.objectStore(STORE_NAME).put({ bytes, fileName }, TAPE_KEY);
108
+ tx.oncomplete = () => resolve();
109
+ tx.onerror = () => reject(tx.error);
110
+ });
111
+ }
112
+
113
+ async function loadTapeFromStorage(): Promise<{
114
+ bytes: Uint8Array;
115
+ fileName: string;
116
+ } | null> {
117
+ try {
118
+ const db = await openDB();
119
+ return new Promise((resolve, reject) => {
120
+ const tx = db.transaction(STORE_NAME, "readonly");
121
+ const request = tx.objectStore(STORE_NAME).get(TAPE_KEY);
122
+ request.onsuccess = () => resolve(request.result ?? null);
123
+ request.onerror = () => reject(request.error);
124
+ });
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ // Check for saved tape on page load
131
+ loadTapeFromStorage().then((savedTape) => {
132
+ if (savedTape) {
133
+ replayBtn.style.display = "block";
134
+ replayBtn.textContent = `Replay last tape (${savedTape.fileName})`;
135
+ }
136
+ });
137
+
138
+ replayBtn.addEventListener("click", async () => {
139
+ const saved = await loadTapeFromStorage();
140
+ if (!saved) {
141
+ statusEl.textContent = "No saved tape found";
142
+ return;
143
+ }
144
+
145
+ try {
146
+ await loadTape(saved.bytes, saved.fileName);
147
+ } catch (err) {
148
+ statusEl.textContent = `Error loading tape: ${err}`;
149
+ console.error(err);
150
+ }
151
+ });
152
+
153
+ inputEl.addEventListener("change", async () => {
154
+ const file = inputEl.files?.[0];
155
+ if (!file) return;
156
+
157
+ try {
158
+ const bytes = new Uint8Array(await file.arrayBuffer());
159
+ await saveTapeToStorage(bytes, file.name);
160
+ await loadTape(bytes, file.name);
161
+ } catch (err) {
162
+ statusEl.textContent = `Error loading tape: ${err}`;
163
+ console.error(err);
164
+ }
165
+ });
@@ -0,0 +1,68 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Load Tape - Mario</title>
7
+ <style>
8
+ body {
9
+ margin: 0;
10
+ padding: 20px;
11
+ font-family: system-ui, sans-serif;
12
+ background: #1a1a2e;
13
+ color: #eee;
14
+ }
15
+ .container {
16
+ max-width: 600px;
17
+ margin: 0 auto;
18
+ }
19
+ h1 {
20
+ margin-bottom: 20px;
21
+ }
22
+ .file-input-wrapper {
23
+ margin-bottom: 20px;
24
+ }
25
+ input[type="file"] {
26
+ padding: 10px;
27
+ background: #16213e;
28
+ border: 2px dashed #0f3460;
29
+ border-radius: 8px;
30
+ color: #eee;
31
+ cursor: pointer;
32
+ width: 100%;
33
+ box-sizing: border-box;
34
+ }
35
+ input[type="file"]:hover {
36
+ border-color: #e94560;
37
+ }
38
+ .status {
39
+ padding: 10px;
40
+ background: #16213e;
41
+ border-radius: 8px;
42
+ margin-bottom: 20px;
43
+ }
44
+ .instructions {
45
+ font-size: 14px;
46
+ color: #888;
47
+ line-height: 1.6;
48
+ }
49
+ </style>
50
+ </head>
51
+ <body>
52
+ <div class="container">
53
+ <h1>Load Tape</h1>
54
+ <div class="file-input-wrapper">
55
+ <input type="file" id="tape-input" accept=".bloop" />
56
+ </div>
57
+ <button id="replay-last" style="display: none; margin-bottom: 20px; padding: 10px 20px; background: #e94560; border: none; border-radius: 8px; color: #eee; cursor: pointer; font-size: 16px;">
58
+ Replay last tape
59
+ </button>
60
+ <div class="status" id="status">Select a .bloop tape file to load</div>
61
+ <div class="instructions">
62
+ <p>To save a tape: Run the mario game and press Ctrl+S (or Cmd+S on Mac)</p>
63
+ <p>Playback controls: Use the bottom bar buttons or hotkeys (5=back, 6=pause, 7=forward)</p>
64
+ </div>
65
+ </div>
66
+ <script type="module" src="/src/tape-load.ts"></script>
67
+ </body>
68
+ </html>
@@ -0,0 +1,27 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "useDefineForClassFields": true,
5
+ "module": "ESNext",
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "types": ["vite/client", "@webgpu/types"],
8
+ "skipLibCheck": true,
9
+
10
+ /* Bundler mode */
11
+ "moduleResolution": "bundler",
12
+ "allowImportingTsExtensions": true,
13
+ "verbatimModuleSyntax": true,
14
+ "moduleDetection": "force",
15
+ "noEmit": true,
16
+
17
+ /* JSX for Preact */
18
+ "jsx": "react-jsx",
19
+ "jsxImportSource": "preact",
20
+
21
+ /* Linting */
22
+ "strict": true,
23
+ "noFallthroughCasesInSwitch": true,
24
+ "noUncheckedSideEffectImports": true
25
+ },
26
+ "include": ["src"]
27
+ }
@@ -0,0 +1,5 @@
1
+ import { defineConfig } from "vite";
2
+
3
+ export default defineConfig({
4
+ // WASM loads from CDN automatically via @bloopjs/engine
5
+ });