@homie-rocks/studio 0.50.1 → 0.50.2

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.
package/CHANGELOG.md CHANGED
@@ -17,6 +17,21 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.50.2] - 2026-10-13
21
+
22
+ **Plugin 0.50.2** · Keep your own runner out of solid ground.
23
+
24
+ Your player's own runner stays outside declared solid terrain in a 2D game on server rules, even on a lossy connection.
25
+
26
+ ### Fixed
27
+
28
+ - Own-runner drawing now checks interpolation and correction against the solid map, stopping at contact instead of drawing the runner inside terrain. In Homie Arcade's Tiny Platformer, measurements went from 250 penetrating frames in 12 lossy runs before the fix to 0 frames penetrating more than 0.1 tile in 30 runs and 77,368 frames after it.
29
+ - Square 2D runners can declare a box body, and custom movement using substeps previews the remaining duration of held controls along the same movement path.
30
+
31
+ ### Upgrade notes
32
+
33
+ - Follow [RULES.md](plugins/homie/skills/game/RULES.md): declare `map: './map'` in rules, put solid geometry in `map/main.json`, and keep it consistent with custom movement. Declare a square 2D runner with `body.shape: 'box'`, `radius` (its half-width) and `maxSpeed`. Use `ctx.substeps` for custom integration so the toolkit draws that path without a second view-side displacement. Blocked visual corrections do not change authoritative movement.
34
+
20
35
  ## [0.50.1] - 2026-10-13
21
36
 
22
37
  **Plugin 0.50.1** · Studio events reach their functions and the owner's AI.
@@ -54,7 +54,7 @@ its guarded `move`. Bots, AI and away people are always moved on the server.
54
54
  `body.move: 'owner'` is an existing compatibility option that accepts bounded position
55
55
  claims and cannot enforce walls against a modified browser; do not use it for new games.
56
56
  A non-player entity may use `world.sweep(self, delta)` instead of a move function.
57
- Bodies require radius and maxSpeed. In 2D use circle; in 3D use sphere, capsule or
57
+ Bodies require radius and maxSpeed. In 2D use circle or box (radius is its half-width); in 3D use sphere, capsule or
58
58
  axis-aligned box, with full height for capsule/box. `sweep: true` adds automatic map
59
59
  collision; explicit `ctx.map.sweep` moves and returns a contact for sliding.
60
60
 
@@ -1418,6 +1418,13 @@ Omit it to use the call time. A player can declare
1418
1418
  stun and deliver them when control returns. The default `'drop'` preserves the
1419
1419
  previous behaviour. The game's action handler still decides its cooldown.
1420
1420
 
1421
+ In 2D, own-body interpolation and correction are swept against the declared solid map.
1422
+ Keep that map consistent with custom movement and declare a square 2D runner as
1423
+ `body.shape: 'box'`. A blocked visual correction stops at contact; it never
1424
+ changes authoritative movement. Use `ctx.substeps` for custom integration so
1425
+ the toolkit draws the same path, without a second view-side displacement.
1426
+ Substep previews include the remaining duration of controls currently held.
1427
+
1421
1428
  Residual drawing corrections use `room.predict.blendMs` (100 ms by default),
1422
1429
  slowed on each moving axis to avoid reversing movement (jumping cannot spend
1423
1430
  horizontal correction faster), with a one-second maximum
@@ -3237,6 +3237,48 @@
3237
3237
  }
3238
3238
  }
3239
3239
  }
