Skip to content

feat: first stab at adding cross-platform support for the console/input handler - #12

Open
robinbaxon wants to merge 3 commits into
stho01:mainfrom
robinbaxon:add-cross-platform-support
Open

robinbaxon wants to merge 3 commits into
stho01:mainfrom
robinbaxon:add-cross-platform-support

Conversation

@robinbaxon

@robinbaxon robinbaxon commented Aug 30, 2025 •

Copy link
Copy Markdown
Collaborator
  • Some features work OK on ANSI enabled rich terminals like kitty
  • Works not-so-good on "regular" cheap terminals

Notable missing features:

ConsoleHandler.Resizable ❌
ConsoleHandler.SetFont ❌

Working TerraForM example on MacOS, using Kitty: ✅
image

Glitchy Asteroids example on MacOS, using Kitty: 🚧
image

@robinbaxon
robinbaxon requested a review from stho01 August 30, 2025 18:56
@robinbaxon
robinbaxon marked this pull request as ready for review August 30, 2025 18:57
@robinbaxon

Copy link
Copy Markdown
Collaborator Author

Will refactor and adhere to #7, leaving consoleengine.windows alone

…put (Claude Opus 5.5)

- Pick Windows/cross-platform handlers at runtime
(OperatingSystem.IsWindows) instead of
  compile time $(OS) defines; revert the Kernel32 stubs
- Rewrite CrossPlatformConsoleHandler: alternate screen, no autowrap,
diffed frames written
  in a single write, synchronized updates, truecolor/16 color output,
clipping to the
  terminal size, terminal restored on exit/Ctrl+C
- Read real key up/down state on macOS through CoreGraphics, calibrated
against terminal
  input so it falls back to the terminal when macOS lacks Input
Monitoring permission
- Terminal input fallback that treats keys as held until auto repeat
stops
- Launch the logger in a new Terminal window on macOS, don't crash if it
can't start
- Remove the missing Pong project from the solution
- Build and test on windows, macos and ubuntu in CI

Code made by Claude (Opus 5.5)
@robinbaxon
robinbaxon force-pushed the add-cross-platform-support branch from 002696a to 06b2b49 Compare October 22, 2026 07:15

@stho01 stho01 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review of the cross-platform console/input support. 8 findings below: 6 correctness issues (input timing heuristics, logger startup, terminal restore, keyboard layouts) and 2 cleanups.

🤖 Generated with Claude Code

continue;

// Auto repeat events arrive in a rapid stream, anything slower is a new key press.
var isRepeat = now - _terminalLastEvent[vk] < RepeatHoldTicks;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: the first auto-repeat and slow frames count as new presses, and fast double-taps are swallowed.

Whether an event is a repeat is decided by comparing the frame timestamp now with the previous event for the same key, using a 150ms window.

  • The OS waits before it starts repeating: about 375ms by default on macOS, 500–660ms on Linux desktops. So the first repeat is always more than 150ms after the press and is classified as a new press. Because wasHeld is still true, repressed fires Pressed a second time. Example: holding A in TerraForM's InputNameScene types AA.
  • Every event drained in a frame shares the same now. If a frame takes longer than 150ms, the first queued repeat in each frame looks like a new press, so Pressed fires every frame while a key is held.
  • Pressing the same key twice within 150ms (typing ll, for example) is treated as a repeat, so the second press is lost.

Suggestion: treat an event as a repeat when the key is still inside its hold window (now < _terminalHeldUntil[vk]), not by measuring the gap since the last event.

