dotframe 0.1.0

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 (77) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE +21 -0
  3. package/README.md +57 -0
  4. package/assets/audio/hit.mp3 +0 -0
  5. package/assets/audio/loop.mp3 +0 -0
  6. package/assets/crafter.png +0 -0
  7. package/assets/fonts/archivo-black.json +133 -0
  8. package/assets/fonts/archivo-black.png +0 -0
  9. package/assets/fonts/bangers.json +133 -0
  10. package/assets/fonts/bangers.png +0 -0
  11. package/assets/suzanne.glb +0 -0
  12. package/cli/commands/config.ts +35 -0
  13. package/cli/commands/doctor.ts +71 -0
  14. package/cli/commands/new.ts +73 -0
  15. package/cli/commands/play.ts +244 -0
  16. package/cli/commands/ship.ts +94 -0
  17. package/cli/commands/skills.ts +66 -0
  18. package/cli/commands/snap.ts +88 -0
  19. package/cli/lib.ts +148 -0
  20. package/cli/main.ts +95 -0
  21. package/cli/simkit.ts +152 -0
  22. package/cli/tsconfig.json +8 -0
  23. package/native/df_audio.c +266 -0
  24. package/native/df_native.c +605 -0
  25. package/native/ffi.macos.json +318 -0
  26. package/native/ffi.windows.json +322 -0
  27. package/native/third_party/dr_mp3.h +5430 -0
  28. package/native/third_party/stb_image.h +7988 -0
  29. package/native/third_party/stb_image_write.h +1724 -0
  30. package/native/third_party/stb_truetype.h +5079 -0
  31. package/package.json +47 -0
  32. package/scripts/build-native.sh +33 -0
  33. package/scripts/build-web.sh +10 -0
  34. package/scripts/vendor.sh +36 -0
  35. package/skills/assets/SKILL.md +11 -0
  36. package/skills/core/SKILL.md +51 -0
  37. package/skills/core/references/errors.md +22 -0
  38. package/skills/discord/SKILL.md +12 -0
  39. package/skills/export-web/SKILL.md +20 -0
  40. package/skills/game-design/SKILL.md +27 -0
  41. package/skills/game-design/references/sim-walkthrough.md +32 -0
  42. package/skills/ios/SKILL.md +19 -0
  43. package/skills/macos/SKILL.md +16 -0
  44. package/skills/netplay/SKILL.md +34 -0
  45. package/skills/relay/SKILL.md +18 -0
  46. package/src/audio.ts +19 -0
  47. package/src/draw2d.ts +901 -0
  48. package/src/ecs.ts +30 -0
  49. package/src/gltf.ts +82 -0
  50. package/src/gpu.ts +86 -0
  51. package/src/input.ts +215 -0
  52. package/src/math.ts +113 -0
  53. package/src/native/backend.ts +142 -0
  54. package/src/native/ffi.ts +42 -0
  55. package/src/native/library.ts +65 -0
  56. package/src/native/run.ts +88 -0
  57. package/src/physics.ts +57 -0
  58. package/src/platform.ts +12 -0
  59. package/src/raster2d.ts +348 -0
  60. package/src/render.ts +132 -0
  61. package/src/shapes.ts +37 -0
  62. package/src/sim.ts +56 -0
  63. package/src/storage.ts +6 -0
  64. package/src/web/run.ts +338 -0
  65. package/templates/_base/AGENTS.md +7 -0
  66. package/templates/_base/dotframe.json +15 -0
  67. package/templates/_base/gitignore +5 -0
  68. package/templates/_base/index.html +14 -0
  69. package/templates/_base/main.web.ts +27 -0
  70. package/templates/_base/package.json +12 -0
  71. package/templates/_base/sim.ts +31 -0
  72. package/templates/blank/game.ts +64 -0
  73. package/templates/fighter/game.ts +131 -0
  74. package/templates/platformer/game.ts +120 -0
  75. package/tools/check-audio.ts +57 -0
  76. package/tools/gen-library-glue.ts +83 -0
  77. package/tools/make-sprite.ts +92 -0