3240
+ },
3241
+ "0.50.2": {
3242
+ "files": {
3243
+ "CLAUDE.md": "ddf2b2a0376202f6",
3244
+ "README.md": "684b3cca8001673c",
3245
+ "HANDOFF.md": "29337e2ace95b2e7",
3246
+ "games/README.md": "5ad32fe80c61f117",
3247
+ "music/README.md": "a3dc042dadd39df8",
3248
+ "videos/README.md": "16babc3437d5545b",
3249
+ "posts/README.md": "37841aae476bd540",
3250
+ "site/README.md": "1dbdf22c03a1e505",
3251
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
3252
+ },
3253
+ "sections": {
3254
+ "AGENTS.md": {
3255
+ "(intro)": "394cbbe934e80722",
3256
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
3257
+ "## Layout": "f3a280b8c96cd068",
3258
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
3259
+ "## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
3260
+ "## The site": "0cb8dd9a6f8a0096",
3261
+ "## Making games": "bb715272fd1a615b",
3262
+ "## The Game Codex and progress": "0738ef719cef3a4c",
3263
+ "## Art direction and models": "a48fff7bf0a5f2fc",
3264
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
3265
+ "## Servers and AI seats": "1ccfd549d2369a60",
3266
+ "## Selling things (the shop)": "9e020d69dd6a954e",
3267
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
3268
+ "## Rules": "b2234d80f6cbe71d",
3269
+ "## Telling Homie": "80b46fdd9e578db8",
3270
+ "## Beta": "9d02638207a26e73"
3271
+ }
3272
+ },
3273
+ "named": {
3274
+ "Homie Arcade": {
3275
+ "sections": {
3276
+ "AGENTS.md": {
3277
+ "## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
3278
+ }
3279
+ }
3280
+ }
3281
+ }
3240
3282
  }
3241
3283
  }
3242
3284
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.50.1",
3
+ "version": "0.50.2",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/rules/math.ts CHANGED
@@ -218,7 +218,7 @@ export function rayBox(px: number, py: number, dx: number, dy: number, min: Vec3
218
218
  if (Math.abs(d[i]) < 1e-12) { if (p[i] <= lo[i] || p[i] >= hi[i]) return null; continue; }
219
219
  let ta = (lo[i] - p[i]) / d[i]; let tb = (hi[i] - p[i]) / d[i];
220
220
  if (ta > tb) { const s = ta; ta = tb; tb = s; }
221
- if (ta > t0) { t0 = ta; nx = i === 0 ? (d[i] > 0 ? -1 : 1) : 0; ny = i === 1 ? (d[i] > 0 ? -1 : 1) : 0; }
221
+ if (ta >= t0) { t0 = ta; nx = i === 0 ? (d[i] > 0 ? -1 : 1) : 0; ny = i === 1 ? (d[i] > 0 ? -1 : 1) : 0; }
222
222
  if (tb < t1) t1 = tb;
223
223
  if (t0 > t1) return null;
224
224
  }
@@ -286,7 +286,7 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
286
286
  put(body, 'pos', at); if (own(options, 'ground') !== false) put(body, 'grounded', restsOnMap(map, at, shape));
287
287
  return h ? Object.freeze({ at, normal: v(h.nx, h.ny, h.nz), ...(h.id ? { entity: h.id } : {}) }) : undefined;
288
288
  }
289
- const { hit: h } = castMap(map, px, py, dx, dy, radius);
289
+ const { hit: h } = castMap(map, px, py, dx, dy, radius, undefined, false, shape.shape === 'box');
290
290
  put(body, 'grounded', true);
291
291
  if (!h) { put(body, 'pos', wrapPoint(v(fr(px + dx), fr(py + dy), 0), map.wrap)); return undefined; }
292
292
  const l = Math.sqrt(dx * dx + dy * dy);
package/rules/rules.ts CHANGED
@@ -599,7 +599,7 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
599
599
  let body: KindTable['body'] = null;
600
600
  if (e.body !== undefined) {
601
601
  const b = e.body;
602
- const shapes = dims === 2 ? ['circle'] : ['sphere', 'capsule', 'box'];
602
+ const shapes = dims === 2 ? ['circle', 'box'] : ['sphere', 'capsule', 'box'];
603
603
  if (!b || !shapes.includes(b.shape)) throw new Error(`${at}.body.shape is ${shapes.map((s) => `'${s}'`).join(' or ')} when space.dims is ${dims}`);
604
604
  if (typeof b.radius !== 'number' || typeof b.maxSpeed !== 'number' || !Number.isFinite(b.radius) || !Number.isFinite(b.maxSpeed) || !(b.radius > 0) || !(b.maxSpeed >= 0)) throw new Error(`${at}.body needs radius (metres) and maxSpeed (metres a second)`);
605
605
  if (b.terrain !== undefined && b.terrain !== 'exact') throw new Error(`${at}.body.terrain is 'exact', or omitted for legacy casts`);
@@ -733,7 +733,7 @@ function publicField(field: Field, hidden=false): Field {
733
733
  export function schemaOf(c: Compiled): Schema {
734
734
  return {
735
735
  contract: RULES_CONTRACT, dims: c.dims, seats: c.seats, ...(c.records?{records:true as const}:{}), ...(c.historySeconds ? { rewind: { historySeconds: c.historySeconds, ...(c.rewindSeconds !== undefined ? { capSeconds: c.rewindSeconds } : {}) } } : {}),
736
- kinds: c.kinds.map((k) => ({ ...(k.query?{query:k.query}:{}), ...(k.collider?{collider:k.collider}:{}), ...(k.body?.terrain ? {terrain:k.body.terrain} : {}), name: k.name, player: Boolean(k.player), ...(k.player?.control ? {control:k.player.control}:{}), owner: Boolean(k.body?.owner), radius: k.body?.radius ?? 0, ...(c.dims === 3 && k.body ? { shape: k.body.shape, height: k.body.height || 2 * k.body.radius } : {}), maxSpeed: k.body?.maxSpeed ?? 0, score: k.score, fields: k.fields.map(([name,field])=>[name,publicField(field)]), motion: k.motion, input: k.input })),
736
+ kinds: c.kinds.map((k) => ({ ...(k.query?{query:k.query}:{}), ...(k.collider?{collider:k.collider}:{}), ...(k.body?.terrain ? {terrain:k.body.terrain} : {}), name: k.name, player: Boolean(k.player), ...(k.player?.control ? {control:k.player.control}:{}), owner: Boolean(k.body?.owner), ...(c.dims === 2 && k.body?.shape === 'box' ? {shape: 'box'} : {}), radius: k.body?.radius ?? 0, ...(c.dims === 3 && k.body ? { shape: k.body.shape, height: k.body.height || 2 * k.body.radius } : {}), maxSpeed: k.body?.maxSpeed ?? 0, score: k.score, fields: k.fields.map(([name,field])=>[name,publicField(field)]), motion: k.motion, input: k.input })),
737
737
  effects: c.effects, effectNames: c.effectNames, commands: c.commands, shared: c.shared, rounds: c.rounds, settings: c.settings,
738
738
  };
739
739
  }
package/rules/view.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { disposeMoveContext } from './math.ts';
1
+ import { disposeMoveContext, sweepMap } from './math.ts';
2
2
  import { boundPosition, periodicDelta, blendPosition, relativePoint, wrapPoint } from './topology.mjs';
3
3
  import { rayQuery, raySnapshot, type QueryTarget } from './query.ts';
4
4
  import { solidAt } from './collision.ts';
@@ -422,6 +422,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
422
422
  mine = { id: u.id, kind: u.kind, r: u.r, pos: u.pos, vel: u.vel, heading: u.heading, grounded: u.grounded, motion: motionOf(u.kind, u.motion), fields: predictors[u.kind] ? thawFields(kindOf.get(u.kind)?.fields ?? [], u.fields) : u.fields };
423
423
  }
424
424
  /** The game's guarded `move` for one tick, counted as the server counts it, with the result rounded as the server rounds it. */
425
+ const substepKinds = new Set<string>();
425
426
  function runMove(kindName: string, body: { pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; motion: Record<string, unknown> }, input: Readonly<Record<string, unknown>>, t: number): typeof body & {steps?: readonly import('./substeps.ts').StepPose[]} {
426
427
  const fn = moves[kindName];
427
428
  if (!fn || (kindOf.get(kindName)?.control && !body.motion[kindOf.get(kindName)!.control!])) return body;
@@ -430,6 +431,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
430
431
  // quota. Authority debits projection once per tick, not once per mover.
431
432
  geometryAt();
432
433
  const out = stepMove(fn, body, input, moveCtx, Math.max(1, Math.floor(schema.settings.budget.tick / 4)), kindOf.get(kindName)?.motion ?? [], dims, (err) => { if (!(err instanceof BudgetError)) console.warn('[room] move', err); });
434
+ if (out.steps?.length) substepKinds.add(kindName);
433
435
  const kind = kindOf.get(kindName), r = kind?.radius ?? 0;
434
436
  const height = kind?.height || 2 * r;
435
437
  out.pos = vec3(boundPosition(out.pos,map,r,height,dims), dims);
@@ -930,6 +932,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
930
932
  const kind = kindOf.get(mine.kind);
931
933
  if (kind?.control && !server.motion[kind.control]) return entityOf(server, true);
932
934
  let pos = mine.pos;
935
+ let safeOrigin = mine.pos;
933
936
  let heading = mine.heading;
934
937
  let velocity = mine.vel, grounded = mine.grounded, motion = mine.motion;
935
938
  let settled = true;
@@ -940,7 +943,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
940
943
  // A held field previews as its mean over the part of the tick that has passed. The body is drawn that fraction of
941
944
  // the way to the preview, so it stands exactly where the control has carried it: it starts from rest at a press
942
945
  // and stops dead at a release, with nothing to blend away afterwards.
943
- for (const [name, fd] of kind.input) now[name] = (fd.t === 'press' || fd.t === 'pulse') ? (pressed.get(name) ?? (fd.t === 'pulse' && pulseQueue[0]?.name===name ? pulseQueue[0].value : fd.t === 'press' ? false : 0)) : fd.t === 'held' ? coerce(fd, heldMean(name, fd.init, Math.min(clock(), edgeOf(stepped + 1)), false), dims) : coerce(fd, sample[name] ?? fd.init, dims);
946
+ // A recorded substep path needs the whole tick's forecast instead: include
947
+ // the remaining duration of controls held now, then sample its actual steps.
948
+ for (const [name, fd] of kind.input) now[name] = (fd.t === 'press' || fd.t === 'pulse') ? (pressed.get(name) ?? (fd.t === 'pulse' && pulseQueue[0]?.name===name ? pulseQueue[0].value : fd.t === 'press' ? false : 0)) : fd.t === 'held' ? coerce(fd, heldMean(name, fd.init, Math.min(clock(), edgeOf(stepped + 1)), false, substepKinds.has(kind.name) ? edgeOf(stepped + 1) : Math.min(clock(), edgeOf(stepped + 1))), dims) : coerce(fd, sample[name] ?? fd.init, dims);
944
949
  const next = runMove(kind.name, { pos: mine.pos, vel: mine.vel, heading: mine.heading, grounded: mine.grounded, motion: motionOf(mine.kind, mine.motion) }, Object.freeze(now), stepped + 1);
945
950
  // A landing does not take the whole tick (0.46.5): the body met the ground part way through it. How far through is
946
951
  // read from the fall it would have made at the speed it entered the tick with; it is drawn down to the ground by
@@ -958,6 +963,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
958
963
  if (next.steps?.length) {
959
964
  const n = next.steps.length - 1, at = a * n, index = Math.min(n - 1, Math.floor(at)), f = at - index;
960
965
  const before = next.steps[index], after = next.steps[index + 1];
966
+ safeOrigin = before.pos;
961
967
  pos = vec3(blendPosition(before.pos, after.pos, f, map.wrap), dims);
962
968
  heading = blendHeading(before.heading, after.heading, f, dims);
963
969
  velocity = vec3({x:lerp(before.vel.x,after.vel.x,f),y:lerp(before.vel.y,after.vel.y,f),z:lerp(before.vel.z,after.vel.z,f)},dims);
@@ -979,14 +985,25 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
979
985
  // Catch-up can reach the fractional current tick before its next whole step exists.
980
986
  // Use the normal drawing preview, including its 3D pose, instead of holding then jumping.
981
987
  const t = Math.floor(catchTick), a = history.get(t), b = history.get(t + 1) ?? (t === stepped ? preview : null);
982
- if (a && b) { const f = catchTick - t; pos = blendPosition(a.pos, b.pos, f, map.wrap); heading = blendHeading(a.heading, b.heading, f, dims);
988
+ if (a && b) { safeOrigin = a.pos; const f = catchTick - t; pos = blendPosition(a.pos, b.pos, f, map.wrap); heading = blendHeading(a.heading, b.heading, f, dims);
983
989
  velocity = vec3({ x: lerp(a.vel.x, b.vel.x, f), y: lerp(a.vel.y, b.vel.y, f), z: lerp(a.vel.z, b.vel.z, f) }, dims);
984
990
  grounded = a.grounded && (f === 0 || b.grounded); motion = a.motion;
985
- } else if (a) { pos = a.pos; heading = a.heading; velocity = a.vel; grounded = a.grounded; motion = a.motion; }
991
+ } else if (a) { safeOrigin = a.pos; pos = a.pos; heading = a.heading; velocity = a.vel; grounded = a.grounded; motion = a.motion; }
986
992
  }
987
993
  }
994
+ // Two-dimensional presentation follows collision too: interpolation and reconciliation are not
995
+ // permission to cross a floor. Sweep from a valid movement pose, never feed
996
+ // the clipped drawing back into prediction or authority.
997
+ const constrain = (from: Vec3, to: Vec3): Vec3 => {
998
+ if (dims !== 2 || !kind || kind.owner) return to;
999
+ const body = {pos: from};
1000
+ sweepMap(geometryAt(), body, {x:periodicDelta(from.x,to.x,map.wrap?.x),y:periodicDelta(from.y,to.y,map.wrap?.y),z:periodicDelta(from.z,to.z,map.wrap?.z)}, kind.radius, dims,
1001
+ {shape:kind.shape ?? 'circle',radius:kind.radius,height:kind.height || 2*kind.radius,terrain:kind.terrain});
1002
+ return body.pos;
1003
+ };
1004
+ pos = constrain(safeOrigin, pos);
988
1005
  const offset = visualOffset(clock(), pos, settled);
989
- pos = wrapPoint({ x: pos.x + offset.x, y: pos.y + offset.y, z: pos.z + offset.z },map.wrap);
1006
+ pos = constrain(pos, wrapPoint({ x: pos.x + offset.x, y: pos.y + offset.y, z: pos.z + offset.z },map.wrap));
990
1007
  if (headingBlend) {
991
1008
  const t = predict.blendMs > 0 ? Math.min(1, (clock() - headingBlend.at) / predict.blendMs) : 1;
992
1009
  heading = blendHeading(headingBlend.from, heading, t, dims);
@@ -1,2 +1,2 @@
1
1
  // Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
2
- export const STUDIO_BUILD = Object.freeze({"version":"0.50.1","sha256":"0ff5d7cf032cfcbfd6fa3a787c60055eadce37a46fd8f7fc09c33e073310d6ae"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.50.2","sha256":"b5a5e460524fdf3585639ef1296b5e6cc9e0596e6f13da493d9a2e6938271ca4"});
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.50.1';
2
+ export const STUDIO_VERSION_TAG = '0.50.2';