{
// A terminal key is held until no new press/repeat arrives within this window. The first window has to
// outlast the OS' "delay until repeat", the following ones only the repeat interval.
private static readonly long InitialHoldTicks = Stopwatch.Frequency * 550 / 1000;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: the 550ms initial hold window is shorter than common repeat delays.

On X11 the default repeat delay is 660ms, and macOS's longer "Delay Until Repeat" settings reach well over a second. With those settings a held key reports Released (and Held = false) after 550ms, then Pressed again when the first repeat arrives. For example, holding RIGHT in Platformer makes the player stop briefly, and any action triggered on Pressed (jump, menu select) fires twice.

Consider a more generous initial window, or make it configurable, for example with a CONSOLE_ENGINE_* environment variable like the existing ones.

else
{
_loggerProcess.StartInfo.FileName = logger;
_loggerProcess.StartInfo.UseShellExecute = true;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: on Linux the logger runs inside the game's own terminal.

When UseShellExecute = true and FileName is an executable file, .NET on Unix runs it directly instead of opening a new window. So on Linux Process.Start succeeds and the catch below is never reached. ConsoleEngine.Logger then:

  • writes Logger up and running... and every log line into the game's alternate screen, corrupting the rendered frame,
  • writes title escape sequences (Console.Title) to the same terminal,
  • ends with Console.ReadKey(), competing with the game for keyboard input.

On Linux it would be safer to start it in a terminal emulator (x-terminal-emulator -e …, or $TERMINAL), or not to start it at all and print a hint to run the logger manually.

_originalHeight = SafeWindowSize(() => Console.WindowHeight, 0);
_initialized = true;

AppDomain.CurrentDomain.ProcessExit += (_, _) => RestoreTerminal();

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: the terminal isn't restored after a crash.

The terminal is restored only from ProcessExit and CancelKeyPress, and .NET does not raise ProcessExit when the process dies from an unhandled exception. After an exception in a scene's Update, for example, the shell comes back inside the alternate screen, with auto-wrap off (?7l) and the cursor hidden, until the user runs reset.

Suggestion: also hook AppDomain.CurrentDomain.UnhandledException, or wrap the game loop in GameBase.Start in a try/finally that calls the handler's Close().

void Map(int vk, params ushort[] macKeys) => map[vk] = macKeys;

// Letters (ANSI positions)
Map((int)Key.A, 0x00); Map((int)Key.S, 0x01); Map((int)Key.D, 0x02); Map((int)Key.F, 0x03);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: the native key map follows physical key positions, so it's wrong on non-US layouts.

kVK_ANSI_* codes identify where a key sits on the keyboard, but Windows virtual key codes (and the terminal path through ConsoleKey) follow the active layout. Once calibration trusts native input (one arrow or Space press is enough), the results differ from Windows:

  • On AZERTY, pressing the key labelled A reports Key.Q and Z reports W, so WASD controls break. Dvorak is scrambled entirely.
  • On Nordic and German layouts, the OEM keys (0xBA–0xDE) map to different virtual key codes than on Windows.
  • Letters that don't match also count as calibration misses, which can turn native input off altogether.

Either translate through the current layout (UCKeyTranslate / TISCopyCurrentKeyboardLayoutInputSource), or at least mention the US-layout assumption in the README next to CONSOLE_ENGINE_INPUT=terminal.

{
_loggerProcess.Start();
}
catch (Exception)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: if the logger fails to start, the failure is ignored and messages pile up.

When Process.Start throws (on headless Linux, for example), the exception is swallowed but Log keeps running as if the logger were attached:

  • the dispatch thread blocks in Socket.Accept() forever,
  • EnqueueMessage keeps adding to the unbounded Messages queue (10 FPS reports per second when ShowFps is on, plus every Log.WriteLine), and nothing ever reads them.

Before this change the failure at least surfaced. Consider setting a flag in the catch that stops queuing messages, or bounding the queue.

Comment thread ConsoleEngine/GameBase.cs
protected GameBase(int width, int height, FontInfo fontInfo)
{
_console = new RenderConsole(new ConsoleHandler(width, height, fontInfo));
_console = new RenderConsole(OperatingSystem.IsWindows()

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the platform choice is made in two places.

OperatingSystem.IsWindows() picks the Win32 or cross-platform implementation both here and in Input.cs, where it creates the static Handler. A small factory (for example Platform.CreateConsoleHandler(...) and Platform.CreateInputHandler()) would keep the decision in one place. That would make it easier to add a backend, or to force the ANSI renderer on Windows Terminal, without the two places getting out of sync.


/// <summary>Writes the current down state of every mapped key into <paramref name="down"/>, indexed by Windows virtual key code.</summary>
/// <returns>true if any key is down.</returns>
public static bool Poll(bool[] down)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: unused code.

The any value that Poll returns is ignored by its only caller (CrossPlatformInputHandler.Update), so Poll could return void. Similarly, CrossPlatformConsoleHandler.InitializeConsole calls SetFont(_fontInfo), which is empty, so the call and the _fontInfo field could both go.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants