moshcode 0.58.0 → 0.60.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.
package/src/engines.mjs CHANGED
@@ -19,6 +19,14 @@
19
19
  // repeating; only put a pattern here when it is this engine's own wording.
20
20
  // Every pattern is matched against the bottom of the screen with ANSI stripped.
21
21
  //
22
+ // `hooks` (optional) is how this engine reports its own state, so the herd can
23
+ // stop reading paint (PRD 0011 R1). It sits here for the same reason `state`
24
+ // does — and because the two are the same fact at different confidence: a hook
25
+ // spec supersedes the screen rules below it, so an engine that gains one should
26
+ // keep its rules rather than delete them. A missing `hooks` is not a gap to
27
+ // paper over with a guess; it means this engine is classified from its screen,
28
+ // which is what every engine did before.
29
+ //
22
30
  // `resume` (optional) is the argv that reopens this engine's last conversation,
23
31
  // used by `moshcode restore --resume` after a reboot. Omit it rather than guess:
24
32
  // a session that starts fresh is a small disappointment, and one that starts
@@ -82,6 +90,30 @@ export const ENGINES = {
82
90
  "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_SESSION_ID", "CLAUDE_CODE_CHILD_SESSION",
83
91
  ],
84
92
  resume: ["--continue"],
93
+ // The engine speaking for itself (PRD 0011 R1). Claude Code's lifecycle
94
+ // hooks are documented and stable, which is why it is the one engine that
95
+ // ships a spec here rather than a promise to write one: a hook schema we
96
+ // guessed at would be a rule that rots with no screen to fall back to.
97
+ //
98
+ // `file` is a function, not a path, because the whole install is a write to
99
+ // the user's home and the only honest way to test that is to move HOME.
100
+ hooks: {
101
+ format: "claude-settings",
102
+ file: () => path.join(homedir(), ".claude", "settings.json"),
103
+ // Stop fires when the turn ends, which is the end of the *task* — A2A
104
+ // calls the same moment `completed`. Notification covers both halves of
105
+ // blocked (a permission request and a plain question). UserPromptSubmit
106
+ // is the cheapest honest `working`: PreToolUse would also do it, at the
107
+ // cost of forking a moshcode per tool call for a state it is already in.
108
+ // `label` is what moshcode calls the event; `event` is what the engine
109
+ // calls it. They differ because one is a sentence and the other is a
110
+ // schema key, and printing the schema key at someone is not an answer.
111
+ events: [
112
+ { event: "Stop", state: "done", label: "stop" },
113
+ { event: "Notification", state: "blocked", label: "notification" },
114
+ { event: "UserPromptSubmit", state: "working", label: "prompt-submit" },
115
+ ],
116
+ },
85
117
  state: {
86
118
  // The permission dialog's own heading, and the selector on its first
87
119
  // option — the generic numbered-menu pattern would catch the second only
@@ -5,6 +5,7 @@
5
5
  // a wall. Without that you cannot dig a channel up the side of the wall, and
6
6
  // digging a channel is the entire reason anybody still plays this.
7
7
  import { advanceBall, drawnBall, drawnCell, snapBall } from "./games-draw.mjs";
8
+ import { glidePaddle, holdPaddle, paddleMotion, perSecond, pressPaddle, releasePaddle } from "./games-paddle.mjs";
8
9
  import { acid, amber, bone, danger, rgb } from "./ui.mjs";
9
10
 
10
11
  export const WIDTH = 40;
@@ -17,11 +18,11 @@ export const BRICK_TOP = 1;
17
18
 
18
19
  export const PADDLE_W = 7;
19
20
  export const PADDLE_ROW = HEIGHT - 1;
20
- // A keypress, not a tick, so this is unchanged by the tick rate but it does
21
- // have to keep up with the ball, and a ball taken off the end of the paddle now
22
- // crosses a column in under two ticks. Three columns a press stays ahead of it
23
- // at a terminal's key-repeat rate; two only just did.
24
- const PADDLE_STEP = 3;
21
+ // Three columns a press was enough to keep up with the ball and much too much
22
+ // to watch: at a terminal's repeat rate the paddle covered ninety columns a
23
+ // second in jumps you could count. It is now a press's worth of *travel* rather
24
+ // than a jump, paid out over the ticks that follow — see games-paddle.mjs.
25
+ const PADDLE_STEP = 3; // columns one press is worth
25
26
 
26
27
  /**
27
28
  * How often the wall is stepped. See the note in games-pong.mjs: the ball can
@@ -59,6 +60,23 @@ const MAX_VX = 1.4 * SCALE;
59
60
  const MIN_VX = 0.15 * SCALE; // never let it go vertical and unsteerable
60
61
  const LEVEL_UP = 1.12; // a multiplier on pace, so it does not scale
61
62
 
63
+ /**
64
+ * How fast the paddle runs while you hold an arrow, in columns per second.
65
+ *
66
+ * The fastest the ball ever travels sideways is `MAX_VX`, which is fifty-three
67
+ * columns a second. A paddle slower than that is one that a ball taken off the
68
+ * end can simply outrun, so this sits just above it: you can always still get
69
+ * there, and only just, which is the game. It crosses the board in a little
70
+ * over half a second.
71
+ */
72
+ const PADDLE_RATE = perSecond(58, TICK_MS);
73
+
74
+ /** The board's edges, as far as the paddle is concerned. */
75
+ const PADDLE_GLIDE = { rate: PADDLE_RATE, step: PADDLE_STEP, lo: 0, hi: WIDTH - PADDLE_W };
76
+
77
+ /** Which way an arrow moves the paddle, and 0 for a key that is not one. */
78
+ const towards = (key) => (key === "left" ? -1 : key === "right" ? 1 : 0);
79
+
62
80
  /** Top rows are worth more, which is what makes the ball worth risking. */
63
81
  export const ROW_POINTS = [50, 40, 30, 20, 10];
64
82
  const ROW_COLOR = [danger, amber, acid, rgb(90, 200, 250), rgb(190, 130, 255)];
@@ -97,6 +115,11 @@ export function launch(state) {
97
115
 
98
116
  /** One tick. Exported so a test can clear a whole wall with no clock. */
99
117
  export function step(state) {
118
+ // The paddle moves here, with the ball, rather than back in `onKey` — so it
119
+ // travels at a speed instead of jumping at the keyboard's repeat rate. It
120
+ // goes first so that a ball resting on it is put where it has just got to.
121
+ state.paddle = glidePaddle(state.motion, state.paddle, PADDLE_GLIDE);
122
+
100
123
  if (state.stuck) {
101
124
  // A ball that has not been launched rides the paddle, so moving before you
102
125
  // serve aims the serve. It is carried rather than travelling, so it is put
@@ -175,11 +198,13 @@ export const BREAKOUT = {
175
198
  blurb: "dig a channel up the side and let the ball do the rest",
176
199
  keys: "← → paddle · space launch · q quit",
177
200
  tickMs: TICK_MS,
201
+ heldKeys: true, // worth asking the terminal for key releases — see games.mjs
178
202
 
179
203
  create({ rng = Math.random } = {}) {
180
204
  const state = {
181
205
  wall: buildWall(),
182
206
  paddle: Math.floor((WIDTH - PADDLE_W) / 2),
207
+ motion: paddleMotion(),
183
208
  score: 0,
184
209
  lives: LIVES,
185
210
  level: 1,
@@ -192,10 +217,36 @@ export const BREAKOUT = {
192
217
 
193
218
  tick: step,
194
219
 
195
- onKey(state, key) {
196
- if (key === "left") state.paddle = clamp(state.paddle - PADDLE_STEP, 0, WIDTH - PADDLE_W);
197
- else if (key === "right") state.paddle = clamp(state.paddle + PADDLE_STEP, 0, WIDTH - PADDLE_W);
198
- else if (key === "space" || key === "up" || key === "enter") launch(state);
220
+ onKey(state, key, ctx) {
221
+ const dir = towards(key);
222
+ if (dir) {
223
+ // Told when the key comes back up, the paddle is simply put in gear and
224
+ // the tick does the rest — including for the auto-repeats that keep
225
+ // arriving while it is held, which must not be allowed to add a second
226
+ // helping of speed on top of the one the tick is already paying.
227
+ if (ctx?.heldKeys) { holdPaddle(state.motion, dir); return state; }
228
+ // Not told, it is paid for a press at a time. A press that finds it
229
+ // standing still is also spent immediately, so the frame this keypress
230
+ // draws already has it moving; a press that finds it already moving is
231
+ // not, because the tick is paying it out evenly and a second helping on
232
+ // top would be the very jolt this is here to remove.
233
+ const resting = state.motion.owed === 0;
234
+ pressPaddle(state.motion, dir, PADDLE_STEP);
235
+ if (resting) state.paddle = glidePaddle(state.motion, state.paddle, PADDLE_GLIDE);
236
+ // A ball waiting on the paddle is carried by it, and aiming the serve is
237
+ // the only thing you can do before you launch: it would look broken if it
238
+ // stayed behind until the next tick.
239
+ if (state.stuck) {
240
+ state.ball.x = state.paddle + PADDLE_W / 2;
241
+ snapBall(state.drawn, state.ball.x, state.ball.y);
242
+ }
243
+ } else if (key === "space" || key === "up" || key === "enter") launch(state);
244
+ return state;
245
+ },
246
+
247
+ onRelease(state, key) {
248
+ const dir = towards(key);
249
+ if (dir) releasePaddle(state.motion, dir);
199
250
  return state;
200
251
  },
201
252
 
@@ -223,7 +274,10 @@ export const BREAKOUT = {
223
274
  }
224
275
  }
225
276
 
226
- for (let i = 0; i < PADDLE_W; i++) put(state.paddle + i, PADDLE_ROW, bone("▀"));
277
+ // The paddle travels on fractions of a column and is drawn on whole ones,
278
+ // the same as the ball is.
279
+ const paddle = Math.round(state.paddle);
280
+ for (let i = 0; i < PADDLE_W; i++) put(paddle + i, PADDLE_ROW, bone("▀"));
227
281
  // Drawn on half-rows and on its own even clock, so the ball steps the same
228
282
  // distance down the wall as across it, and at a rate. See games-draw.mjs.
229
283
  const ball = drawnCell(state.drawn);
@@ -0,0 +1,128 @@
1
+ // Shared paddle motion, for the games where the thing you hold is a paddle.
2
+ //
3
+ // Everything else on a pong or breakout board already moves on the tick. The
4
+ // ball is stepped sixty times a second and then drawn on its own even clock
5
+ // (games-draw.mjs) precisely so that it reads as travelling rather than
6
+ // hopping. The paddle did not: it was moved by the keypress itself, one jump
7
+ // per key, which left the one object you are actually steering as the only
8
+ // thing on the board not moving at a rate.
9
+ //
10
+ // Two things fall out of that, and both of them read as a slow game rather than
11
+ // as a coarse control.
12
+ //
13
+ // A terminal has no key-up. What it has is auto-repeat: one press, then a gap
14
+ // of about half a second, then twenty-five or thirty a second for as long as
15
+ // you hold the key. A paddle moved by the key therefore sits still for that
16
+ // half second however hard you lean on the arrow — long enough for a pong ball
17
+ // to cross a third of the table — and then crosses the board in a blur. None of
18
+ // that is the paddle being slow. It is the paddle being on the keyboard's clock
19
+ // instead of the game's.
20
+ //
21
+ // And a step big enough to be worth those half-second gaps is a step you can
22
+ // watch land. Breakout moved three columns per key, which at thirty repeats a
23
+ // second is ninety columns a second delivered in visible three-column jumps:
24
+ // the paddle teleports, most of its own width, several times a second.
25
+ //
26
+ // So a key does not move the paddle here. It buys movement, and the tick spends
27
+ // it. A press adds `step` cells of debt; each tick pays out at most `rate` of
28
+ // it. Held, the debt is refilled faster than it can ever be paid, so the paddle
29
+ // runs at exactly `rate` — a constant speed, on the game's clock, for as long
30
+ // as the key is down, and identical whether the terminal repeats at fifteen a
31
+ // second or a hundred. Tapped, the debt is one `step` and the paddle glides
32
+ // that far and stops.
33
+ //
34
+ // The debt is capped at a single press, which is what makes letting go stop the
35
+ // paddle. Without the cap a held key would bank seconds of travel it had no
36
+ // time to spend, and the paddle would sail on long after the ball had gone by —
37
+ // the one thing worse than a paddle that will not start.
38
+ //
39
+ // The position stays a plain number on the game's state. Only the debt lives
40
+ // here, so a game keeps `state.you` or `state.paddle` as the coordinate it
41
+ // always was and a test can still put the paddle somewhere by assigning to it.
42
+
43
+ // All of that is what a terminal that will not say when a key comes back up
44
+ // leaves you with, and it is as good as that gets: the half-second before
45
+ // auto-repeat starts is a half second in which the terminal has told us
46
+ // nothing, and no paddle can be tuned out of a gap in its own input.
47
+ //
48
+ // A terminal that answers `HELD_KEYS.ask` (games.mjs) does say. There the
49
+ // paddle is not paced by presses at all — `holdPaddle` puts it in gear and it
50
+ // stays there, at exactly `rate`, until `releasePaddle` takes it out. No
51
+ // repeat delay to sit through, no overrun to bound, and the same `rate` and the
52
+ // same glide either way, so a game plays the same in both and simply answers
53
+ // sooner in one.
54
+
55
+ /** A paddle owing no movement. Games keep the position; this is the rest. */
56
+ export const paddleMotion = () => ({ owed: 0, held: 0 });
57
+
58
+ /**
59
+ * A key held down, where the terminal will tell us when it is let go.
60
+ *
61
+ * The debt is refilled every tick for as long as this is in gear, so it can
62
+ * never run out and the paddle simply runs at `rate`.
63
+ */
64
+ export function holdPaddle(motion, dir) {
65
+ motion.held = dir;
66
+ return motion;
67
+ }
68
+
69
+ /**
70
+ * That key let go. `dir` is checked so that releasing the arrow you are no
71
+ * longer pressing cannot stop the one you are — rolling from one direction
72
+ * straight into the other sends the release for the first *after* the press for
73
+ * the second, and stopping on it would drop every reversal.
74
+ */
75
+ export function releasePaddle(motion, dir) {
76
+ if (motion.held !== dir) return motion;
77
+ motion.held = 0;
78
+ motion.owed = 0;
79
+ return motion;
80
+ }
81
+
82
+ /**
83
+ * A press: `dir` is -1 or 1, `step` is how far one press is worth.
84
+ *
85
+ * Pressing the other way drops whatever is left of the old direction first, so
86
+ * a reversal starts the moment you ask for it rather than after the paddle has
87
+ * finished paying out the way it was already going. Getting that wrong costs a
88
+ * press, and a lost press on a paddle is a lost ball.
89
+ */
90
+ export function pressPaddle(motion, dir, step) {
91
+ if (motion.owed * dir < 0) motion.owed = 0;
92
+ motion.owed = dir * Math.min(step, Math.abs(motion.owed) + step);
93
+ return motion;
94
+ }
95
+
96
+ /**
97
+ * Pay out this tick's share of the debt and return where the paddle now is.
98
+ *
99
+ * Called once per tick — and once more by a press that finds the paddle
100
+ * standing still, so that the frame drawn for that press already shows it
101
+ * moving. A tick away is only sixteen milliseconds, but starting on the frame
102
+ * you pressed is the difference between a control that answers and one that
103
+ * agrees to answer shortly. A press that finds the paddle already moving does
104
+ * not, because the tick is paying it out evenly and a second helping on top is
105
+ * exactly the jolt this is here to remove.
106
+ */
107
+ export function glidePaddle(motion, at, { rate, step, lo, hi }) {
108
+ // A key that is being held owes a fresh press every tick, so the debt below
109
+ // never runs dry and the paddle just runs.
110
+ if (motion.held) motion.owed = motion.held * step;
111
+ if (!motion.owed) return at;
112
+ const move = Math.sign(motion.owed) * Math.min(rate, Math.abs(motion.owed));
113
+ motion.owed -= move;
114
+ const next = Math.min(hi, Math.max(lo, at + move));
115
+ // A paddle against the end of the board owes nothing. Left standing, the debt
116
+ // would sit there and then fire the instant you pressed the other way.
117
+ if (next === at) motion.owed = 0;
118
+ return next;
119
+ }
120
+
121
+ /**
122
+ * Cells per tick, from a speed written in cells per second.
123
+ *
124
+ * Paddle speeds are a feel, and a feel is in seconds. Writing them per tick
125
+ * would silently re-tune every one of them the next time a game changed how
126
+ * often it steps.
127
+ */
128
+ export const perSecond = (cells, tickMs) => (cells * tickMs) / 1000;
@@ -8,6 +8,7 @@
8
8
  // not. That is the whole game, and it is why the angle off the paddle depends on
9
9
  // where the ball hit it.
10
10
  import { advanceBall, drawnBall, drawnCell, snapBall } from "./games-draw.mjs";
11
+ import { glidePaddle, holdPaddle, paddleMotion, perSecond, pressPaddle, releasePaddle } from "./games-paddle.mjs";
11
12
  import { acid, bone, danger, dim } from "./ui.mjs";
12
13
 
13
14
  export const WIDTH = 44;
@@ -64,10 +65,34 @@ const THEM_SPEED = 0.3 * SCALE; // slow enough that a ball into the corner bea
64
65
  const FLAT = 0.08 * SCALE; // below this a rally has gone flat
65
66
  const NUDGE = 0.12 * SCALE; // and this is the angle it is put back at
66
67
  const MAX_VY = 0.5 * SCALE;
67
- const YOU_STEP = 1; // a keypress, not a tick — the same either way
68
+
69
+ /**
70
+ * Your paddle, which moves on this game's clock and not on the keyboard's.
71
+ *
72
+ * `YOU_RATE` is the speed the paddle actually travels at while you hold an
73
+ * arrow, in rows per second, so it says something about the game rather than
74
+ * about the rate the game happens to tick at. Twenty-one rows a second crosses
75
+ * the table's twelve in a little over half a second — comfortably faster than
76
+ * the ball falls, which is what makes a ball put into the corner a shot you
77
+ * lost rather than one you were never allowed to reach. The machine is left at
78
+ * ten rows a second and so is still beatable exactly where it always was.
79
+ *
80
+ * `YOU_STEP` is what one press is worth, and therefore also how far the paddle
81
+ * will still glide after you let go: nudging without holding moves a shade over
82
+ * a row, and a released paddle is done within a tenth of a second. See
83
+ * games-paddle.mjs for why a press buys movement instead of performing it.
84
+ */
85
+ const YOU_RATE = perSecond(21, TICK_MS);
86
+ const YOU_STEP = 1.2; // rows one press is worth
68
87
 
69
88
  const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
70
89
 
90
+ /** The table's ends, as far as your paddle is concerned. */
91
+ const YOU_GLIDE = { rate: YOU_RATE, step: YOU_STEP, lo: 0, hi: HEIGHT - PADDLE };
92
+
93
+ /** Which way an arrow moves your paddle, and 0 for a key that is not one. */
94
+ const towards = (key) => (key === "up" ? -1 : key === "down" ? 1 : 0);
95
+
71
96
  /** A ball in the middle, heading at whoever just lost the point. */
72
97
  export function serve(state, toward) {
73
98
  state.ball = {
@@ -103,6 +128,9 @@ function returned(ball, top, dir) {
103
128
 
104
129
  /** One tick of rally. Exported so a test can play a whole match with no clock. */
105
130
  export function step(state) {
131
+ // Your paddle moves here, with everything else, rather than back in `onKey`.
132
+ state.you = glidePaddle(state.motion, state.you, YOU_GLIDE);
133
+
106
134
  const ball = state.ball;
107
135
  ball.x += ball.vx;
108
136
  ball.y += ball.vy;
@@ -151,10 +179,12 @@ export const PONG = {
151
179
  blurb: "first to seven, and the angle is all in where you hit it",
152
180
  keys: "↑ ↓ move · q quit",
153
181
  tickMs: TICK_MS,
182
+ heldKeys: true, // worth asking the terminal for key releases — see games.mjs
154
183
 
155
184
  create({ rng = Math.random } = {}) {
156
185
  const state = {
157
186
  you: (HEIGHT - PADDLE) / 2,
187
+ motion: paddleMotion(),
158
188
  them: (HEIGHT - PADDLE) / 2,
159
189
  yours: 0,
160
190
  theirs: 0,
@@ -166,9 +196,28 @@ export const PONG = {
166
196
 
167
197
  tick: step,
168
198
 
169
- onKey(state, key) {
170
- if (key === "up") state.you = clamp(state.you - YOU_STEP, 0, HEIGHT - PADDLE);
171
- if (key === "down") state.you = clamp(state.you + YOU_STEP, 0, HEIGHT - PADDLE);
199
+ onKey(state, key, ctx) {
200
+ const dir = towards(key);
201
+ if (!dir) return state;
202
+ // Told when the key comes back up, the paddle is simply put in gear and the
203
+ // tick does the rest — including for the auto-repeats that keep arriving
204
+ // while it is held, which must not be allowed to add a second helping of
205
+ // speed on top of the one the tick is already paying. See games-paddle.mjs.
206
+ if (ctx?.heldKeys) { holdPaddle(state.motion, dir); return state; }
207
+ // Not told, the paddle is paid for a press at a time. A press that finds it
208
+ // standing still is also spent immediately, so the frame this keypress
209
+ // draws is already one it has moved in; a press that finds it already
210
+ // moving is not, because the tick is paying it out evenly and a second
211
+ // helping on top would be the very jolt this is here to remove.
212
+ const resting = state.motion.owed === 0;
213
+ pressPaddle(state.motion, dir, YOU_STEP);
214
+ if (resting) state.you = glidePaddle(state.motion, state.you, YOU_GLIDE);
215
+ return state;
216
+ },
217
+
218
+ onRelease(state, key) {
219
+ const dir = towards(key);
220
+ if (dir) releasePaddle(state.motion, dir);
172
221
  return state;
173
222
  },
174
223
 
package/src/games.mjs CHANGED
@@ -45,8 +45,14 @@ import { OUTRUN } from "./games-outrun.mjs";
45
45
  * @property {number|Function} [tickMs] real-time games only — a number, or (state) => number
46
46
  * @property {boolean} [vim] false when a game wants h/j/k/l as letters, not arrows
47
47
  * @property {boolean} [restartable] false when `r` is only a restart once the game is over
48
+ * @property {boolean} [heldKeys] true for a game where a key is held rather than
49
+ * tapped, which is worth asking the terminal for key releases for. See
50
+ * `HELD_KEYS`; `ctx.heldKeys` then says whether it agreed.
48
51
  * @property {Function} create ({ rng }) => state
49
- * @property {Function} onKey (state, key, { rng }) => state
52
+ * @property {Function} onKey (state, key, { rng, heldKeys }) => state
53
+ * @property {Function} [onRelease] (state, key, ctx) => state — that key let go,
54
+ * and only in a terminal that reports it. Never the first thing a game hears
55
+ * about a key, so it is only ever a reason to stop.
50
56
  * @property {Function} [tick] (state, { rng }) => state
51
57
  * @property {Function} render (state) => string[] the board, already coloured
52
58
  * @property {Function} status (state) => string right of the title
@@ -112,6 +118,48 @@ export function frame({ title = "", status = "", rows = [], keys = "" } = {}) {
112
118
 
113
119
  /* --------------------------------------------------------------------- keys */
114
120
 
121
+ const VIM_KEYS = { h: "left", j: "down", k: "up", l: "right" };
122
+
123
+ /** Codepoints that are a named key rather than a character. */
124
+ const NAMED_CODES = { 9: "tab", 13: "enter", 27: "escape", 32: "space", 127: "backspace" };
125
+
126
+ /** A CSI sequence ends at the first byte in this range. */
127
+ const isFinal = (c) => c >= "\x40" && c <= "\x7e";
128
+
129
+ /**
130
+ * One CSI sequence → a key name, or null for one the arcade does not use.
131
+ *
132
+ * Two shapes arrive here. Arrows are `CSI A`..`CSI D`, as they have been
133
+ * forever. Everything from the keyboard protocol (see `HELD_KEYS` below) is
134
+ * `CSI code ; modifiers : event u` — a codepoint, which modifier keys were
135
+ * down, and whether the key was pressed, repeated or released. Both carry the
136
+ * event in the same place, so both are read the same way.
137
+ */
138
+ function csiKey(params, final, vim) {
139
+ const fields = params.split(";");
140
+ const key = (fields[0] ?? "").split(":");
141
+ const mod = (fields[1] ?? "").split(":");
142
+ // Modifiers are sent as a bitmask plus one, so that an absent field and "no
143
+ // modifiers" are the same thing.
144
+ const modifiers = mod[0] ? Number.parseInt(mod[0], 10) - 1 : 0;
145
+ const event = mod[1] ? Number.parseInt(mod[1], 10) : 1;
146
+
147
+ let name = { A: "up", B: "down", C: "right", D: "left" }[final] ?? null;
148
+ if (!name && final === "u") {
149
+ const code = Number.parseInt(key[0], 10);
150
+ if (!Number.isFinite(code)) return null;
151
+ name = NAMED_CODES[code]
152
+ ?? (code >= 32 && code <= 126 ? String.fromCodePoint(code).toLowerCase() : null);
153
+ }
154
+ if (!name) return null;
155
+ // Ctrl-c and ctrl-d are a quit however they arrive.
156
+ if (modifiers & 4) return name === "c" || name === "d" ? "quit" : null;
157
+ if (vim && VIM_KEYS[name]) name = VIM_KEYS[name];
158
+ // 1 is a press, 2 a repeat — both are the key being down, which is all a game
159
+ // that does not care about releases ever sees. 3 is the key coming back up.
160
+ return event === 3 ? `release:${name}` : name;
161
+ }
162
+
115
163
  /**
116
164
  * Raw terminal bytes → key names the games understand.
117
165
  *
@@ -119,6 +167,10 @@ export function frame({ title = "", status = "", rows = [], keys = "" } = {}) {
119
167
  * hold several keypresses (hold an arrow key down and they arrive in batches),
120
168
  * which is why this returns a list.
121
169
  *
170
+ * A key coming back up arrives as `release:up`, and only in a terminal that was
171
+ * asked for them and agreed — see `HELD_KEYS`. Games that do not implement
172
+ * `onRelease` never see one.
173
+ *
122
174
  * `vim: false` is for the games that read letters — hangman cannot ask for a
123
175
  * word with an `h` in it while `h` means left, and blackjack wants `h` to be
124
176
  * hit. Arrows are unaffected either way; they arrive as escape sequences.
@@ -129,12 +181,22 @@ export function decodeKeys(chunk, { vim = true } = {}) {
129
181
  for (let i = 0; i < input.length; i++) {
130
182
  const c = input[i];
131
183
  if (c === "\x1b") {
132
- const seq = input.slice(i, i + 3);
133
- const arrow = { "\x1b[A": "up", "\x1b[B": "down", "\x1b[C": "right", "\x1b[D": "left" }[seq];
134
- if (arrow) { keys.push(arrow); i += 2; continue; }
135
- // A bare escape is a quit everywhere in the arcade; a longer sequence we
136
- // don't know (mouse, function key) is swallowed rather than misread.
137
- if (input[i + 1] === "[" || input[i + 1] === "O") { i += 2; continue; }
184
+ const intro = input[i + 1];
185
+ if (intro === "[" || intro === "O") {
186
+ // Run to the end of the sequence and read it as a whole. Skipping a
187
+ // fixed two bytes instead which is what this used to do leaves the
188
+ // tail of anything longer than an arrow to be read as if it had been
189
+ // typed, so a mouse report or a terminal's answer to a question lands
190
+ // in the game as a fistful of letters.
191
+ let j = i + 2;
192
+ while (j < input.length && !isFinal(input[j])) j++;
193
+ // A sequence split across two chunks is dropped rather than half-read.
194
+ if (j >= input.length) break;
195
+ const key = csiKey(input.slice(i + 2, j), input[j], vim);
196
+ if (key) keys.push(key);
197
+ i = j;
198
+ continue;
199
+ }
138
200
  keys.push("escape");
139
201
  continue;
140
202
  }
@@ -145,7 +207,7 @@ export function decodeKeys(chunk, { vim = true } = {}) {
145
207
  if (c === "\t") { keys.push("tab"); continue; }
146
208
  // vim keys, everywhere, for free — every game that reads arrows gets them
147
209
  // without knowing about them. A game that reads letters opts out.
148
- const vimKey = vim ? { h: "left", j: "down", k: "up", l: "right" }[c] : null;
210
+ const vimKey = vim ? VIM_KEYS[c] : null;
149
211
  if (vimKey) { keys.push(vimKey); continue; }
150
212
  if (c >= " " && c <= "~") keys.push(c.toLowerCase());
151
213
  }
@@ -197,6 +259,36 @@ const ESC = {
197
259
  eraseLine: "\x1b[K",
198
260
  };
199
261
 
262
+ /**
263
+ * Asking the terminal to say when a key comes back up.
264
+ *
265
+ * A terminal does not report key releases. What it reports is auto-repeat: the
266
+ * press, then nothing for about half a second, then thirty a second until you
267
+ * let go. For a game where you hold a direction that half second is the whole
268
+ * problem — a pong paddle cannot start moving until the ball is a third of the
269
+ * way down the table, and no amount of making the paddle faster fixes it,
270
+ * because there is nothing to be fast about yet.
271
+ *
272
+ * The keyboard protocol fixes it properly: `HELD_KEYS.on` asks for keys to be
273
+ * reported as press, repeat and release, so a paddle can move for exactly as
274
+ * long as the key is down and stop the instant it is not.
275
+ *
276
+ * Not every terminal implements it, so it is asked for rather than assumed:
277
+ * `ask` is a question a terminal that supports it answers and one that does not
278
+ * ignores, and only an answer turns it on. That is what keeps this free of
279
+ * risk — a terminal that stays quiet is sent nothing and behaves exactly as it
280
+ * did before, and one that answers has told us it understands the sequences it
281
+ * is about to be sent.
282
+ */
283
+ const HELD_KEYS = {
284
+ ask: "\x1b[?u",
285
+ answer: /\x1b\[\?[\d;]*u/,
286
+ on: "\x1b[>3u", // disambiguate escape codes, and report press/repeat/release
287
+ off: "\x1b[<u", // put back whatever the terminal had before
288
+ /** How long a terminal gets to answer before we take the silence as a no. */
289
+ waitMs: 60,
290
+ };
291
+
200
292
  /**
201
293
  * How many ticks a late clock is allowed to make up in one go.
202
294
  *
@@ -332,11 +424,44 @@ export async function runGame(game, deps = {}) {
332
424
  arm();
333
425
  }
334
426
 
427
+ /**
428
+ * Ask the terminal whether it will report key releases, and wait briefly.
429
+ *
430
+ * Only for the games that can use them, and only on a real terminal — a test
431
+ * hands in a stream that will never answer, and waiting on it would put a
432
+ * timeout in front of every game in the suite.
433
+ *
434
+ * Anything the player typed while we were waiting is handed back rather than
435
+ * eaten, so that a key pressed the instant a game starts still counts.
436
+ */
437
+ const askForReleases = () => {
438
+ if (!game.heldKeys || !input.isTTY) return Promise.resolve({ held: false, typed: "" });
439
+ return new Promise((res) => {
440
+ let seen = "";
441
+ let settled = false;
442
+ const finish = (held) => {
443
+ if (settled) return;
444
+ settled = true;
445
+ clearTimer(waiting);
446
+ input.off?.("data", listen);
447
+ res({ held, typed: seen.replace(HELD_KEYS.answer, "") });
448
+ };
449
+ const listen = (chunk) => {
450
+ seen += String(chunk);
451
+ if (HELD_KEYS.answer.test(seen)) finish(true);
452
+ };
453
+ const waiting = setTimer(() => finish(false), HELD_KEYS.waitMs);
454
+ input.on?.("data", listen);
455
+ output.write(HELD_KEYS.ask);
456
+ });
457
+ };
458
+
335
459
  const wasRaw = Boolean(input.isRaw);
336
460
  const restore = () => {
337
461
  if (closed) return;
338
462
  closed = true;
339
463
  stop();
464
+ if (ctx.heldKeys) output.write(HELD_KEYS.off);
340
465
  output.write(ESC.showCursor);
341
466
  try { input.setRawMode?.(wasRaw); } catch { /* already gone */ }
342
467
  input.off?.("data", onData);
@@ -345,18 +470,34 @@ export async function runGame(game, deps = {}) {
345
470
  const onSignal = () => { restore(); process.exit(130); };
346
471
 
347
472
  function onData(chunk) {
473
+ // One chunk can carry several keypresses — holding an arrow down delivers
474
+ // them in batches — and every one of them is applied before anything is
475
+ // drawn. Drawing per key instead paints frames nobody can see: the batch is
476
+ // consumed in the same millisecond, so all but the last are overwritten
477
+ // before the terminal has finished with them, having cost a full frame
478
+ // build and a write each on the way past.
479
+ let moved = false;
348
480
  for (const key of decodeKeys(chunk, { vim: game.vim !== false })) {
481
+ // A key coming back up is not a move. It only ever tells a game to stop
482
+ // doing something, so it cannot start, restart or end one.
483
+ if (key.startsWith("release:")) {
484
+ if (game.onRelease && !state.over) {
485
+ state = game.onRelease(state, key.slice(8), ctx) || state;
486
+ moved = true;
487
+ }
488
+ continue;
489
+ }
349
490
  if (key === "quit" || key === "q" || key === "escape") { restore(); resolve(); return; }
350
491
  if (key === "r" && (state.over || game.restartable !== false)) {
351
492
  state = game.create(ctx);
352
493
  dueAt = null; // a new game starts its clock from now, not from the old one
353
- draw();
494
+ moved = true;
354
495
  arm();
355
496
  continue;
356
497
  }
357
498
  if (state.over) continue; // a finished board takes r and q, nothing else
358
499
  state = game.onKey(state, key, ctx) || state;
359
- draw();
500
+ moved = true;
360
501
  // A key can end a real-time game — a hard drop into the ceiling — so the
361
502
  // clock is stopped when that happens. Otherwise see `nudge`.
362
503
  if (game.tickMs) {
@@ -364,6 +505,7 @@ export async function runGame(game, deps = {}) {
364
505
  else nudge();
365
506
  }
366
507
  }
508
+ if (moved) draw();
367
509
  }
368
510
 
369
511
  let resolve;
@@ -373,12 +515,22 @@ export async function runGame(game, deps = {}) {
373
515
  try { input.setRawMode?.(true); } catch { /* not a tty */ }
374
516
  input.setEncoding?.("utf8");
375
517
  input.resume?.();
376
- input.on?.("data", onData);
377
518
  process.on("SIGINT", onSignal);
378
519
  process.on("SIGTERM", onSignal);
379
520
 
521
+ // The board goes up before the terminal is asked anything, so that a game
522
+ // still starts instantly on a terminal that takes the full wait to not answer.
380
523
  draw();
381
- arm();
524
+ const { held, typed } = await askForReleases();
525
+ // Quitting while the terminal was being asked leaves nothing to set up, and
526
+ // turning the protocol on now would leave it on with no game to turn it off.
527
+ if (!closed) {
528
+ ctx.heldKeys = held;
529
+ if (held) output.write(HELD_KEYS.on);
530
+ input.on?.("data", onData);
531
+ if (typed) onData(typed);
532
+ arm();
533
+ }
382
534
  await done;
383
535
  restore();
384
536
  process.off("SIGINT", onSignal);