EveScript Tutorial
中文 · User guide · Module handbook
This tutorial is maintained by hand; the Chinese documentation is authoritative.
EveScript is EVEngine's game scripting language. It extends Squirrel with script modules, gradual types, null operators, named arguments, Inspector annotations, persistent hot-reload state, unit literals, pattern matching and async / await. It keeps Squirrel's VM, object model, closures, generators and dynamic values.
All scripts use .nut. No language marker or feature switch is required: configuration, game files, imports, REPL and debugger code use the same syntax. Ordinary Squirrel remains supported.
1. Your first game
Create config.nut, main.nut and scripts/movement.nut in your game directory.
config.nut:
- Required
modulesmust exist; otherwise startup fails beforemain.nutexecutes. - Once either module field is present, only listed slots and the required startup slots (
win,gfx,timer,platform_event,fs,hot) are constructed. Unlisted slots return false fromhas_module(); useensure_module("audio")when needed. - Optional modules load when available. Check
has_module()before using them. - Names are script root slots:
gfx, not the CMake namegraphics. - Both fields must be literal string arrays so tools can inspect them without executing configuration code.
- Projects omitting both fields keep the previous behavior of constructing all compiled modules.
- Native classes bind lazily on first access to names such as
eve.Graphics. The Squirrelinoperator does not trigger binding. Do not test"Graphics" in eve; useeve.moduleList,has_module()orensure_module().
scripts/movement.nut:
main.nut:
Run eve run my-game. After saving a changed script, persist playerX retains its value while functions and constants are replaced.
The source repository also provides python scripts/migrate_evescript.py --check to preview migration and --write to apply it. It rewrites only provably equivalent root-state guards and persist(name, initializer) patterns; it does not guess how to rewrite dynamic root operations or dofile().
2. Squirrel foundations
Source files use UTF-8. Keywords are case-sensitive. Use // and /* ... */ comments. Semicolons remain valid; use them to separate multiple statements on one line. Prefer a consistent style relying on newlines and braces.
Arithmetic, comparison, logical operators, ternary expressions, member access and assignment retain their Squirrel meanings. import, export, async, await, persist and match have special meanings in their syntax positions. from and as are contextual import keywords, so legacy identifiers can still use them elsewhere.
local creates a lexically scoped variable. <- creates a slot; = changes an existing slot. Prefer local/private state and explicit exports. Reserve root slots for lifecycle callbacks and other intentional shared entry points. Squirrel const is a compile-time constant, not a general runtime read-only variable.
Functions support defaults and closures:
Tables, arrays and iteration:
Classes, inheritance and this:
Native objects use the same member-call syntax, but their callable methods come from script bindings, not every C++ public declaration.
if, for, try / catch and Squirrel generators still work:
Use generators for sequences advanced by their caller. Use async function when waiting for promises, timers or main-thread events.
3. Script modules: import and export
New code should use explicit modules instead of sharing a root table through dofile().
export applies only to top-level const, function and class declarations. Private locals do not leak to importers.
Module specifiers must be string literals:
game:/...: game-package scripts.engine:/...: SDK standard scripts.plugin:<id>/...: a plugin's exported scripts../...and../...: relative to the importing module, without escaping its root.
A canonical URI is instantiated once per runtime generation. Cyclic imports are rejected; move shared definitions to a third module. URIs work across development directories, .eve archives, APKs, iOS bundles and Web preloaded filesystems. Dynamically assembled import paths are not supported.
dofile(path) remains available for legacy execution and intentional shared root-table side effects. It does not provide module privacy, explicit exports, single-instantiation caching or a static dependency graph.
4. Gradual types
Annotations are optional and erased before bytecode generation. Values remain ordinary Squirrel values.
Primitive types are null, bool, int, float, string and dynamic. Use Array<T> and Table<K, V> for containers, and documented script class names for native types.
Known literals and function calls are checked at compile time. Native calls use the SDK's Binding Contracts for parameter names, types and units. dynamic and uninferred legacy code can still fail at runtime. For example, assigning "many" to an int, or passing 1.5 to a known int parameter, is a compile error.
5. Nullable values
T? permits null:
The helper functions above are supplied by your game. ?. skips access/calls on null receivers; ?? evaluates its right side only when the left side is null; ??= assigns only when null. Receivers and left operands are evaluated once. Non-nullable types cannot be assigned null. Validate dynamic legacy values at native/resource boundaries.
6. String choice types
Use string literal unions for mode and policy names:
Completion, spelling checks, exhaustive matching and Inspector choices use the same values. Runtime values remain strings. Passing a known literal outside the union is a compile error. material here is an existing game resource, not a new language construct.
7. Named arguments
The compiler uses a known script signature or native Binding Contract to lower the call to positional arguments. No argument table or extra per-frame allocation is created. Unknown, duplicate or missing arguments are compile errors.
Expressions are evaluated in their written order, even if parameter order differs. Dynamic native functions without a reliable contract require positional arguments. C++ default arguments do not automatically become script defaults; ambiguous methods also need positional calls.
SDK contract generation reads actual addFunc bindings from the build's profile. It fails on unresolvable parameter names instead of inventing arg0 names. The compiler, editor and runtime share the contracts. Literal checks apply at argument-expression boundaries; they do not misclassify numbers inside a valid compound expression such as "count " + (count + 1).
8. Inspector annotations
Annotations are shorthand for Squirrel attribute metadata:
Inspector, reflection and MCP read the same metadata. String choice types supply combo options. Misspelled built-in annotations are compile errors. Plugin annotations must be registered first; unknown annotations are not silently ignored.
9. Persistent hot-reload variables
Ordinary variables have no retention guarantee across reloads. Declare persistent root state explicitly:
persistis valid only at file root scope.- Names must be unique in the root table.
- Ordinary variables need no
transientmarker. - Prefer plain data; native handles that cannot be safely snapshotted belong to their modules' state providers.
- Migrate changed persistent layouts explicitly in
eve_reload().
Legacy score <- persist("score", function() { return 0 }) remains compatible. Prefer the declaration syntax in new code so variable and string names cannot diverge.
10. Unit literals
| Dimension | Target types | Suffix |
|---|---|---|
| Time | seconds, milliseconds | ms |
| Angle | radians, degrees | deg |
| Pixels | pixels | px |
| Distance | meters | m |
A known target type or native contract performs deterministic conversion. camera.setYaw(90deg) receives radians if its contract specifies radians; asyncSleep(250ms) receives milliseconds. Without a target, the literal uses its own base-unit value. Dimensions cannot be mixed. Units are compile-time checks/conversions; runtime values remain floats.
11. Pattern matching
match is a statement, not a value-producing expression:
Branches accept a statement or a block. Cover every string-union choice or provide a final else. Dynamic values always need else, because coverage cannot be proven. else may occur once and must be last. The matched expression is evaluated once. To produce a value, assign an outer local or return from branches.
12. Async functions and await
EveScript async uses engine promises, timers, events and the main-thread pump:
The loaders are game-defined. Calling an async function immediately returns a Promise. await normalizes values through Promise.resolve. Continuations resume on the game thread. try / catch handles rejection; returning resolves the outer Promise, and uncaught exceptions reject it. await is valid only inside async functions.
Loops, locals, this and exception state survive suspension. The engine calls async_pump() each frame; game code normally should not pump manually. Hot reload cancels pending continuations from the old generation. Save progress as plain data and restart long-lived work after reload.
Async does not move the Squirrel VM onto workers. CPU-heavy tasks use supported thread-module operations and return through Channel/Event/Promise. Workers must not access VM, window, GPU or script objects.
When an imported module changes, the runtime recompiles it and reverse dependencies and commits the group only after successful instantiation. A failure restores the previous modules and dependency graph. Legacy root scripts outside the module graph use soft reload.
13. Async, events and reactive streams
Choose by the task:
- One-time loads, delays and cutscenes: async functions.
- Discrete gameplay events with multiple listeners: event module.
- Continuous filtering/composition of input and state: Rx.
- Worker execution: thread plus Channel/Event, returning to the main thread.
See the Chinese event, Rx and thread guides. Do not turn every event stream into an indefinitely waiting async task.
14. Organize lifecycle and modules
Lifecycle callbacks remain engine-defined root slots:
Keep main.nut as orchestration: import systems, create top-level persistent state and wire callbacks. Put player, combat, HUD and world logic in their own modules.
Export a small stable interface. Avoid non-repeatable top-level side effects; initialize explicitly. Keep dependencies acyclic. Persist durable data while leaving rebuildable caches temporary. Declare required modules in config.modules; optional features use both optionalModules and has_module().
15. Error handling and coding style
Use lowercase snake_case.nut filenames and / in module URIs. Use snake_case for locals/functions, PascalCase for classes and UPPER_SNAKE_CASE for exported constants. Prefer locals and explicit exports over root-table writes.
Annotate public functions, engine boundaries and persistent state first. Use string unions for choices, named arguments for similar adjacent parameters, and unit literals for time/angles/distances.
Catch recoverable resource errors; do not hide programming mistakes. Avoid large temporary arrays, dynamic imports and unnecessary fine-grained C++/Squirrel calls in per-frame paths. Async tasks must account for failure, exit and reload cancellation. See API conventions for Result handling.
16. Differences from ordinary Squirrel
EveScript adds private import/export modules, erased gradual types, nullable operators, string unions, named arguments, short attribute annotations, persistent declarations, units, exhaustive match and Promise-based async syntax.
It retains .nut, the same VM and native bindings. It does not require types everywhere, introduce let / var, mark ordinary variables as transient, add an ECS DSL or create a second runtime. Events, Rx, promises and state bindings keep their separate roles.
17. Migrate an existing project
- Upgrade the SDK and run the existing game first. Ordinary classes, closures, generators, tables, arrays, attributes and
dofile()remain supported. - Declare required and optional project modules; listing either field stops unlisted modules from being constructed at startup.
- Replace shared file-level side effects with imports and exports.
- Add types to stable boundaries rather than every local at once.
- Replace repeated root guards with
persist, null checks with null operators, ambiguous positional calls with named arguments, bare unit values with unit literals, and deeply nested sequential Promise chains with async functions.
Keep the game runnable after each step and verify hot reload and packaging separately.
18. Tools, diagnostics and debugging
The language server uses standard input/output for LSP; do not mix extra logs into that channel. The repository's tools/vscode-eve-debug/ starts it for .nut files in VS Code/Cursor. Features include syntax/module diagnostics, binding completion and hover, signature help, outlines, cross-file definition/reference lookup, safe export/alias renaming, formatting, folding, semantic highlighting and incremental document updates.
Source maps keep breakpoints, stacks and exceptions tied to your original .nut files rather than lowered state-machine code. Packaging validates the static module graph, rejects missing modules, cycles and escaping URIs, and writes a module manifest.
Common diagnostics:
await is only allowed inside async function: make the function async or use a Promise chain.named arguments require a known function or Binding Contract: use positional arguments or check your SDK contract.non-exhaustive match: cover remaining choices or add a finalelse.outside the allowed choices: use a documented string choice.persist is only allowed at root scope: move it to module top level.required module is missing: use a matching SDK/profile, or make the feature optional with a fallback.- Import cycle: extract shared definitions into an acyclic dependency.
19. A complete example
Use the configuration from section 1. scripts/player.nut:
main.nut:
Learn Squirrel tables, arrays, classes and closures first; use modules and types to establish boundaries; add async when a workflow actually waits. Consult your SDK's API catalog for exact rendering and input signatures.