package/src/render.ts ADDED
@@ -0,0 +1,132 @@
1
+ import type { World } from "./ecs";
2
+ import type { MeshData } from "./gltf";
3
+ import { BufferUsage, type Color, type Draw, f32Bytes, type RenderGpu, u32Bytes, VertexFormat } from "./gpu";
4
+ import { compose, lookAt, multiply, perspective, type Vec3, vec3 } from "./math";
5
+
6
+ export interface Camera {
7
+ eye: Vec3;
8
+ target: Vec3;
9
+ fovY: number;
10
+ }
11
+
12
+ export interface Renderer {
13
+ addMesh: (data: MeshData) => number;
14
+ render: (world: World, camera: Camera, clear: Color) => void;
15
+ }
16
+
17
+ interface GpuMesh {
18
+ vertexBuffer: number;
19
+ indexBuffer: number;
20
+ count: number;
21
+ }
22
+
23
+ interface EntityBinding {
24
+ uniformBuffer: number;
25
+ bindGroup: number;
26
+ }
27
+
28
+ const shader = `
29
+ struct Uniforms {
30
+ mvp: mat4x4f,
31
+ model: mat4x4f,
32
+ color: vec4f,
33
+ }
34
+ @group(0) @binding(0) var<uniform> u: Uniforms;
35
+
36
+ struct VertexOut {
37
+ @builtin(position) position: vec4f,
38
+ @location(0) normal: vec3f,
39
+ }
40
+
41
+ @vertex
42
+ fn vs_main(@location(0) position: vec3f, @location(1) normal: vec3f) -> VertexOut {
43
+ var out: VertexOut;
44
+ out.position = u.mvp * vec4f(position, 1.0);
45
+ out.normal = (u.model * vec4f(normal, 0.0)).xyz;
46
+ return out;
47
+ }
48
+
49
+ @fragment
50
+ fn fs_main(in: VertexOut) -> @location(0) vec4f {
51
+ let light = normalize(vec3f(0.4, 0.8, 0.6));
52
+ let diffuse = max(dot(normalize(in.normal), light), 0.0);
53
+ return vec4f(u.color.rgb * (0.25 + 0.75 * diffuse), 1.0);
54
+ }
55
+ `;
56
+
57
+ // mvp (16) + model (16) + color (4) floats.
58
+ const UNIFORM_FLOATS = 36;
59
+
60
+ export function createRenderer(gpu: RenderGpu): Renderer {
61
+ const pipeline = gpu.createPipeline({
62
+ wgsl: shader,
63
+ stride: 24,
64
+ attributes: [
65
+ { format: VertexFormat.Float32x3, offset: 0, location: 0 },
66
+ { format: VertexFormat.Float32x3, offset: 12, location: 1 },
67
+ ],
68
+ depth: true,
69
+ blend: false,
70
+ });
71
+ const meshes: GpuMesh[] = [];
72
+ const bindings = new Map<number, EntityBinding>();
73
+ const uniforms = new Float32Array(UNIFORM_FLOATS);
74
+
75
+ const addMesh = (data: MeshData): number => {
76
+ const vertexCount = data.positions.length / 3;
77
+ const interleaved = new Float32Array(vertexCount * 6);
78
+ for (let i = 0; i < vertexCount; i++) {
79
+ interleaved[i * 6] = data.positions[i * 3];
80
+ interleaved[i * 6 + 1] = data.positions[i * 3 + 1];
81
+ interleaved[i * 6 + 2] = data.positions[i * 3 + 2];
82
+ interleaved[i * 6 + 3] = data.normals[i * 3];
83
+ interleaved[i * 6 + 4] = data.normals[i * 3 + 1];
84
+ interleaved[i * 6 + 5] = data.normals[i * 3 + 2];
85
+ }
86
+ meshes.push({
87
+ vertexBuffer: gpu.createBuffer(BufferUsage.Vertex, f32Bytes(interleaved)),
88
+ indexBuffer: gpu.createBuffer(BufferUsage.Index, u32Bytes(data.indices)),
89
+ count: data.indices.length,
90
+ });
91
+ return meshes.length - 1;
92
+ };
93
+
94
+ const render = (world: World, camera: Camera, clear: Color): void => {
95
+ const view = lookAt(camera.eye, camera.target, vec3(0, 1, 0));
96
+ const viewProjection = multiply(perspective(camera.fovY, gpu.aspect(), 0.1, 100), view);
97
+ const draws: Draw[] = [];
98
+ for (const [entity, meshRef] of world.meshes) {
99
+ const transform = world.transforms.get(entity);
100
+ const mesh = meshes[meshRef.mesh];
101
+ if (!transform || !mesh) continue;
102
+
103
+ let binding = bindings.get(entity);
104
+ if (!binding) {
105
+ const uniformBuffer = gpu.createBuffer(BufferUsage.Uniform, f32Bytes(uniforms));
106
+ binding = { uniformBuffer, bindGroup: gpu.bind(pipeline, uniformBuffer, -1) };
107
+ bindings.set(entity, binding);
108
+ }
109
+
110
+ const model = compose(transform.position, transform.rotation, transform.scale);
111
+ uniforms.set(multiply(viewProjection, model), 0);
112
+ uniforms.set(model, 16);
113
+ uniforms[32] = meshRef.color.x;
114
+ uniforms[33] = meshRef.color.y;
115
+ uniforms[34] = meshRef.color.z;
116
+ uniforms[35] = 1;
117
+ gpu.writeBuffer(binding.uniformBuffer, f32Bytes(uniforms));
118
+
119
+ draws.push({
120
+ pipeline,
121
+ bindGroup: binding.bindGroup,
122
+ vertexBuffer: mesh.vertexBuffer,
123
+ indexBuffer: mesh.indexBuffer,
124
+ first: 0,
125
+ count: mesh.count,
126
+ });
127
+ }
128
+ gpu.frame(clear, draws);
129
+ };
130
+
131
+ return { addMesh, render };
132
+ }
package/src/shapes.ts ADDED
@@ -0,0 +1,37 @@
1
+ import type { MeshData } from "./gltf";
2
+
3
+ // Unit cube centered at the origin (extent 1 on each axis), with per-face normals.
4
+ export function box(): MeshData {
5
+ const faces = [
6
+ { n: [1, 0, 0], u: [0, 0, -1], v: [0, 1, 0] },
7
+ { n: [-1, 0, 0], u: [0, 0, 1], v: [0, 1, 0] },
8
+ { n: [0, 1, 0], u: [1, 0, 0], v: [0, 0, -1] },
9
+ { n: [0, -1, 0], u: [1, 0, 0], v: [0, 0, 1] },
10
+ { n: [0, 0, 1], u: [1, 0, 0], v: [0, 1, 0] },
11
+ { n: [0, 0, -1], u: [-1, 0, 0], v: [0, 1, 0] },
12
+ ];
13
+ const positions = new Float32Array(24 * 3);
14
+ const normals = new Float32Array(24 * 3);
15
+ const indices = new Uint32Array(36);
16
+ const corners = [
17
+ [-1, -1],
18
+ [1, -1],
19
+ [1, 1],
20
+ [-1, 1],
21
+ ];
22
+ for (let f = 0; f < 6; f++) {
23
+ const face = faces[f];
24
+ for (let c = 0; c < 4; c++) {
25
+ const vertex = f * 4 + c;
26
+ const su = corners[c][0] * 0.5;
27
+ const sv = corners[c][1] * 0.5;
28
+ for (let axis = 0; axis < 3; axis++) {
29
+ positions[vertex * 3 + axis] = face.n[axis] * 0.5 + face.u[axis] * su + face.v[axis] * sv;
30
+ normals[vertex * 3 + axis] = face.n[axis];
31
+ }
32
+ }
33
+ const base = f * 4;
34
+ indices.set([base, base + 1, base + 2, base, base + 2, base + 3], f * 6);
35
+ }
36
+ return { positions, normals, indices };
37
+ }
package/src/sim.ts ADDED
@@ -0,0 +1,56 @@
1
+ import type { Audio } from "./audio";
2
+ import type { Draw2D } from "./draw2d";
3
+ import type { RenderGpu, WindowOptions } from "./gpu";
4
+
5
+ // The contract a game exposes to the dotframe CLI (sim, snap, replay, desync). A game exports one Sim as the
6
+ // default export of the module named by "sim" in dotframe.json.
7
+
8
+ export type LoadBytes = (path: string) => Promise<Uint8Array>;
9
+
10
+ export interface SimPlatform {
11
+ gpu: RenderGpu;
12
+ load: LoadBytes;
13
+ // True when no frame will ever be shown (sim, replay, desync); assets can be skipped.
14
+ headless: boolean;
15
+ // Present when frames will be drawn (snap): the Draw2D render() receives, so fonts can be registered on it.
16
+ draw?: Draw2D;
17
+ audio?: Audio;
18
+ }
19
+
20
+ export interface SimRun {
21
+ // Resolves once assets are loaded; headless runs may resolve immediately.
22
+ ready: Promise<void>;
23
+ start: (seed: number, options: Record<string, unknown>) => void;
24
+ // One fixed simulation step with one encoded input per player.
25
+ step: (inputs: number[]) => void;
26
+ // A number that changes whenever simulation state changes; peers compare it frame by frame.
27
+ checksum: () => number;
28
+ // A small JSON summary for agents (positions, damage, stocks, winner).
29
+ state: () => unknown;
30
+ over: () => boolean;
31
+ save: () => unknown;
32
+ restore: (snapshot: unknown) => void;
33
+ // Optional: the whole simulation state as a plain object graph. desync walks it at checkpoints and reports the
34
+ // first differing path, which catches state the checksum does not cover.
35
+ inspect?: () => unknown;
36
+ // Optional: draws the current frame; must not change simulation state.
37
+ render?: (draw: Draw2D) => void;
38
+ }
39
+
40
+ export interface Sim {
41
+ players: number;
42
+ window: WindowOptions;
43
+ // Default options merged under --options.
44
+ options: Record<string, unknown>;
45
+ // Turns a JSON input (game-defined object) into the number passed to step.
46
+ encode: (input: unknown) => number;
47
+ // The encoded "nothing pressed" input, used before the first input and as the netplay prediction seed.
48
+ neutral: number;
49
+ // A random input, for mashing scripts. next() returns [0, 1).
50
+ random: (next: () => number) => number;
51
+ create: (platform: SimPlatform) => SimRun;
52
+ }
53
+
54
+ export function defineSim(sim: Sim): Sim {
55
+ return sim;
56
+ }
package/src/storage.ts ADDED
@@ -0,0 +1,6 @@
1
+ // Small persistent key-value store: localStorage on the web, a JSON file in the user's preferences directory natively.
2
+
3
+ export interface Storage {
4
+ get: (key: string) => string | null;
5
+ set: (key: string, value: string) => void;
6
+ }
package/src/web/run.ts ADDED
@@ -0,0 +1,338 @@
1
+ import {
2
+ BufferUsage,
3
+ type Color,
4
+ type Draw,
5
+ type Gpu,
6
+ type PipelineOptions,
7
+ type Setup,
8
+ type Texture,
9
+ type WindowOptions,
10
+ } from "../gpu";
11
+ import type { Audio } from "../audio";
12
+ import { type Input, keyCodes, type Pointer, type Touch } from "../input";
13
+ import type { Storage } from "../storage";
14
+
15
+ const vertexFormats: GPUVertexFormat[] = ["float32x2", "float32x3", "float32x4", "float32", "unorm8x4"];
16
+
17
+ export async function loadBytes(path: string): Promise<Uint8Array> {
18
+ const response = await fetch(path);
19
+ if (!response.ok) throw new Error(`fetch ${path}: ${response.status}`);
20
+ return new Uint8Array(await response.arrayBuffer());
21
+ }
22
+
23
+ export async function run(options: WindowOptions, setup: Setup): Promise<void> {
24
+ if (!navigator.gpu) throw new Error("WebGPU is not available in this browser");
25
+ const adapter = await navigator.gpu.requestAdapter();
26
+ if (!adapter) throw new Error("No WebGPU adapter");
27
+ const device = await adapter.requestDevice();
28
+
29
+ document.title = options.title;
30
+ const canvas = document.createElement("canvas");
31
+ const dpr = globalThis.devicePixelRatio ?? 1;
32
+ canvas.style.width = `${options.width}px`;
33
+ canvas.style.height = `${options.height}px`;
34
+ canvas.width = Math.round(options.width * dpr);
35
+ canvas.height = Math.round(options.height * dpr);
36
+ document.body.appendChild(canvas);
37
+
38
+ const context = canvas.getContext("webgpu");
39
+ if (!context) throw new Error("Could not create a WebGPU canvas context");
40
+ const format = navigator.gpu.getPreferredCanvasFormat();
41
+ context.configure({ device, format, alphaMode: "opaque" });
42
+ const depthTexture = device.createTexture({
43
+ size: [canvas.width, canvas.height],
44
+ format: "depth24plus",
45
+ usage: GPUTextureUsage.RENDER_ATTACHMENT,
46
+ });
47
+
48
+ const pipelines: GPURenderPipeline[] = [];
49
+ const buffers: GPUBuffer[] = [];
50
+ const bindGroups: GPUBindGroup[] = [];
51
+ const textureViews: GPUTextureView[] = [];
52
+ const textureSmooth: boolean[] = [];
53
+ // Nearest keeps pixel art crisp; linear suits fonts and photos.
54
+ const nearest = device.createSampler({ magFilter: "nearest", minFilter: "nearest" });
55
+ const linear = device.createSampler({ magFilter: "linear", minFilter: "linear" });
56
+ const uploadTexture = (
57
+ width: number,
58
+ height: number,
59
+ smooth: boolean,
60
+ write: (texture: GPUTexture) => void,
61
+ ): Texture => {
62
+ const texture = device.createTexture({
63
+ size: [width, height],
64
+ format: "rgba8unorm",
65
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
66
+ });
67
+ write(texture);
68
+ textureViews.push(texture.createView());
69
+ textureSmooth.push(smooth);
70
+ return { id: textureViews.length - 1, width, height };
71
+ };
72
+ const depthPipelines = new Set<number>();
73
+
74
+ const gpu: Gpu = {
75
+ createBuffer: (usage: number, data: Uint8Array): number => {
76
+ let flags = GPUBufferUsage.COPY_DST;
77
+ if (usage & BufferUsage.Vertex) flags |= GPUBufferUsage.VERTEX;
78
+ if (usage & BufferUsage.Index) flags |= GPUBufferUsage.INDEX;
79
+ if (usage & BufferUsage.Uniform) flags |= GPUBufferUsage.UNIFORM;
80
+ const buffer = device.createBuffer({ size: Math.ceil(data.byteLength / 4) * 4, usage: flags });
81
+ device.queue.writeBuffer(buffer, 0, data);
82
+ buffers.push(buffer);
83
+ return buffers.length - 1;
84
+ },
85
+ writeBuffer: (buffer: number, data: Uint8Array): void => {
86
+ const target = buffers[buffer];
87
+ if (target) device.queue.writeBuffer(target, 0, data);
88
+ },
89
+ destroyBuffer: (buffer: number): void => {
90
+ buffers[buffer]?.destroy();
91
+ },
92
+ createPipeline: (pipelineOptions: PipelineOptions): number => {
93
+ const module = device.createShaderModule({ code: pipelineOptions.wgsl });
94
+ const buffersLayout: GPUVertexBufferLayout[] =
95
+ pipelineOptions.stride > 0
96
+ ? [
97
+ {
98
+ arrayStride: pipelineOptions.stride,
99
+ attributes: pipelineOptions.attributes.map((attribute) => ({
100
+ format: vertexFormats[attribute.format] ?? "float32x3",
101
+ offset: attribute.offset,
102
+ shaderLocation: attribute.location,
103
+ })),
104
+ },
105
+ ]
106
+ : [];
107
+ pipelines.push(
108
+ device.createRenderPipeline({
109
+ layout: "auto",
110
+ vertex: { module, entryPoint: "vs_main", buffers: buffersLayout },
111
+ fragment: {
112
+ module,
113
+ entryPoint: "fs_main",
114
+ targets: [
115
+ {
116
+ format,
117
+ blend: pipelineOptions.blend
118
+ ? {
119
+ color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
120
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
121
+ }
122
+ : undefined,
123
+ },
124
+ ],
125
+ },
126
+ primitive: pipelineOptions.depth ? { cullMode: "back" } : {},
127
+ depthStencil: pipelineOptions.depth
128
+ ? { format: "depth24plus", depthWriteEnabled: true, depthCompare: "less" }
129
+ : undefined,
130
+ }),
131
+ );
132
+ if (pipelineOptions.depth) depthPipelines.add(pipelines.length - 1);
133
+ return pipelines.length - 1;
134
+ },
135
+ bind: (pipeline: number, buffer: number, texture: number): number => {
136
+ const entries: GPUBindGroupEntry[] = [];
137
+ if (buffer >= 0) entries.push({ binding: 0, resource: { buffer: buffers[buffer] } });
138
+ if (texture >= 0) {
139
+ entries.push({ binding: 1, resource: textureViews[texture] });
140
+ entries.push({ binding: 2, resource: textureSmooth[texture] ? linear : nearest });
141
+ }
142
+ bindGroups.push(device.createBindGroup({ layout: pipelines[pipeline].getBindGroupLayout(0), entries }));
143
+ return bindGroups.length - 1;
144
+ },
145
+ createTexture: (width: number, height: number, rgba: Uint8Array, smooth: boolean): Texture =>
146
+ uploadTexture(width, height, smooth, (texture) =>
147
+ device.queue.writeTexture({ texture }, rgba, { bytesPerRow: width * 4, rowsPerImage: height }, [width, height]),
148
+ ),
149
+ createImage: async (png: Uint8Array, smooth: boolean): Promise<Texture> => {
150
+ const bitmap = await createImageBitmap(new Blob([new Uint8Array(png)], { type: "image/png" }), { premultiplyAlpha: "none" });
151
+ return uploadTexture(bitmap.width, bitmap.height, smooth, (texture) =>
152
+ device.queue.copyExternalImageToTexture({ source: bitmap }, { texture }, [bitmap.width, bitmap.height]),
153
+ );
154
+ },
155
+ frame: (clear: Color, draws: Draw[]): void => {
156
+ const encoder = device.createCommandEncoder();
157
+ // Pipelines without depth cannot run in a pass with a depth attachment.
158
+ const usesDepth = draws.some((draw) => depthPipelines.has(draw.pipeline));
159
+ const pass = encoder.beginRenderPass({
160
+ colorAttachments: [
161
+ {
162
+ view: context.getCurrentTexture().createView(),
163
+ loadOp: "clear",
164
+ storeOp: "store",
165
+ clearValue: { r: clear.r, g: clear.g, b: clear.b, a: 1 },
166
+ },
167
+ ],
168
+ depthStencilAttachment: usesDepth
169
+ ? { view: depthTexture.createView(), depthLoadOp: "clear", depthStoreOp: "store", depthClearValue: 1 }
170
+ : undefined,
171
+ });
172
+ for (const draw of draws) {
173
+ const pipeline = pipelines[draw.pipeline];
174
+ if (!pipeline) continue;
175
+ pass.setPipeline(pipeline);
176
+ if (draw.bindGroup >= 0) pass.setBindGroup(0, bindGroups[draw.bindGroup]);
177
+ if (draw.vertexBuffer >= 0) pass.setVertexBuffer(0, buffers[draw.vertexBuffer]);
178
+ if (draw.indexBuffer >= 0) {
179
+ pass.setIndexBuffer(buffers[draw.indexBuffer], "uint32");
180
+ pass.drawIndexed(draw.count, 1, draw.first);
181
+ } else {
182
+ pass.draw(draw.count, 1, draw.first);
183
+ }
184
+ }
185
+ pass.end();
186
+ device.queue.submit([encoder.finish()]);
187
+ },
188
+ aspect: (): number => canvas.width / Math.max(canvas.height, 1),
189
+ };
190
+
191
+ const pressed = new Set<string>();
192
+ globalThis.addEventListener("keydown", (event) => {
193
+ if (keyCodes.includes(event.code)) event.preventDefault();
194
+ pressed.add(event.code);
195
+ });
196
+ globalThis.addEventListener("keyup", (event) => pressed.delete(event.code));
197
+ globalThis.addEventListener("blur", () => pressed.clear());
198
+ const pointer: Pointer = { x: 0, y: 0, buttons: 0 };
199
+ const trackPointer = (event: PointerEvent): void => {
200
+ const rect = canvas.getBoundingClientRect();
201
+ pointer.x = (event.clientX - rect.left) / rect.width;
202
+ pointer.y = (event.clientY - rect.top) / rect.height;
203
+ pointer.buttons = event.buttons & 7;
204
+ };
205
+ canvas.addEventListener("pointermove", trackPointer);
206
+ canvas.addEventListener("pointerdown", trackPointer);
207
+ globalThis.addEventListener("pointerup", trackPointer);
208
+ canvas.addEventListener("contextmenu", (event) => event.preventDefault());
209
+ const fingers = new Map<number, Touch>();
210
+ const trackFinger = (event: PointerEvent): void => {
211
+ if (event.pointerType === "mouse") return;
212
+ const rect = canvas.getBoundingClientRect();
213
+ const touch = { id: event.pointerId, x: (event.clientX - rect.left) / rect.width, y: (event.clientY - rect.top) / rect.height };
214
+ if (event.type === "pointerup" || event.type === "pointercancel") fingers.delete(event.pointerId);
215
+ else fingers.set(event.pointerId, touch);
216
+ };
217
+ for (const type of ["pointerdown", "pointermove", "pointerup", "pointercancel"]) canvas.addEventListener(type, trackFinger as EventListener);
218
+ canvas.style.touchAction = "none";
219
+ // Gamepad slots in connection order, matching the native backend.
220
+ const gamepad = (pad: number): Gamepad | null => navigator.getGamepads?.().filter((g) => g !== null)[pad] ?? null;
221
+ const input: Input = {
222
+ down: (key: number): boolean => pressed.has(keyCodes[key] ?? ""),
223
+ firstDown: (): number => {
224
+ for (let key = 0; key < keyCodes.length; key++) if (pressed.has(keyCodes[key])) return key;
225
+ return -1;
226
+ },
227
+ axis: (pad: number, axis: number): number => gamepad(pad)?.axes[axis] ?? 0,
228
+ button: (pad: number, button: number): boolean => gamepad(pad)?.buttons[button]?.pressed ?? false,
229
+ pointer: (): Pointer => ({ x: pointer.x, y: pointer.y, buttons: pointer.buttons }),
230
+ touches: (): Touch[] => [...fingers.values()],
231
+ };
232
+ const storagePrefix = `dotframe:${options.title}:`;
233
+ const storage: Storage = {
234
+ get: (key: string): string | null => {
235
+ try {
236
+ return localStorage.getItem(storagePrefix + key);
237
+ } catch {
238
+ return null;
239
+ }
240
+ },
241
+ set: (key: string, value: string): void => {
242
+ try {
243
+ localStorage.setItem(storagePrefix + key, value);
244
+ } catch {
245
+ // Storage can be unavailable (private mode, blocked site data); the game keeps running.
246
+ }
247
+ },
248
+ };
249
+
250
+ // Browsers start audio suspended until a user gesture.
251
+ const audioContext = new AudioContext();
252
+ const master = audioContext.createGain();
253
+ master.gain.value = 0.8;
254
+ master.connect(audioContext.destination);
255
+ const resume = (): void => {
256
+ if (audioContext.state === "suspended") audioContext.resume();
257
+ if (music && musicWanted && music.paused) music.play().catch(() => {});
258
+ };
259
+ globalThis.addEventListener("keydown", resume);
260
+ globalThis.addEventListener("pointerdown", resume);
261
+ const sounds: AudioBuffer[] = [];
262
+ const tracks: string[] = [];
263
+ let music: HTMLAudioElement | null = null;
264
+ let musicWanted = false;
265
+ let musicVolume = 1;
266
+ let masterVolume = 0.8;
267
+ const audio: Audio = {
268
+ loadSound: async (mp3: Uint8Array): Promise<number> => {
269
+ sounds.push(await audioContext.decodeAudioData(new Uint8Array(mp3).buffer));
270
+ return sounds.length - 1;
271
+ },
272
+ play: (sound: number, volume: number, rate: number): void => {
273
+ const buffer = sounds[sound];
274
+ if (!buffer) return;
275
+ const source = audioContext.createBufferSource();
276
+ source.buffer = buffer;
277
+ source.playbackRate.value = rate;
278
+ const gain = audioContext.createGain();
279
+ gain.gain.value = volume;
280
+ source.connect(gain).connect(master);
281
+ source.start();
282
+ },
283
+ tone: (frequency: number, duration: number, volume: number): void => {
284
+ const now = audioContext.currentTime;
285
+ const oscillator = audioContext.createOscillator();
286
+ const gain = audioContext.createGain();
287
+ oscillator.type = "square";
288
+ oscillator.frequency.value = frequency;
289
+ gain.gain.setValueAtTime(volume, now);
290
+ gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
291
+ oscillator.connect(gain).connect(master);
292
+ oscillator.start(now);
293
+ oscillator.stop(now + duration);
294
+ },
295
+ loadMusic: async (mp3: Uint8Array): Promise<number> => {
296
+ tracks.push(URL.createObjectURL(new Blob([new Uint8Array(mp3)], { type: "audio/mpeg" })));
297
+ return tracks.length - 1;
298
+ },
299
+ playMusic: (track: number, loop: boolean, volume: number): void => {
300
+ music?.pause();
301
+ const url = tracks[track];
302
+ if (!url) return;
303
+ music = new globalThis.Audio(url);
304
+ music.loop = loop;
305
+ musicVolume = volume;
306
+ music.volume = musicVolume * masterVolume;
307
+ musicWanted = true;
308
+ music.play().catch(() => {});
309
+ },
310
+ stopMusic: (): void => {
311
+ music?.pause();
312
+ music = null;
313
+ musicWanted = false;
314
+ },
315
+ pauseMusic: (paused: boolean): void => {
316
+ musicWanted = !paused;
317
+ if (paused) music?.pause();
318
+ else music?.play().catch(() => {});
319
+ },
320
+ setMusicVolume: (volume: number): void => {
321
+ musicVolume = volume;
322
+ if (music) music.volume = musicVolume * masterVolume;
323
+ },
324
+ setMasterVolume: (volume: number): void => {
325
+ masterVolume = volume;
326
+ master.gain.value = volume;
327
+ if (music) music.volume = musicVolume * masterVolume;
328
+ },
329
+ };
330
+
331
+ const frame = setup({ gpu, input, audio, storage });
332
+ const start = performance.now();
333
+ const tick = (): void => {
334
+ if (!frame((performance.now() - start) / 1000)) return;
335
+ requestAnimationFrame(tick);
336
+ };
337
+ requestAnimationFrame(tick);
338
+ }
@@ -0,0 +1,7 @@
1
+ # __NAME__
2
+
3
+ A dotframe game. Run `dotframe skills get core` before changing anything; it explains the edit, sim, snap, replay loop.
4
+
5
+ - Game logic: `src/game.ts` (pure state, `step`, `render`). The sim contract: `sim.ts`.
6
+ - Check a change: `dotframe sim --mash 7 --frames 600 --json`, then `dotframe snap --frame 300 --mash 7`.
7
+ - Golden replays: `replays/*.json`, verified by `bun test`.
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "__NAME__",
3
+ "sim": "sim.ts",
4
+ "targets": {
5
+ "web": {
6
+ "steps": [
7
+ { "label": "bundle", "argv": ["bun", "build", "main.web.ts", "--outfile", "dist/web/main.js", "--target", "browser", "--minify"] },
8
+ { "label": "page", "argv": ["cp", "index.html", "dist/web/index.html"] }
9
+ ],
10
+ "out": "dist/web",
11
+ "deploy": { "provider": "vercel", "project": "__NAME__", "scope": "__SCOPE__" }
12
+ }
13
+ },
14
+ "assets": { "localOnly": [] }
15
+ }
@@ -0,0 +1,5 @@
1
+ node_modules
2
+ dist
3
+ .dotframe
4
+ .vercel
5
+ *.png
@@ -0,0 +1,14 @@
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" />
6
+ <title>__NAME__</title>
7
+ <style>
8
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #000; }
9
+ </style>
10
+ </head>
11
+ <body>
12
+ <script type="module" src="./main.js"></script>
13
+ </body>
14
+ </html>
@@ -0,0 +1,27 @@
1
+ import { createDraw2D } from "dotframe/src/draw2d";
2
+ import type { Frame } from "dotframe/src/gpu";
3
+ import { Key } from "dotframe/src/input";
4
+ import type { Platform } from "dotframe/src/platform";
5
+ import { run } from "dotframe/src/web/run";
6
+ import { createGame, keyInput, PLAYERS, render, step, WINDOW } from "./src/game";
7
+
8
+ const STEP = 1 / 60;
9
+
10
+ await run(WINDOW, ({ gpu, input }: Platform): Frame => {
11
+ const draw = createDraw2D(gpu, WINDOW.width, WINDOW.height);
12
+ const game = createGame(Math.floor(Math.random() * 1e9));
13
+ let simulated = -1;
14
+ return (time: number): boolean => {
15
+ if (simulated < 0) simulated = time;
16
+ // Fixed 60 Hz steps, so the browser plays exactly what dotframe sim simulates.
17
+ for (let n = 0; simulated + STEP <= time && n < 5; n++) {
18
+ const keys = [keyInput(input, [Key.A, Key.D, Key.W, Key.J]), keyInput(input, [Key.Left, Key.Right, Key.Up, Key.L])];
19
+ step(game, keys.slice(0, PLAYERS));
20
+ simulated += STEP;
21
+ }
22
+ draw.begin();
23
+ render(game, draw);
24
+ draw.end({ r: 0, g: 0, b: 0 });
25
+ return true;
26
+ };
27
+ });
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "__NAME__",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "dotframe dev",
7
+ "test": "dotframe replay verify replays/*.json"
8
+ },
9
+ "dependencies": {
10
+ "dotframe": "^0.1.0"
11
+ }
12
+ }