Proposal: error handling for games — try/else values + panic/recover (no crashes for non-experts) #8

Closed
opened 2026-08-29 20:21:54 +02:00 by orkun · 1 comment
Owner

Summary

Give Ludic a first-class error story so that "something went wrong"
(a missing save file, a bad network packet, a divide-by-zero, an out-of-range
list index) is a value a game can recover from — not a crash and not a silent
wrong result.

This is a language-level feature (new keywords + lowering), so it belongs
before the stdlib libraries that will lean on it (Filesystem, Net, Time parsing,
Regex, JSON).

Why it matters for game devs (who aren't systems programmers)

Non-expert developers should never see a raw segfault. When a save is corrupt or
a mod file is malformed, the game should be able to say "couldn't load that,
here's the fallback"
in one or two obvious lines — no boilerplate, no undefined
behavior.

Design direction (needs an RFC before coding)

Two models are on the table; we should pick one and keep it small:

  1. Result-style values (Go/Rust flavored, no unwinding) — a function that can
    fail returns a value carrying either an ok payload or an err. Ergonomic
    sugar makes the happy path short:

    # doc-check: skip — illustrative API sketch
    let save = try Save.load("slot1") else {
      Log.warn("no save, starting fresh")
      return World.fresh()
    }
    
  2. panic / recover for programmer mistakes (index out of range, assertion
    failed) — always logs file:line, and by default aborts the frame cleanly
    rather than corrupting the ECS world. A top-level recover lets a game loop
    survive a bad frame in dev builds.

Recommended split: recoverable failures = values (try/else, no hidden
control flow), bugs = panic (loud, with location). This keeps determinism
and avoids exceptions-as-goto, which non-experts (and the ECS scheduler) find
hard to reason about.

Considerations

  • Must stay deterministic and native/C-free (no libunwind dependency if avoidable).
  • Zero-cost on the happy path; no hidden heap allocation per call.
  • Great messages: every panic prints file:line and a human sentence.
  • Interop with the type-system work in #1 (an err type / tagged union).

Scope / acceptance

  • RFC picks the model and the exact keywords.
  • Lowering in the self-host compiler + .ll output.
  • panic(msg) builtin with file:line; documented abort behavior.
  • Docs page + example (loading a possibly-missing save).
  • Tests (see the testing-framework issue) covering both success and failure paths.

Related: #1 (type system / tagged unions), #2 (stdlib), and every I/O library below.

## Summary Give Ludic a **first-class error story** so that "something went wrong" (a missing save file, a bad network packet, a divide-by-zero, an out-of-range list index) is a value a game can *recover from* — not a crash and not a silent wrong result. This is a **language-level** feature (new keywords + lowering), so it belongs before the stdlib libraries that will lean on it (Filesystem, Net, Time parsing, Regex, JSON). ## Why it matters for game devs (who aren't systems programmers) Non-expert developers should never see a raw segfault. When a save is corrupt or a mod file is malformed, the game should be able to say *"couldn't load that, here's the fallback"* in one or two obvious lines — no boilerplate, no undefined behavior. ## Design direction (needs an RFC before coding) Two models are on the table; we should pick **one** and keep it small: 1. **Result-style values** (Go/Rust flavored, no unwinding) — a function that can fail returns a value carrying either an `ok` payload or an `err`. Ergonomic sugar makes the happy path short: ```ludic # doc-check: skip — illustrative API sketch let save = try Save.load("slot1") else { Log.warn("no save, starting fresh") return World.fresh() } ``` 2. **panic / recover** for *programmer* mistakes (index out of range, assertion failed) — always logs file:line, and by default aborts the frame cleanly rather than corrupting the ECS world. A top-level `recover` lets a game loop survive a bad frame in dev builds. Recommended split: **recoverable failures = values** (`try/else`, no hidden control flow), **bugs = panic** (loud, with location). This keeps determinism and avoids exceptions-as-goto, which non-experts (and the ECS scheduler) find hard to reason about. ## Considerations - Must stay deterministic and native/C-free (no libunwind dependency if avoidable). - Zero-cost on the happy path; no hidden heap allocation per call. - Great messages: every panic prints `file:line` and a human sentence. - Interop with the type-system work in #1 (an `err` type / tagged union). ## Scope / acceptance - [ ] RFC picks the model and the exact keywords. - [ ] Lowering in the self-host compiler + `.ll` output. - [ ] `panic(msg)` builtin with file:line; documented abort behavior. - [ ] Docs page + example (loading a possibly-missing save). - [ ] Tests (see the testing-framework issue) covering both success and failure paths. Related: #1 (type system / tagged unions), #2 (stdlib), and every I/O library below.
orkun added the
proposal
priority:high
area:types
labels 2026-08-29 20:21:54 +02:00
Author
Owner

Shipped in c7c8e27 — the first half of the error story: programmer bugs abort loud and located instead of crashing.

RFC decision. Per the issue's own recommendation, the model is the split: bugs → panic (loud, with file:line), recoverable failures → values (try/else, no hidden control flow). This keeps determinism and avoids exceptions-as-goto. Because value-carrying try/else needs a canonical err/tagged-union type — the issue notes the interop with #1 — that half is sequenced after #1. This change ships the panic half, which stands alone.

What landed:

  • panic(msg) — prints file:line: panic: <msg> to stderr and aborts with exit code 1. A clear, located message a non-expert can act on, never a raw segfault or a silent wrong result.
  • assert(cond, msg) — the guarded form: aborts with file:line: assertion failed: <msg> only when cond is false; otherwise execution continues. For the invariants the language should catch loudly (index in range, value non-negative, impossible branch).
function withdraw(balance: int, amount: int) -> int {
  assert(amount >= 0, "amount must be non-negative")
  assert(amount <= balance, "cannot overdraw")
  return balance - amount
}
# withdraw(100, 250)  ->  Bank.ludic:3: assertion failed: cannot overdraw   (exit 1)

How it's built: the call node now carries its source line and g_src_name (set from the input path) carries the file, so the location is baked in at compile time; both builtins lower in emit_call to an fprintf-to-stderr + exit(1) + unreachable tail (assert branches on the condition first). @fprintf and the format constant are declared on demand (g_uses_panic), so a program that never panics is byte-identical to before — and the compiler's own source uses neither, so the C-free bootstrap fixpoint still holds (verified). panic/assert are registered as builtins across the vocabulary (ludic_syntax.h, JetBrains lexer, TextMate grammar — x test-tools confirms sync) and documented at docs/language/builtins/.

Acceptance: ✅ RFC picks the model (the split above), ✅ lowering in the self-host compiler + .ll, ✅ panic(msg) with file:line + documented abort (exit 1, stderr), ✅ docs page + example (examples/library/errors.ludic), ✅ tests covering both paths — the success path (asserts hold, program runs to the end) as a suite example, and the failure path (panic → non-zero exit + the located stderr line) as a dedicated panic_case; x test is now 69 checks. Deferred: value-carrying try/else (rides #1's tagged unions) and a top-level recover for the dev game loop. Closing the panic half.

Shipped in c7c8e27 — the first half of the error story: programmer bugs abort **loud and located** instead of crashing. **RFC decision.** Per the issue's own recommendation, the model is the split: *bugs → panic* (loud, with `file:line`), *recoverable failures → values* (`try`/`else`, no hidden control flow). This keeps determinism and avoids exceptions-as-goto. Because value-carrying `try`/`else` needs a canonical `err`/tagged-union type — the issue notes the interop with #1 — that half is sequenced after #1. This change ships the panic half, which stands alone. **What landed:** - **`panic(msg)`** — prints `file:line: panic: <msg>` to **stderr** and aborts with exit code 1. A clear, located message a non-expert can act on, never a raw segfault or a silent wrong result. - **`assert(cond, msg)`** — the guarded form: aborts with `file:line: assertion failed: <msg>` only when `cond` is false; otherwise execution continues. For the invariants the language should catch loudly (index in range, value non-negative, impossible branch). ```ludic function withdraw(balance: int, amount: int) -> int { assert(amount >= 0, "amount must be non-negative") assert(amount <= balance, "cannot overdraw") return balance - amount } # withdraw(100, 250) -> Bank.ludic:3: assertion failed: cannot overdraw (exit 1) ``` **How it's built:** the call node now carries its source line and `g_src_name` (set from the input path) carries the file, so the location is baked in at compile time; both builtins lower in `emit_call` to an `fprintf`-to-stderr + `exit(1)` + `unreachable` tail (`assert` branches on the condition first). `@fprintf` and the format constant are declared **on demand** (`g_uses_panic`), so a program that never panics is byte-identical to before — and the compiler's own source uses neither, so the **C-free bootstrap fixpoint still holds** (verified). `panic`/`assert` are registered as builtins across the vocabulary (`ludic_syntax.h`, JetBrains lexer, TextMate grammar — `x test-tools` confirms sync) and documented at `docs/language/builtins/`. **Acceptance:** ✅ RFC picks the model (the split above), ✅ lowering in the self-host compiler + `.ll`, ✅ `panic(msg)` with `file:line` + documented abort (exit 1, stderr), ✅ docs page + example (`examples/library/errors.ludic`), ✅ tests covering **both** paths — the success path (asserts hold, program runs to the end) as a suite example, and the failure path (`panic` → non-zero exit + the located stderr line) as a dedicated `panic_case`; `x test` is now 69 checks. **Deferred:** value-carrying `try`/`else` (rides #1's tagged unions) and a top-level `recover` for the dev game loop. Closing the panic half.
orkun closed this issue 2026-08-31 13:33:57 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: workshopsoft/ludic#8
No description provided.