lecodes-sdk 2.0.3 → 2.0.4

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/package.json CHANGED
@@ -20,6 +20,7 @@
20
20
  "./bridges/*": "./src/bridges/*.d.ts",
21
21
  "./ui/tree": "./src/ui/tree.ts",
22
22
  "./tween/easing": "./src/animate/tween/easing.ts",
23
+ "./tween/read": "./src/animate/tween/read.ts",
23
24
  "./testing": "./tests/helpers/index.ts",
24
25
  "./testing/preload": "./tests/helpers/preload.ts",
25
26
  "./version": "./src/version.ts"
@@ -42,7 +43,7 @@
42
43
  "typescript": "~5.8.3",
43
44
  "gl-matrix": "^3.4.4"
44
45
  },
45
- "version": "2.0.3",
46
+ "version": "2.0.4",
46
47
  "files": [
47
48
  "src",
48
49
  "dist",
@@ -0,0 +1,146 @@
1
+ // The READER of the tween blob: `buildBlob`'s layout (./spec.ts) back into tracks and keys. For a
2
+ // host that plays an animation with no creator-tween in it (Web Lite hands the tracks to the
3
+ // browser's animations) — creator-tween reads the same layout in C (blob.h).
4
+ //
5
+ // The reader is the builder's twin and lives beside it: tests/animate/blob.test.ts builds a spec,
6
+ // reads it back and holds the two against each other, so a change of the layout that reaches one
7
+ // side only fails there.
8
+
9
+ import { EASE_TABLE, EASE_TABLE_SIZE, evalEasing, type EasingSpec } from "./easing"
10
+
11
+ /** The version of the layout this reader knows (./spec.ts BLOB_VERSION). */
12
+ const VERSION = 1
13
+ // The kinds whose one lane is an index into the string table (./spec.ts KIND_*).
14
+ const KIND_TRANSFORM_MATRIX = 4, KIND_DISCRETE = 8, KIND_STRING = 9
15
+
16
+ export type BlobKey = {
17
+ /** 0..1 within the track. */
18
+ t: number
19
+ easing: EasingSpec
20
+ /** Absent = the target's value when the animation first plays. */
21
+ value?: { lanes: number[] } | { str: string }
22
+ }
23
+
24
+ export type BlobTrack = {
25
+ domain: number
26
+ /** An index into the `targets` handed over next to the blob (a role, in a transition's blob). */
27
+ target: number
28
+ prop: string
29
+ kind: number
30
+ lanes: number
31
+ /** The unit of a FLOAT_UNIT, the function list of a TRANSFORM. */
32
+ sig?: string
33
+ atMs: number
34
+ durMs: number
35
+ commit: boolean
36
+ keys: BlobKey[]
37
+ }
38
+
39
+ export type ReadBlob = {
40
+ clock: number
41
+ /** One iteration, ms. */
42
+ durationMs: number
43
+ delayMs: number
44
+ /** -1 = forever. */
45
+ iterations: number
46
+ pingPong: boolean
47
+ rate: number
48
+ /** Time-callbacks, ms; the index is what the host reports. */
49
+ calls: number[]
50
+ tracks: BlobTrack[]
51
+ }
52
+
53
+ /** The blob as tracks, or null for data that is not a blob of this version (or is cut short). */
54
+ export const readBlob = (data: ArrayLike<number>, strings: readonly string[]): ReadBlob | null => {
55
+ if (data.length < 10 || data[0] !== VERSION) return null
56
+ const nTracks = data[7]!, nCalls = data[8]!, nTables = data[9]!
57
+ let at = 10
58
+ if (at + nCalls + nTables * EASE_TABLE_SIZE > data.length) return null
59
+ const calls: number[] = []
60
+ for (let i = 0; i < nCalls; i++) calls.push(data[at++]!)
61
+ const tables: Float32Array[] = []
62
+ for (let i = 0; i < nTables; i++) {
63
+ const table = new Float32Array(EASE_TABLE_SIZE)
64
+ for (let k = 0; k < EASE_TABLE_SIZE; k++) table[k] = data[at++]!
65
+ tables.push(table)
66
+ }
67
+ const str = (index: number): string | undefined => (index >= 0 ? strings[index] : undefined)
68
+
69
+ const tracks: BlobTrack[] = []
70
+ for (let t = 0; t < nTracks; t++) {
71
+ if (at + 10 > data.length) return null
72
+ const kind = data[at + 3]!, lanes = data[at + 4]!, nKeys = data[at + 8]!
73
+ const track: BlobTrack = {
74
+ domain: data[at]!, target: data[at + 1]!, prop: str(data[at + 2]!) ?? "", kind, lanes,
75
+ sig: str(data[at + 9]!), atMs: data[at + 5]!, durMs: data[at + 6]!, commit: data[at + 7]! !== 0, keys: [],
76
+ }
77
+ at += 10
78
+ if (at + nKeys * (7 + lanes) > data.length) return null
79
+ const stringLane = kind === KIND_TRANSFORM_MATRIX || kind === KIND_DISCRETE || kind === KIND_STRING
80
+ for (let k = 0; k < nKeys; k++) {
81
+ const easeKind = data[at + 1]!
82
+ const p: [number, number, number, number] = [data[at + 2]!, data[at + 3]!, data[at + 4]!, data[at + 5]!]
83
+ const easing: EasingSpec = easeKind === EASE_TABLE ? { kind: easeKind, p, table: tables[p[0]] } : { kind: easeKind, p }
84
+ const key: BlobKey = { t: data[at]!, easing }
85
+ if (data[at + 6]! !== 0) {
86
+ if (stringLane) key.value = { str: str(data[at + 7]!) ?? "" }
87
+ else {
88
+ const values: number[] = []
89
+ for (let l = 0; l < lanes; l++) values.push(data[at + 7 + l]!)
90
+ key.value = { lanes: values }
91
+ }
92
+ }
93
+ at += 7 + lanes
94
+ track.keys.push(key)
95
+ }
96
+ tracks.push(track)
97
+ }
98
+ if (at !== data.length) return null
99
+ return { clock: data[1]!, durationMs: data[2]!, delayMs: data[3]!, iterations: data[4]!, pingPong: data[5]! !== 0, rate: data[6]!, calls, tracks }
100
+ }
101
+
102
+ // ---- a track's value at a time -------------------------------------------------------------------
103
+
104
+ const KIND_QUAT = 6, KIND_MAT4 = 11
105
+
106
+ const slerp = (out: number[], o: number, a: number[], b: number[], s: number): void => {
107
+ let bx = b[o] ?? 0, by = b[o + 1] ?? 0, bz = b[o + 2] ?? 0, bw = b[o + 3] ?? 1
108
+ const ax = a[o] ?? 0, ay = a[o + 1] ?? 0, az = a[o + 2] ?? 0, aw = a[o + 3] ?? 1
109
+ let cosom = ax * bx + ay * by + az * bz + aw * bw
110
+ if (cosom < 0) { cosom = -cosom; bx = -bx; by = -by; bz = -bz; bw = -bw }
111
+ let k0 = 1 - s, k1 = s
112
+ if (1 - cosom > 1e-6) {
113
+ const omega = Math.acos(Math.min(1, cosom)), sinom = Math.sin(omega)
114
+ k0 = Math.sin((1 - s) * omega) / sinom
115
+ k1 = Math.sin(s * omega) / sinom
116
+ }
117
+ out[o] = k0 * ax + k1 * bx; out[o + 1] = k0 * ay + k1 * by; out[o + 2] = k0 * az + k1 * bz; out[o + 3] = k0 * aw + k1 * bw
118
+ }
119
+
120
+ /** The two keys around the local time `ms` of a track and how far between them the track is,
121
+ * eased: the first key holds before the track, the last one after it. */
122
+ export const segmentAt = (track: BlobTrack, ms: number): { from: BlobKey, to: BlobKey, s: number } | null => {
123
+ const keys = track.keys
124
+ if (keys.length === 0) return null
125
+ const u = track.durMs <= 0 ? (ms >= track.atMs ? 1 : 0) : Math.min(1, Math.max(0, (ms - track.atMs) / track.durMs))
126
+ const first = keys[0]!, last = keys[keys.length - 1]!
127
+ if (keys.length === 1 || u <= first.t) return { from: first, to: first, s: 0 }
128
+ if (u >= last.t) return { from: last, to: last, s: 0 }
129
+ let i = 0
130
+ while (i + 1 < keys.length && keys[i + 1]!.t <= u) i++
131
+ const from = keys[i]!, to = keys[i + 1]!
132
+ const span = to.t - from.t
133
+ return { from, to, s: evalEasing(from.easing, span > 0 ? (u - from.t) / span : 1) }
134
+ }
135
+
136
+ /** The lanes of a numeric track at the local time `ms`, into `out` (a key with no value reads as zeros). */
137
+ export const sampleTrack = (track: BlobTrack, ms: number, out: number[]): void => {
138
+ const at = segmentAt(track, ms)
139
+ if (!at) return
140
+ const lanesOf = (key: BlobKey): number[] => (key.value && "lanes" in key.value ? key.value.lanes : [])
141
+ const a = lanesOf(at.from), b = lanesOf(at.to), s = at.s
142
+ const lerp = (from: number, to: number) => { for (let k = from; k < to; k++) out[k] = (a[k] ?? 0) + ((b[k] ?? 0) - (a[k] ?? 0)) * s }
143
+ if (track.kind === KIND_QUAT) { slerp(out, 0, a, b, s); return }
144
+ if (track.kind === KIND_MAT4) { lerp(0, 3); slerp(out, 3, a, b, s); lerp(7, 10); return }
145
+ lerp(0, track.lanes)
146
+ }
@@ -1,4 +1,4 @@
1
- // GENERATED by scripts/vendor-canvas.ts from engines/canvas/recorders/ts/src/cssColor.ts @ ae6bc30b952b — do not edit here.
1
+ // GENERATED by scripts/vendor-canvas.ts from engines/canvas/recorders/ts/src/cssColor.ts @ 72e545dab1f5 — do not edit here.
2
2
  // Bump the submodule and run `bun run vendor:canvas`; `bun run check:canvas-ops` fails when this copy is stale.
3
3
  // The CSS color parser — the TypeScript twin of core/include/anycanvas/css_color.h: the same grammar,
4
4
  // the same name table, float32 emulated op for op (Math.fround, in the header's order), so both give
@@ -1,4 +1,4 @@
1
- // GENERATED by scripts/vendor-canvas.ts from engines/canvas/recorders/ts/src/recorder.ts @ ae6bc30b952b — do not edit here.
1
+ // GENERATED by scripts/vendor-canvas.ts from engines/canvas/recorders/ts/src/recorder.ts @ 72e545dab1f5 — do not edit here.
2
2
  // Bump the submodule and run `bun run vendor:canvas`; `bun run check:canvas-ops` fails when this copy is stale.
3
3
  // The TypeScript recorder: a Canvas2D-shaped API whose calls WRITE the opcode stream of spec/ops.h.
4
4
  // Nothing is drawn here — the stream goes to the core interpreter (native, or wasm on the web), which
@@ -1,4 +1,4 @@
1
- // GENERATED by scripts/vendor-canvas.ts from engines/canvas/spec/gen/spec.ts @ ae6bc30b952b — do not edit here.
1
+ // GENERATED by scripts/vendor-canvas.ts from engines/canvas/spec/gen/spec.ts @ 72e545dab1f5 — do not edit here.
2
2
  // Bump the submodule and run `bun run vendor:canvas`; `bun run check:canvas-ops` fails when this copy is stale.
3
3
  // GENERATED by spec/generate.ts from spec/*.h — do not edit.
4
4
 
package/src/inject.ts CHANGED
@@ -39,7 +39,7 @@ export { device, type HapticStyle, type MotionOptions } from "./runtime/device"
39
39
  export { Input, InputChannel, type InputKeyEvent, type InputGamepadEvent, type InputEventName, type GamepadState, type GamepadAxisName } from "./runtime/input"
40
40
  export { WebSocket } from "./runtime/net"
41
41
  // Multiplayer (docs/multiplayer-plan.md): roles, players, JSON messages over the _creatorNet transport.
42
- export { Net, NetPlayer, Replicated, NetEntity, type NetMessage, type NetRole, type NetStatus, type NetEvents, type NetLaunch, type NetInput, type NetKind, type NetTransform } from "./net"
42
+ export { Net, NetPlayer, Replicated, NetEntity, type NetMessage, type NetRole, type NetStatus, type NetEvents, type NetLaunch, type NetInput, type NetKind, type NetTransform, type NetCorrection } from "./net"
43
43
  // App-backend transport: the compiler-generated stubs of `*.server.ts` modules call these; user code
44
44
  // only ever imports its server functions (docs/backend-plan.md §4).
45
45
  export { __rpc, __channel, __serverOnly, RpcError, type ClientChannel } from "./runtime/rpc"
package/src/net/codec.ts CHANGED
@@ -6,23 +6,31 @@
6
6
  // Nothing else: no strings, no nesting, no optionals — snapshot fields are flat and fixed-size by
7
7
  // design. Both sides run the same bundle, so the layout is derived, never transmitted; the `sig`
8
8
  // feeds the schema hash the handshake compares.
9
+ //
10
+ // A numeric field is INTERPOLATED between two snapshots unless the kind names it `discrete` (an
11
+ // index, a count, an id): a discrete field takes the later snapshot's value whole, as a boolean does.
12
+ // That is a rule of the reader — the wire is the same f32.
9
13
 
10
14
  export type FieldKind = 0 | 1 | 2 // f32 · bool · f32[]
11
- export type Field = { key: string; kind: FieldKind; len: number }
15
+ export type Field = { key: string; kind: FieldKind; len: number; step: boolean }
12
16
  export type Layout = { fields: Field[]; floats: number; bools: number; bytes: number; sig: string }
13
17
 
14
18
  const describe = (v: unknown): string => Array.isArray(v) ? `array of ${v.length}` : v === null ? "null" : typeof v
15
19
 
16
- export const makeLayout = (defaults: Record<string, unknown>, what: string): Layout => {
20
+ export const makeLayout = (defaults: Record<string, unknown>, what: string, discrete: readonly string[] = []): Layout => {
17
21
  const fields: Field[] = []
18
22
  let floats = 0
19
23
  let bools = 0
24
+ for (const key of discrete) {
25
+ if (!(key in defaults)) throw new Error(`${what}: discrete field '${key}' is not in the defaults`)
26
+ }
20
27
  for (const key of Object.keys(defaults)) {
21
28
  const v = defaults[key]
22
- if (typeof v === "number") { fields.push({ key, kind: 0, len: 1 }); floats++ }
23
- else if (typeof v === "boolean") { fields.push({ key, kind: 1, len: 1 }); bools++ }
29
+ const step = discrete.includes(key)
30
+ if (typeof v === "number") { fields.push({ key, kind: 0, len: 1, step }); floats++ }
31
+ else if (typeof v === "boolean") { fields.push({ key, kind: 1, len: 1, step: true }); bools++ }
24
32
  else if (Array.isArray(v) && v.length > 0 && v.length <= 64 && v.every((n) => typeof n === "number")) {
25
- fields.push({ key, kind: 2, len: v.length }); floats += v.length
33
+ fields.push({ key, kind: 2, len: v.length, step }); floats += v.length
26
34
  } else {
27
35
  throw new Error(`${what}: field '${key}' must be a number, a boolean or a fixed-length number[] (got ${describe(v)})`)
28
36
  }
@@ -97,14 +105,15 @@ export const unpackFrom = (view: DataView, offset: number, layout: Layout, into:
97
105
  return o
98
106
  }
99
107
 
100
- /** `out = a + (b − a) · t` for the numeric fields; booleans take `b`'s. */
108
+ /** `out = a + (b − a) · t` for the numeric fields; booleans and the discrete ones take `b`'s. */
101
109
  export const lerpState = (layout: Layout, a: any, b: any, t: number, out: any): void => {
102
110
  for (const f of layout.fields) {
103
- if (f.kind === 0) out[f.key] = a[f.key] + (b[f.key] - a[f.key]) * t
104
- else if (f.kind === 2) {
111
+ if (f.kind === 2) {
105
112
  const pa = a[f.key] as number[], pb = b[f.key] as number[], po = out[f.key] as number[]
106
- for (let i = 0; i < f.len; i++) po[i] = pa[i] + (pb[i] - pa[i]) * t
107
- } else out[f.key] = b[f.key]
113
+ const k = f.step ? 1 : t
114
+ for (let i = 0; i < f.len; i++) po[i] = pa[i] + (pb[i] - pa[i]) * k
115
+ } else if (f.step) out[f.key] = b[f.key]
116
+ else out[f.key] = a[f.key] + (b[f.key] - a[f.key]) * t
108
117
  }
109
118
  }
110
119
 
package/src/net/core.ts CHANGED
@@ -48,7 +48,7 @@ export type NetStats = { rtt: number; loss: number; sentKbps: number; receivedKb
48
48
 
49
49
  export const CH_RELIABLE = 0
50
50
  export const CH_UNRELIABLE = 1
51
- export const PROTO = 2
51
+ export const PROTO = 3
52
52
  export const LOCAL_SLOT = -1
53
53
 
54
54
  // Record kinds from creator-net (creator-net.h CNET_RECORD_*).
@@ -67,6 +67,9 @@ export class NetPlayer {
67
67
  /** A local bag for game facts (score, team, ready). NOT replicated — anything a client must see
68
68
  * goes through a message or replicated state. */
69
69
  data: any = {}
70
+ /** Server: what this player's `Net.connect(…, { hello })` carried (the game's own: a build, a
71
+ * token) — the value `Net.listen({ accept })` was asked about. Undefined everywhere else. */
72
+ hello: unknown = undefined
70
73
  /** @internal transport slot on the server; LOCAL_SLOT for the in-process player */
71
74
  _slot: number
72
75
  /** @internal server side: this player's input ring (replication.ts) */
@@ -129,6 +132,10 @@ export const state = {
129
132
  connectResolve: null as null | (() => void),
130
133
  connectReject: null as null | ((e: Error) => void),
131
134
  myName: "Player",
135
+ /** client: the game's own part of the `$hello` (`Net.connect(…, { hello })`), opaque to the SDK */
136
+ hello: undefined as unknown,
137
+ /** server: the game's say in the handshake (`Net.listen({ accept })`) — a reason refuses */
138
+ accept: null as null | ((hello: unknown, who: { name: string }) => string | null | void),
132
139
  }
133
140
 
134
141
  // Replication's seams into the roster / pump (set by replication.ts when a session starts — from a
@@ -255,6 +262,8 @@ export const resetToOffline = (): void => {
255
262
  state.nextId = 1
256
263
  state.connectResolve = null
257
264
  state.connectReject = null
265
+ state.hello = undefined
266
+ state.accept = null
258
267
  }
259
268
 
260
269
  // ---- server side ------------------------------------------------------------------------------------
@@ -283,13 +292,17 @@ const onServerRecord = (kind: number, slot: number, arg: number, payload: unknow
283
292
  const [name, data] = parsed
284
293
  if (name === "$hello") {
285
294
  if (playerBySlot(slot)) return
286
- const reason = !data || data.proto !== PROTO ? "version" : hooks.helloCheck?.(data) ?? null
295
+ const name = String(data?.name ?? `Player ${state.nextId}`)
296
+ // the SDK's own checks first, then the game's say (it never sees a client of another protocol)
297
+ const reason = !data || data.proto !== PROTO ? "version"
298
+ : hooks.helloCheck?.(data) ?? state.accept?.(data.hello, { name }) ?? null
287
299
  if (reason) {
288
- sendRaw(slot, CH_RELIABLE, JSON.stringify(["$reject", { reason }]))
300
+ sendRaw(slot, CH_RELIABLE, JSON.stringify(["$reject", { reason: String(reason) }]))
289
301
  b.kick(slot)
290
302
  return
291
303
  }
292
- const p = addPlayer(state.nextId++, String(data.name ?? `Player ${state.nextId - 1}`), slot)
304
+ const p = addPlayer(state.nextId++, name, slot)
305
+ p.hello = data.hello
293
306
  sendRaw(slot, CH_RELIABLE, JSON.stringify(["$welcome", {
294
307
  id: p.id,
295
308
  players: state.players.filter((q) => q !== p).map((q) => ({ id: q.id, name: q.name })),
@@ -310,7 +323,7 @@ const onServerRecord = (kind: number, slot: number, arg: number, payload: unknow
310
323
  const onClientRecord = (kind: number, slot: number, arg: number, payload: unknown): void => {
311
324
  const b = bridge()!
312
325
  if (kind === REC_CONNECTED) {
313
- sendRaw(0, CH_RELIABLE, JSON.stringify(["$hello", { proto: PROTO, name: state.myName, ...(hooks.helloExtra?.() ?? {}) }]))
326
+ sendRaw(0, CH_RELIABLE, JSON.stringify(["$hello", { proto: PROTO, name: state.myName, hello: state.hello, ...(hooks.helloExtra?.() ?? {}) }]))
314
327
  return
315
328
  }
316
329
  if (kind === REC_CONNECT_FAILED || kind === REC_DISCONNECTED) {
package/src/net/index.ts CHANGED
@@ -10,12 +10,12 @@ import {
10
10
  type Listener, type NetEvents, type NetLaunch, type NetMessage, type NetRole, type NetStatus,
11
11
  } from "./core"
12
12
  import {
13
- NetEntity, Replicated, allEntities, currentTick, declareInput, declareKind, entityById, installReplication, setInput, stats,
14
- type NetInput, type NetKind, type NetTransform,
13
+ NetEntity, Replicated, allEntities, currentTick, declareInput, declareKind, entityById, installReplication, setInput, stats, viewTick,
14
+ type NetCorrection, type NetInput, type NetKind, type NetTransform,
15
15
  } from "./replication"
16
16
 
17
17
  export { NetPlayer, NetEntity, Replicated }
18
- export type { NetEvents, NetLaunch, NetMessage, NetRole, NetStatus, NetInput, NetKind, NetTransform }
18
+ export type { NetEvents, NetLaunch, NetMessage, NetRole, NetStatus, NetInput, NetKind, NetTransform, NetCorrection }
19
19
 
20
20
  class NetImpl {
21
21
  get role(): NetRole { return state.role }
@@ -34,12 +34,21 @@ class NetImpl {
34
34
  /** The simulation tick — one per fixed step (`updateFixed`). The server's is THE tick; a client
35
35
  * runs a few ticks ahead so its inputs arrive in time. */
36
36
  get tick(): number { return currentTick() }
37
+ /** The server tick this machine is SHOWING the others at: on a client, its estimate of the server's
38
+ * clock less the interpolation delay (fractional); `tick` everywhere else, where nothing is
39
+ * interpolated. What a client names when it tells the server "as I saw it". */
40
+ get viewTick(): number { return viewTick() }
37
41
  /** Live counters: snapshots, input lead / `ahead`, corrections, missed inputs, bytes out. */
38
42
  get stats(): typeof stats { return stats }
39
43
 
40
44
  /** Start serving on `port`. From offline this makes us the host (a player who is also the
41
- * server); on the dedicated exe (`Net.launch.role === "server"`) there is no local player. */
42
- listen(opts: { port?: number; maxClients?: number; name?: string; dedicated?: boolean } = {}): boolean {
45
+ * server); on the dedicated exe (`Net.launch.role === "server"`) there is no local player.
46
+ * `accept` is the game's say in the handshake: it is given what the client's `connect` passed as
47
+ * `hello` and returns a reason to refuse (the client's `reject` event carries it), nothing to accept. */
48
+ listen(opts: {
49
+ port?: number; maxClients?: number; name?: string; dedicated?: boolean
50
+ accept?: (hello: unknown, who: { name: string }) => string | null | void
51
+ } = {}): boolean {
43
52
  const b = bridge()
44
53
  if (!b) { emit("error", "multiplayer is not supported on this host"); return false }
45
54
  if (state.role !== "offline") this.disconnect()
@@ -56,6 +65,7 @@ class NetImpl {
56
65
  state.isServer = true
57
66
  state.isClient = !dedicated
58
67
  state.status = "online"
68
+ state.accept = opts.accept ?? null
59
69
  if (!dedicated) ensureMe()
60
70
  installReplication()
61
71
  startPhases()
@@ -63,12 +73,15 @@ class NetImpl {
63
73
  return true
64
74
  }
65
75
 
66
- /** Connect to a host/server (`address` = "ip:port"). Resolves once the server welcomed us. */
67
- connect(target: { address: string }, opts: { name?: string } = {}): Promise<void> {
76
+ /** Connect to a host/server (`address` = "ip:port"). Resolves once the server welcomed us.
77
+ * `hello` = anything JSON the game wants the server to see before it lets us in (its
78
+ * `Net.listen({ accept })`, then `player.hello`); the SDK only carries it. */
79
+ connect(target: { address: string }, opts: { name?: string; hello?: unknown } = {}): Promise<void> {
68
80
  const b = bridge()
69
81
  if (!b) return Promise.reject(new Error("multiplayer is not supported on this host"))
70
82
  if (state.role !== "offline") this.disconnect()
71
83
  if (opts.name) state.myName = opts.name
84
+ state.hello = opts.hello
72
85
  return new Promise<void>((resolve, reject) => {
73
86
  if (!b.connect(target.address)) { reject(new Error(`bad address '${target.address}'`)); return }
74
87
  state.role = "client"
@@ -163,8 +176,16 @@ class NetImpl {
163
176
 
164
177
  /** A replicated entity type: the defaults are the snapshot layout (same field rules as `input`),
165
178
  * `transform` tells the SDK which fields mirror the node's pose, `client` births the proxy on a
166
- * client when the server spawns one (`new Node().aspect(Replicated, { kind, owner })`). */
167
- kind<S extends object>(name: string, defaults: S, opts: { transform?: NetTransform; client?: (rep: NetEntity<S>, scene: any) => any } = {}): NetKind<S> {
179
+ * client when the server spawns one (`new Node().aspect(Replicated, { kind, owner })`) — it may
180
+ * be set later, `Kind.client = …`, where the client's code lives. `discrete` names the numeric
181
+ * fields that are never interpolated (an index, a count); `correction` tunes how the owner's
182
+ * prediction is brought back to the server's state (all in the game's own units). */
183
+ kind<S extends object>(name: string, defaults: S, opts: {
184
+ transform?: NetTransform
185
+ client?: (rep: NetEntity<S>, scene: any) => any
186
+ discrete?: readonly (keyof S & string)[]
187
+ correction?: Partial<NetCorrection>
188
+ } = {}): NetKind<S> {
168
189
  return declareKind(name, defaults, opts as any)
169
190
  }
170
191
 
@@ -16,9 +16,10 @@
16
16
  // offset `rep.correction` absorbs the jump and decays.
17
17
  //
18
18
  // Wire (channel 1, unreliable, binary; a leading type byte):
19
- // 1 inputs C→S u8 1 · u32 firstTick · u8 count · count × input record (last 3 ticks)
19
+ // 1 inputs C→S u8 1 · u32 firstTick · u8 count · count × input record (the ticks since the
20
+ // last send + 2 older ones, 3 at least; sent when the tick moved)
20
21
  // 2 snapshot S→C u8 2 · u32 tick · u32 lastInputTick · u8 missed · u16 count ·
21
- // count × { u16 netId · u8 kind · state record } (every 3rd tick)
22
+ // count × { u32 netId · u8 kind · state record } (every 3rd tick)
22
23
  // Channel 0 (reliable JSON): `$spawn {netId, kind, owner, state}` · `$despawn {netId}`.
23
24
 
24
25
  import { Aspect } from "../core/Aspect"
@@ -31,6 +32,19 @@ import { CH_UNRELIABLE, CH_RELIABLE, LOCAL_SLOT, NetPlayer, bridge, broadcastRaw
31
32
 
32
33
  export type NetTransform = "pos" | "pos+rot" | "none"
33
34
 
35
+ /** How an owner's prediction is brought back to the server's state. The SDK knows nothing of what
36
+ * the entity is, so the defaults are neutral: every disagreement counts, none is a teleport. */
37
+ export type NetCorrection = {
38
+ /** A vertical disagreement below this is ignored (world units). 0 = none ignored. A body that
39
+ * snaps to a floor differs by a little between two machines, and lifting it by that feeds itself. */
40
+ deadbandY: number
41
+ /** A disagreement of this much or more is a teleport: the body jumps and `correction` stays zero
42
+ * (world units). Infinity = always smoothed. */
43
+ snap: number
44
+ /** The decay time constant of the visual offset `correction` (seconds). 0 = no smoothing. */
45
+ tau: number
46
+ }
47
+
34
48
  /** The per-tick input layout: `const PlayerInput = Net.input({ moveX: 0, moveY: 0, jump: false })`. */
35
49
  export type NetInput<T extends object = any> = { readonly layout: Layout; readonly defaults: T; readonly __t?: T }
36
50
 
@@ -44,8 +58,10 @@ export type NetKind<S extends object = any> = {
44
58
  /** Which state fields the SDK samples on the server and applies to a client proxy: `pos` (a
45
59
  * `pos: [x,y,z]` field), `pos+rot` (+ a `rot: [x,y,z,w]` quaternion field), or `none`. */
46
60
  readonly transform: NetTransform
47
- /** Births the client-side proxy on spawn; the SDK attaches `Replicated` to what it returns. */
48
- readonly client?: (rep: NetEntity<S>, scene: Scene | null) => Node | void
61
+ readonly correction: NetCorrection
62
+ /** Births the client-side proxy on spawn; the SDK attaches `Replicated` to what it returns.
63
+ * Assignable: a declaration both ends share may leave it to the client's own module. */
64
+ client?: (rep: NetEntity<S>, scene: Scene | null) => Node | void
49
65
  readonly __t?: S
50
66
  /** @internal scratch state for snapshot decoding */
51
67
  readonly _scratch: S
@@ -56,10 +72,10 @@ const SNAPSHOT_EVERY = 3 // 20 Hz
56
72
  const INTERP_DELAY = 6 // ticks behind the estimated server tick (100 ms)
57
73
  const LEAD_MIN = 2, LEAD_MAX = 8
58
74
  const SNAPSHOT_BUDGET = 1000 // bytes — one unreliable datagram (creator-net caps messages at 1024)
59
- const CORRECTION_TAU = 0.1 // seconds — the visual offset's decay time constant
60
- const SNAP_DISTANCE = 2 // metres — a bigger disagreement is a teleport: no smoothing
75
+ const CORRECTION_TAU = 0.1 // seconds — the visual offset's decay time constant, unless the kind says
61
76
  const NO_INPUT = 0xffffffff
62
- const VERTICAL_DEADBAND = 0.12 // metres — owner height disagreements below this are floor-snap noise
77
+ const INPUT_MAX = 8 // input records in one packet (the ring keeps as many ticks)
78
+ const REC_HEAD = 5 // u32 netId · u8 kind
63
79
 
64
80
  // ---- registries ---------------------------------------------------------------------------------------
65
81
 
@@ -72,6 +88,8 @@ let nextNetId = 1
72
88
 
73
89
  let tick = 0
74
90
  let localInput: any = null // what Net.setInput set this tick (every role but `server`)
91
+ let localSet = false // Net.setInput was called at all this session
92
+ let sentTick = -1 // client: the tick of the newest input sent
75
93
  const inputRing = new Map<number, any>() // client: tick → values, resent with the next 2 sends
76
94
  let estimate = -1 // client: the server's tick, fractional, advanced per frame
77
95
  let leadCooldown = 0
@@ -147,6 +165,12 @@ export class NetEntity<S extends object = any> {
147
165
  const p = this.owner
148
166
  return (p?._in?.cur ?? inp.defaults) as T
149
167
  }
168
+ /** `input()` is an input the owner gave — false until the first one exists (the server before
169
+ * the owner's first packet, the owner before its first `Net.setInput`), when it is the defaults. */
170
+ get hasInput(): boolean {
171
+ if (!state.isServer || this.isMine) return localSet
172
+ return !!this.owner?._in?.cur
173
+ }
150
174
 
151
175
  /** Client: `state` was refreshed (a snapshot landed / the interpolation moved). */
152
176
  on(event: "state", cb: (s: S) => void): this { if (!this._listeners.includes(cb)) this._listeners.push(cb); return this }
@@ -157,10 +181,13 @@ export class NetEntity<S extends object = any> {
157
181
 
158
182
  // ---- declarations (Net.kind / Net.input) --------------------------------------------------------------
159
183
 
160
- export const declareKind = <S extends object>(name: string, defaults: S, opts: { transform?: NetTransform; client?: NetKind<S>["client"] } = {}): NetKind<S> => {
184
+ export const declareKind = <S extends object>(name: string, defaults: S, opts: {
185
+ transform?: NetTransform; client?: NetKind<S>["client"]; discrete?: readonly string[]; correction?: Partial<NetCorrection>
186
+ } = {}): NetKind<S> => {
161
187
  if (kinds.has(name)) throw new Error(`Net.kind('${name}') is already declared`)
162
188
  if (kindList.length >= 255) throw new Error("Net.kind: at most 255 kinds")
163
- const layout = makeLayout(defaults as Record<string, unknown>, `Net.kind('${name}')`)
189
+ const layout = makeLayout(defaults as Record<string, unknown>, `Net.kind('${name}')`, opts.discrete)
190
+ const correction: NetCorrection = { deadbandY: 0, snap: Infinity, tau: CORRECTION_TAU, ...opts.correction }
164
191
  const transform = opts.transform ?? "none"
165
192
  const need = transform === "pos" ? ["pos"] : transform === "pos+rot" ? ["pos", "rot"] : []
166
193
  for (const key of need) {
@@ -168,7 +195,7 @@ export const declareKind = <S extends object>(name: string, defaults: S, opts: {
168
195
  const len = key === "pos" ? 3 : 4
169
196
  if (!f || f.kind !== 2 || f.len !== len) throw new Error(`Net.kind('${name}'): transform '${transform}' needs a '${key}' field of ${len} numbers`)
170
197
  }
171
- const kind: NetKind<S> = { name, id: kindList.length, layout, defaults: cloneDefaults(defaults), transform, client: opts.client, _scratch: cloneDefaults(defaults) }
198
+ const kind: NetKind<S> = { name, id: kindList.length, layout, defaults: cloneDefaults(defaults), transform, correction, client: opts.client, _scratch: cloneDefaults(defaults) }
172
199
  kinds.set(name, kind)
173
200
  kindList.push(kind)
174
201
  return kind
@@ -187,6 +214,7 @@ export const setInput = <T extends object>(inp: NetInput<T>, values: Partial<T>)
187
214
  const v = cloneDefaults(inp.defaults)
188
215
  assignState(inp.layout, v, values)
189
216
  localInput = v
217
+ localSet = true
190
218
  if (state.role === "client") {
191
219
  inputRing.set(tick, v)
192
220
  for (const t of inputRing.keys()) if (t < tick - 8) inputRing.delete(t)
@@ -194,6 +222,7 @@ export const setInput = <T extends object>(inp: NetInput<T>, values: Partial<T>)
194
222
  }
195
223
 
196
224
  export const currentTick = (): number => tick
225
+ export const viewTick = (): number => (state.role === "client" && estimate >= 0 ? estimate - INTERP_DELAY : tick)
197
226
  export const allEntities = (): NetEntity[] => [...entities.values()]
198
227
  export const entityById = (netId: number): NetEntity | undefined => entities.get(netId)
199
228
 
@@ -314,6 +343,7 @@ export class Replicated extends Aspect<"replicated", Node> {
314
343
  get correction(): Vec3 { return this._rec?.correction ?? ZERO }
315
344
  /** The owner's input for the tick being simulated (see `NetEntity.input`). */
316
345
  input<T extends object>(inp: NetInput<T>): T { return this._rec ? this._rec.input(inp) : (localInput ?? inp.defaults) }
346
+ get hasInput(): boolean { return this._rec ? this._rec.hasInput : localSet }
317
347
  // state-change listeners live on the entity: `node.replicated.entity.on('state', cb)` (the aspect's
318
348
  // own on/off are its aspect events)
319
349
  }
@@ -358,7 +388,7 @@ const buildSnapshot = (forTick: number): void => {
358
388
  const list = [...entities.values()]
359
389
  if (list.length === 0) { pendingSnapshot = null; return }
360
390
  let size = 0
361
- for (const e of list) size += 3 + e.kind.layout.bytes
391
+ for (const e of list) size += REC_HEAD + e.kind.layout.bytes
362
392
  const buf = new ArrayBuffer(Math.min(size, SNAPSHOT_BUDGET))
363
393
  const view = new DataView(buf)
364
394
  let o = 0
@@ -370,10 +400,10 @@ const buildSnapshot = (forTick: number): void => {
370
400
  }
371
401
  for (let i = 0; i < n; i++) {
372
402
  const e = list[(snapshotStart + i) % n]
373
- const rec = 3 + e.kind.layout.bytes
403
+ const rec = REC_HEAD + e.kind.layout.bytes
374
404
  if (o + rec > buf.byteLength) break
375
405
  samplePose(e)
376
- view.setUint16(o, e.netId, true); o += 2
406
+ view.setUint32(o, e.netId, true); o += 4
377
407
  view.setUint8(o, e.kind.id); o += 1
378
408
  o = packInto(view, o, e.kind.layout, e.state)
379
409
  written++
@@ -439,10 +469,10 @@ const onClientBinary = (channel: number, data: ArrayBuffer): void => {
439
469
 
440
470
  let o = 12
441
471
  for (let i = 0; i < count; i++) {
442
- if (o + 3 > data.byteLength) break
443
- const netId = view.getUint16(o, true)
444
- const kind = clientKinds[view.getUint8(o + 2)]
445
- o += 3
472
+ if (o + REC_HEAD > data.byteLength) break
473
+ const netId = view.getUint32(o, true)
474
+ const kind = clientKinds[view.getUint8(o + 4)]
475
+ o += REC_HEAD
446
476
  if (!kind) {
447
477
  if (!warnedKind) { warnedKind = true; console.warn("Net: snapshot with an unknown kind id — dropped") }
448
478
  return
@@ -478,11 +508,9 @@ const reconcileOwner = (e: NetEntity, T: number): void => {
478
508
  if (!pred) return
479
509
  const s = e.state as any
480
510
  const dx = s.pos[0] - pred[0], dz = s.pos[2] - pred[2]
481
- // Vertical deadband: a grounded character's height differs by a few cm between two machines
482
- // (landing phase, floor snap) and lifting the body by that much only makes it fall again — an
483
- // oscillation the correction itself feeds. Only a real height difference (a ledge, a jump) counts.
511
+ // the kind's vertical deadband (NetCorrection.deadbandY): what it calls noise is not corrected
484
512
  let dy = s.pos[1] - pred[1]
485
- if (Math.abs(dy) < VERTICAL_DEADBAND) dy = 0
513
+ if (Math.abs(dy) < e.kind.correction.deadbandY) dy = 0
486
514
  const d = Math.hypot(dx, dy, dz)
487
515
  if (d < 0.005) return
488
516
  stats.corrections++
@@ -491,7 +519,7 @@ const reconcileOwner = (e: NetEntity, T: number): void => {
491
519
  for (const q of e._pred.values()) { q[0] += dx; q[1] += dy; q[2] += dz }
492
520
  const sh = e._shift ?? (e._shift = [0, 0, 0])
493
521
  sh[0] += dx; sh[1] += dy; sh[2] += dz
494
- if (d >= SNAP_DISTANCE) e.correction.set(0, 0, 0)
522
+ if (d >= e.kind.correction.snap) e.correction.set(0, 0, 0)
495
523
  }
496
524
 
497
525
  const applyOwnerShift = (e: NetEntity): void => {
@@ -502,7 +530,7 @@ const applyOwnerShift = (e: NetEntity): void => {
502
530
  e.node.position = [p.x + sh[0], p.y + sh[1], p.z + sh[2]]
503
531
  const rec = e._pred.get(tick - 1)
504
532
  if (rec) { rec[0] += sh[0]; rec[1] += sh[1]; rec[2] += sh[2] }
505
- if (Math.hypot(sh[0], sh[1], sh[2]) < SNAP_DISTANCE) { e.correction.x -= sh[0]; e.correction.y -= sh[1]; e.correction.z -= sh[2] }
533
+ if (Math.hypot(sh[0], sh[1], sh[2]) < e.kind.correction.snap) { e.correction.x -= sh[0]; e.correction.y -= sh[1]; e.correction.z -= sh[2] }
506
534
  }
507
535
 
508
536
  const clientFrame = (): void => {
@@ -510,11 +538,12 @@ const clientFrame = (): void => {
510
538
  if (estimate >= 0) estimate += dt * 60
511
539
  stats.lead = estimate >= 0 ? tick - estimate : 0
512
540
  const rt = estimate - INTERP_DELAY
513
- const k = Math.exp(-dt / CORRECTION_TAU)
514
541
  for (const e of entities.values()) {
515
542
  if (e.isMine) {
516
543
  const c = e.correction
517
544
  if (c.x !== 0 || c.y !== 0 || c.z !== 0) {
545
+ const tau = e.kind.correction.tau
546
+ const k = tau > 0 ? Math.exp(-dt / tau) : 0
518
547
  c.x *= k; c.y *= k; c.z *= k
519
548
  if (Math.hypot(c.x, c.y, c.z) < 1e-3) c.set(0, 0, 0)
520
549
  }
@@ -538,13 +567,17 @@ const clientFrame = (): void => {
538
567
  }
539
568
 
540
569
  const sendInputs = (): void => {
541
- if (!inputDef || inputRing.size === 0) return
542
- const first = tick - 2
543
- const buf = new ArrayBuffer(6 + 3 * inputDef.layout.bytes)
570
+ // nothing new since the last send: a frame without a fixed step
571
+ if (!inputDef || inputRing.size === 0 || tick === sentTick) return
572
+ // every tick since the last send (a long frame steps more than once) + 2 older ones against loss
573
+ const count = Math.min(INPUT_MAX, Math.max(3, tick - sentTick + 2))
574
+ sentTick = tick
575
+ const first = tick - count + 1
576
+ const buf = new ArrayBuffer(6 + count * inputDef.layout.bytes)
544
577
  const view = new DataView(buf)
545
578
  view.setUint8(0, 1)
546
579
  view.setUint32(1, first, true)
547
- view.setUint8(5, 3)
580
+ view.setUint8(5, count)
548
581
  let o = 6
549
582
  let last: any = localInput ?? inputDef.defaults
550
583
  for (let t = first; t <= tick; t++) {
@@ -615,6 +648,8 @@ export const installReplication = (): void => {
615
648
  estimate = -1
616
649
  }
617
650
  inputRing.clear()
651
+ localSet = false
652
+ sentTick = -1
618
653
  pendingSnapshot = null
619
654
  leadCooldown = 0
620
655
  for (const p of state.players) p._in = null