openfrontio / openfrontio/OpenFrontIO
Add configurable Quick Chat keybinds that send to the player under the cursor
- Dominant language
- TypeScript
- Stars
- 2.7k
- Forks
- 1.4k
- Avg merge
- 17h 43m
- Merged PRs (30d)
- 310
Description
**Is your feature request related to a problem? Please describe.**
Sending a Quick Chat message takes four interactions: click the player, click Chat in the player panel, pick a category, pick a phrase, hit Send. The radial menu path is shorter but still two rings deep.
For the handful of phrases people send constantly — `warnings.number1_warning` ("The #1 player will win soon unless we team up!"), `help.alliance`, `greet.gg` — that cost gets paid over and over in the middle of a real-time match, exactly when attention is scarcest. I'm always frustrated when I want to warn several neighbours about the leader and have to click through the same five steps for each one.
**Describe the solution you'd like**
User-configurable keyboard shortcuts for Quick Chat: assign a phrase to a slot, hover a player, press the key, message sent.
**Hover targets the recipient.** This matches the four existing hover keybinds (`requestAlliance`, `breakAlliance`, `boatAttack`, `groundAttack`), which all treat the player under the cursor as the one you're acting on. `doRequestAllianceUnderCursor()` in `src/client/ClientGameRunner.ts` is a direct template: `getTileUnderCursor()` → `gameView.owner(tile)` → recipient.
**`[P1]` reuses the split that already exists.** `resources/QuickChat.json` already marks which phrases need a target, and `ChatIntegration.createQuickChatMenu()` already branches on it for the radial menu. A hotkey can do the same:
- `requiresPlayer: false` → send immediately (34 of the 58 phrases).
- `requiresPlayer: true` → `ChatModal.openWithSelection(category, phrase, sender, recipient)`, which pre-fills category, phrase and recipient so only the target needs a click (the other 24 phrases, dropping from four interactions to two).
No new interaction concepts, and no new data.
**Slots, not per-phrase binds.** 58 phrases can't each have a default, so add ~8 generic slot actions:
- `quickChatSlot1`…`quickChatSlot8` in `getDefaultKeybinds()`. They then inherit conflict detection, `"Null"` unbinding, and live mid-match rebinding (`InputHandler` already rebuilds its dispatch table on `USER_SETTINGS_CHANGED_EVENT:settings.keybinds`) for free.
- A separate `settings.quickChatSlots` map for slot → phrase key, e.g. `{"1": "warnings.number1_warning"}`. Keeping the phrase assignment out of the keybind value avoids special-casing the keybind map.
For assignment, a star/favourite button in the Quick Chat modal that pins the phrase you just used to a free slot beats scrolling a 58-item dropdown in settings — you bind by using.
**Offer both key schemes and let the player choose.** The keyboard is nearly full — `getDefaultKeybinds()` already claims 18 letters, all ten digits, Space, the modifiers, Period and Comma. Two schemes remain viable and I'd expose both rather than pick for people:
- **`Shift+1` … `Shift+0` — claimable as-is.** `src/client/InputHandler.ts` registers `Shift+` variants of every build key (lines 451-455), but they never fire a build: the handler is gated on `resolveBuildKeybind(e.code, e.shiftKey) !== null`, and both the exact-match and digit-alias paths require `e.shiftKey === parsed.shift`. With the default unshifted binds (`buildCity: "Digit1"`), a shifted press resolves to `null`. Those entries are effectively dead, so `Shift+DigitN` is free.
- **Numpad number keys — need one small change.** `Numpad0`…`Numpad9` *are* live build hotkeys (pushed into the build list at lines 427-450, with `digitFromKeyCode()` treating `Numpad3` as equivalent to `Digit3`), so Numpad3 currently sets the Port ghost structure. The alias is redundant, though — every structure is still reachable by its digit key — so a player who'd rather have Numpad3 send a phrase loses nothing. Proposal: when building the keybind table, skip any `NumpadN` alias whose code a chat slot is bound to. Contained change, in the same function that builds the alias list.
`NumpadAdd` and `NumpadSubtract` should stay off-limits (hardcoded zoom in/out, lines 589 and 596). `NumpadDecimal`, `NumpadEnter`, `NumpadDivide` and `NumpadMultiply` are free today and could be out-of-the-box defaults for the first few slots, with the rest shipping unbound.
Since the Keybinds tab is already dropped for `Platform.isTouch`, this is desktop-only by construction and needs no mobile work.
**Conflict detection should know about the hardcoded aliases.** The numpad and zoom aliases don't live in the `keybinds` map, so the conflict detector in `src/client/UserSettingModal.ts` — which compares against that record — wouldn't warn a player binding a slot to `Numpad3` or `NumpadAdd`. Without this, taking Numpad3 gives you both a ghost structure and a chat message from one press, and because the build handler is separately gated on `canUseBuildKeybinds()`, only sometimes. That reads as flakiness rather than a conflict, so the detector should see the alias set and let the choice be informed.
v1 would be entirely client-side — the intent, validation, sim execution and rate limiting all already exist, so no `src/core` changes and no determinism risk.
**Describe alternatives you've considered**
- **Hover selects `[P1]` instead of the recipient.** Tempting, since many phrases read as "tell A something about B" ("Stop trading with [P1]!"). Rejected because it would invert the meaning of hover relative to every existing hover keybind — `K` on a player would ally them while a chat slot on the same player would send a message *about* them to someone unspecified. A lasting source of misfires.
- **Two-step flow: press the key, then hover/click a second player for `[P1]`.** Avoids the pre-filled modal, but adds a transient modal state to input handling, which is where input bugs tend to breed. The pre-filled modal gets the same result in one click using code that already exists.
- **A dedicated keybind per phrase.** Not viable at 58 phrases with only 8 free letters left.
- **Ctrl+ or Alt+ chords** (e.g. `Ctrl+Numpad1`), which would sidestep the build aliases entirely. Not expressible today — `parseKeybind` and `SettingKeybind` only handle a `Shift+` prefix. Adding Ctrl/Alt support means touching the parser, the capture UI, the display formatter and the conflict detector; a bigger change than this feature warrants, though a reasonable follow-up if people want it.
- **Picking one key scheme instead of two.** Numpad-only strands laptop players with no numpad; Shift+digit-only gives up the nicer ergonomics of right hand on the numpad, left on WASD, cursor on the target. Shipping both as options costs nothing extra once slots are keybind-driven.
- **Just making the radial menu faster.** Helps everyone a little, but can't beat a single keypress for the one phrase you send fifty times a game.
**Additional context**
Two small pre-existing gaps this surfaces. Both are independently correct and could land first as a separate cleanup to shrink the feature PR's review surface:
1. `formatKeyForDisplay()` in `src/client/Utils.ts` has no numpad branch, so `NumpadDecimal` renders as the literal string "NumpadDecimal". `src/client/HelpModal.ts:53` already has the nicer convention — `if (code.startsWith("Numpad")) return \`Num ${code.slice(6)}\`` — which should be lifted into `formatKeyForDisplay` so both agree.
2. `SettingKeybind` builds its display string from `e.key.toUpperCase()` (line 121). With NumLock off, `e.key` for a numpad key is `"End"`/`"Delete"`/etc., so the settings row shows "END" for a bind correctly stored as `Numpad1`. Routing display through `formatKeyForDisplay()` on the stored `code` fixes it. Matching itself is fine — it's all on `e.code`, so NumLock never affects whether a bind fires.
On rate limiting: `quickChatCooldown()` is 3 seconds **per recipient**, enforced in the sim, and when it isn't satisfied `QuickChatExecution.tick()` sets `active = false` and returns without emitting anything — no feedback at all. With mouse-driven chat that's nearly unreachable; with a hotkey it becomes easy to hit, and a silent no-op will read as a broken keybind. Because the cooldown is per-recipient rather than global, a hotkey also makes it possible to sweep the cursor across the map and message every player at full speed, which is spam friction the current UI was providing implicitly.
`canSendQuickChat` isn't exposed to the client today, so real feedback or a global cooldown would mean touching `src/core` and adding tests. I'd keep that out of v1 and mitigate cheaply instead: an `(e) => !e.repeat` condition on the slot binds (the pattern `pauseGame` already uses) so holding a key doesn't auto-fire, and a no-op when the cursor is over water, your own territory, or a `PlayerType.Bot` (the modal already filters bots) rather than burning the cooldown on a send that can't land. Happy to open a follow-up for the sim-side feedback if maintainers think it's worth changing.
Successful sends already appear in the events feed as `Sent {user}: {msg}` via the `chat.to` string, so confirmation exists without new UI.
One design call I'd want maintainer input on before writing code: this lowers the cost of sending the accusatory `warnings.*` phrases ("[P1] is cheating!", "You're ruining both of our games.") to every player on the map. If that's a concern, options include excluding `warnings.*` from instant-send and routing them through the modal, or limiting instant-send to the non-accusatory categories. Happy to go whichever way you prefer.
**Assignment**
- [x] I'd like to be assigned to this issue and work on it myself
Contributor guide
Research direction
Start with src/client/ClientGameRunner.ts, src/client/InputHandler.ts, src/client/UserSettingModal.ts, and the Quick Chat integration described in the issue. Trace the existing hover keybind, keybind parsing and conflict detection before resolving the open choices around key schemes, cooldown behavior, and warning phrases with maintainers. Done means configurable slots send the selected phrase to the hovered eligible recipient without conflicting with existing aliases.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- frontend, game-dev
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 38/100