feat(input): action maps + deterministic record/replay (#7)
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 18s
ci / build-and-test (push) Successful in 1m19s
commit-lint / conventional-commits (push) Successful in 4s
docs / build-and-deploy (push) Successful in 20s

The two ideas the input revamp leads with, built in Ludic over the
single-key poll every target already provides:

- Action maps: gameplay reads named actions, not physical keys, so keys
  are rebindable and a scheme is data. Input.bind(action, key),
  Input.down/pressed(action), Input.rebind(action, from, to).
- Deterministic record/replay: Input.poll() is the one per-frame input
  read; Input.record() captures the key each frame and Input.replay()
  feeds the tape back, so a run reproduces exactly — the seed of lockstep
  netcode. "Read input" and "read a recorded snapshot" are the same call.

runtime/native/input.ludic (spliced when the new Input.* methods are used;
pulls in core.ludic for rt_poll). emit_ns_call routes the methods to the
@fn_input_* runtime; parse.ludic gates the splice. Seven docs/language
pages; worked example + regression examples/library/input_actions.ludic
(1 0 1 1 0 1 0). Full suite 75 passed, self-host fixpoint intact, no golden
drift. The device layer (multi-key held, gamepads, touch, analog) needs a
platform key-state backend and is tracked separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-31 16:03:56 +03:00
parent 3679ce1797
commit 377b6d1186
15 changed files with 19159 additions and 18515 deletions

View file

@ -0,0 +1,25 @@
---
id: input-bind
name: Input.bind
category: input
kind: namespace-method
tokens: Input.bind
sig: Input.bind(action: str, key: int) -> void
tip: Bind a physical key to a named action, so gameplay reads the action, not the key.
order: 1
ns: Input
member: bind
---
Binds a physical key (a character code such as <code>'w'</code> or <code>' '</code>) to a named <em>action</em>, creating the action the first time it is named. Gameplay then reads the action with <a href="input-down.html"><code>Input.down</code></a> / <a href="input-pressed.html"><code>Input.pressed</code></a> instead of a raw key, which is what makes rebinding and alternate control schemes clean. Call it more than once with the same action to bind several keys to it; binding a key already on the action is a no-op. Actions are advanced once per frame by <a href="input-poll.html"><code>Input.poll</code></a>.
```ludic
program Bindings {
entry {
Input.bind("jump", ' ')
Input.bind("up", 'w')
Input.poll()
if Input.down("jump") { print(1) }
}
}
```

View file

@ -0,0 +1,24 @@
---
id: input-down
name: Input.down
category: input
kind: namespace-method
tokens: Input.down
sig: Input.down(action: str) -> bool
tip: Is a named action held on the frame last polled?
order: 4
ns: Input
member: down
---
Returns whether a named action is <em>held</em> on the frame last read by <a href="input-poll.html"><code>Input.poll</code></a> — true when the polled key is one of the keys bound to the action. Use it for continuous input (move while held); for a one-shot press use <a href="input-pressed.html"><code>Input.pressed</code></a>. Reading an action rather than a key is what lets the same handler serve any binding.
```ludic
program Held {
entry {
Input.bind("up", 'w')
Input.poll()
if Input.down("up") { print(1) }
}
}
```

View file

@ -0,0 +1,24 @@
---
id: input-poll
name: Input.poll
category: input
kind: namespace-method
tokens: Input.poll
sig: Input.poll() -> int
tip: Advance one frame of input; the single per-frame read behind actions and replay.
order: 3
ns: Input
member: poll
---
Advances the input by one frame and returns the frame's key. Call it once at the top of a frame; the action reads (<a href="input-down.html"><code>Input.down</code></a>, <a href="input-pressed.html"><code>Input.pressed</code></a>) then report on the key it captured. It is the single place input crosses into the frame, which is what makes recording and replay possible: in record mode it saves the live key, and in replay mode it takes the next key from the tape instead of the device — so "read input" and "read a recorded snapshot" are the same call.
```ludic
program Poll {
entry {
Input.bind("up", 'w')
Input.poll()
if Input.down("up") { print(1) }
}
}
```

View file

@ -0,0 +1,24 @@
---
id: input-pressed
name: Input.pressed
category: input
kind: namespace-method
tokens: Input.pressed
sig: Input.pressed(action: str) -> bool
tip: Did a named action go down this frame (a one-shot edge)?
order: 5
ns: Input
member: pressed
---
Returns whether a named action went down <em>this</em> frame — held on the frame last polled, but not on the one before — the edge you want for "press to jump / confirm / fire", where <a href="input-down.html"><code>Input.down</code></a> would retrigger every frame the key is held. Depends on the two most recent <a href="input-poll.html"><code>Input.poll</code></a> calls, so poll once per frame.
```ludic
program Edge {
entry {
Input.bind("jump", ' ')
Input.poll()
if Input.pressed("jump") { print(1) }
}
}
```

View file

@ -0,0 +1,25 @@
---
id: input-rebind
name: Input.rebind
category: input
kind: namespace-method
tokens: Input.rebind
sig: Input.rebind(action: str, from: int, to: int) -> void
tip: Remap an action from one key to another at runtime (rebinding menus).
order: 2
ns: Input
member: rebind
---
Replaces the key <code>from</code> with <code>to</code> on a named action, at runtime — the primitive a "press a key to rebind" options screen is built on. A no-op if the action does not exist or is not bound to <code>from</code>. Because gameplay reads actions, not keys, a rebind takes effect immediately with no change to the game logic.
```ludic
program Rebinding {
entry {
Input.bind("jump", ' ')
Input.rebind("jump", ' ', 'x')
Input.poll()
if Input.down("jump") { print(1) }
}
}
```

View file

@ -0,0 +1,25 @@
---
id: input-record
name: Input.record
category: input
kind: namespace-method
tokens: Input.record
sig: Input.record() -> void
tip: Start recording polled input each frame (for deterministic replay).
order: 6
ns: Input
member: record
---
Puts input into <em>record</em> mode and resets the tape: from now on each <a href="input-poll.html"><code>Input.poll</code></a> reads the live key and appends it to a recording. Because the simulation is deterministic in its input stream, that tape is all you need to reproduce a run — play it back with <a href="input-replay.html"><code>Input.replay</code></a> for a free exact replay, the seed of lockstep netcode. Recording captures input only; it does not change what the game sees this frame.
```ludic
program Recording {
entry {
Input.bind("up", 'w')
Input.record()
Input.poll()
if Input.down("up") { print(1) }
}
}
```

View file

@ -0,0 +1,27 @@
---
id: input-replay
name: Input.replay
category: input
kind: namespace-method
tokens: Input.replay
sig: Input.replay() -> void
tip: Replay recorded input; poll then reads the tape, not the device.
order: 7
ns: Input
member: replay
---
Rewinds the recording made by <a href="input-record.html"><code>Input.record</code></a> and switches to <em>replay</em> mode: each subsequent <a href="input-poll.html"><code>Input.poll</code></a> returns the next key from the tape instead of the live device, so the recorded session runs again exactly — deterministic replays, demo playback, and the basis of rollback netcode. Past the end of the tape, poll reports no input.
```ludic
program Replaying {
entry {
Input.bind("up", 'w')
Input.record()
Input.poll()
Input.replay()
Input.poll()
if Input.down("up") { print(1) }
}
}
```