Date: 2026-02-17 Status: Design Phase - Implementation Deferred to Tomorrow Context: 2-week survivor demo for Epic Megagrant application (March 20 deadline)
This document captures the design decisions and implementation strategy for LunyScript’s Input API, which is the first critical-path feature for the survivor demo (Days 1-2 of 2-week plan).
Goal: Enable player movement via input actions in a cross-engine manner (Unity/Godot).
public class PlayerController : Script
{
public override void Build(ScriptContext context)
{
// Per-object input (local multiplayer support)
On.Input("Move").Do(
Transform.Move(Input.Direction(), 5.0)
);
On.Input("Fire").Do(
Prefab.Instantiate("Projectile")
);
// Global input (any device, typically pause/menu)
When.Input("Pause").Do(
Time.Pause(),
UI.Show("PauseMenu")
);
}
}
InputApi provides specialized blocks that read from current event context:
// InputApi.cs
public readonly struct InputApi
{
// Returns the current input event's value (Vector2 for axis, float for trigger)
// Only valid inside On.Input() or When.Input() blocks
public VariableBlock Value() => InputValueBlock.Create(_script);
// Returns normalized direction (Vector2.normalized)
public VariableBlock Direction() => InputDirectionBlock.Create(_script);
// Returns magnitude (useful for analog stick pressure)
public VariableBlock Magnitude() => InputMagnitudeBlock.Create(_script);
// Condition: is button pressed this frame? (not held)
public ScriptConditionBlock IsPressed() => InputIsPressedBlock.Create(_script);
// Condition: is button currently held?
public ScriptConditionBlock IsHeld() => InputIsHeldBlock.Create(_script);
}
Design principle: Domain-specific accessor blocks read from runtime context, rather than returning values directly.
Problem:
VariableBlock currently only has Table.VarHandle, meaning it references variables in Global/LocalVariables tables. But Input.Direction() needs to return a runtime-computed value from event context, not a table entry.
Current Architecture:
public abstract class VariableBlock : ScriptConditionBlock
{
internal virtual Table.VarHandle TargetHandle => null;
public abstract Variable GetValue(IScriptRuntimeContext runtimeContext);
}
Solution Options:
// InputDirectionBlock doesn't reference a table variable
internal sealed class InputDirectionBlock : VariableBlock
{
internal override Table.VarHandle TargetHandle => null; // no table reference
public override Variable GetValue(IScriptRuntimeContext runtimeContext)
{
if (!runtimeContext.TryGetEventData<InputEventData>(out var data))
{
LunyLogger.LogWarning("Input.Direction() called outside On.Input()");
return Variable.Zero; // or Vector2.zero
}
return Variable.FromVector2(data.Value.normalized); // runtime computation
}
}
Pros:
GetValue() computes on-the-flyCons:
TargetHandle is null for these blocks (affects Set/Add/etc methods)// At event time, write to global variable with static key
const string INPUT_VALUE_KEY = "__internal_input_value";
// Event handler writes to global table
void OnInputReceived(Vector2 value)
{
runtimeContext.GlobalVariables[INPUT_VALUE_KEY] = Variable.FromVector2(value);
ExecuteSequence();
runtimeContext.GlobalVariables.Remove(INPUT_VALUE_KEY);
}
// Input.Value() reads from global table
internal sealed class InputValueBlock : VariableBlock
{
public override Variable GetValue(IScriptRuntimeContext runtimeContext)
{
return runtimeContext.GlobalVariables[INPUT_VALUE_KEY];
}
}
Pros:
Cons:
public abstract class VariableBlock : ScriptConditionBlock
{
internal virtual Table.VarHandle TargetHandle => null;
// New: blocks can declare they read from event context
protected virtual Boolean UsesEventContext => false;
public abstract Variable GetValue(IScriptRuntimeContext runtimeContext);
}
// Input blocks override UsesEventContext
internal sealed class InputValueBlock : VariableBlock
{
protected override Boolean UsesEventContext => true;
public override Variable GetValue(IScriptRuntimeContext runtimeContext)
{
var data = runtimeContext.GetEventData<InputEventData>();
return Variable.FromVector2(data.Value);
}
}
Pros:
Cons:
Decision: Use Option A for MVP (special VariableBlock subclass with null TargetHandle). Option C can be added later if needed.
Problem:
On.Input() runs when input occurs, but we might want to access the value later (e.g., in On.FrameUpdate() for follow-up logic, or store “other” object from collision for processing in update).
Example:
// Want to use input value in multiple places
On.Input("Move").Do(
Var["lastMoveDir"].Set(Input.Direction()) // store for later
);
On.FrameUpdate().Do(
Transform.Move(Var["lastMoveDir"], 5.0) // use stored value
);
Solution Options:
// User pattern: store event data in variable if needed later
On.Input("Move").Do(
Var["moveDir"].Set(Input.Direction())
);
On.FrameUpdate().Do(
Transform.Move(Var["moveDir"], 5.0)
);
Pros:
Cons:
On.Input("Move").As("moveInput").Do(
Transform.Move(Input.Value()) // reads from event
);
On.FrameUpdate().Do(
Transform.Move(Var["moveInput"], 5.0) // reads from stored variable
);
How it works:
.As(name) creates local variable and auto-stores event dataPros:
Cons:
public abstract class ScriptActionBlock : ScriptBlock
{
// New lifecycle hooks
public virtual void PreFrame(IScriptRuntimeContext context) {}
public virtual void Execute(IScriptRuntimeContext context);
public virtual void PostFrame(IScriptRuntimeContext context) {}
}
// InputValueBlock stores data in PreFrame, clears in PostFrame
internal sealed class InputHandlerBlock : SequenceBlock
{
private InputEventData _capturedData;
public override void PreFrame(IScriptRuntimeContext context)
{
// Prepare: reset captured data
_capturedData = default;
}
public void OnInputReceived(InputEventData data)
{
_capturedData = data; // store for frame
Execute(context);
}
public override void PostFrame(IScriptRuntimeContext context)
{
// Cleanup: clear captured data
_capturedData = default;
}
}
Pros:
Cons:
Decision: Use Option A for MVP (user explicitly stores). Pre/post-frame callbacks (Option C) can be added post-demo if pattern emerges across multiple event types.
Problem: Button inputs need to distinguish:
Solution:
Store both states in InputEventData:
internal struct InputEventData
{
public Vector2 AxisValue; // for axis inputs (Move, Look)
public Boolean ButtonPressed; // true only on press frame
public Boolean ButtonHeld; // true while held
public String ActionName;
}
Unity adapter tracks state:
private Dictionary<string, bool> _buttonStates = new();
void OnInputAction(InputAction action, InputValue value)
{
var actionName = action.name;
var pressed = value.isPressed;
var wasHeld = _buttonStates.TryGetValue(actionName, out var held) && held;
var data = new InputEventData
{
ActionName = actionName,
ButtonPressed = pressed && !wasHeld, // only true on transition
ButtonHeld = pressed
};
_buttonStates[actionName] = pressed;
NotifyInputHandlers(actionName, data);
}
LunyScript blocks:
Input.IsPressed() // reads ButtonPressed
Input.IsHeld() // reads ButtonHeld
Problem:
Current Variable struct doesn’t support Vector2/Vector3 types. Options:
Variable<T> systemConsiderations:
**If we refactor to Variable
**If we defer Variable
Decision: SEE BOTTOM ADDENDUM.
Notes:
Workaround for demo:
// Add to Variable struct
public enum ValueType
{
Null,
Number,
Boolean,
String,
Vector2, // new
Vector3, // new
}
// Add cases to existing methods
public Vector2 AsVector2() => _type == ValueType.Vector2 ? (Vector2)_refValue : Vector2.Zero;
public Vector3 AsVector3() => _type == ValueType.Vector3 ? (Vector3)_refValue : Vector3.Zero;
public static Variable FromVector2(Vector2 v) => new(v, ValueType.Vector2);
public static Variable FromVector3(Vector3 v) => new(v, ValueType.Vector3);
Note: This introduces boxing for vectors (stored in _refValue), but acceptable for demo.
Bottom-up approach to minimize integration issues:
Goal: Define cross-engine input abstraction at Luny layer.
Files to create:
Luny/Engine/Services/LunyInputServiceBase.csLuny/Engine/Bridge/LunyInputValue.cs (struct, holds Vector2/bool/float)Luny/Engine/Bridge/LunyInputActionType.cs (enum: Axis, Button)LunyInputServiceBase API:
public abstract class LunyInputServiceBase : LunyEngineServiceBase
{
// Query input state (for polling)
public abstract LunyInputValue GetActionValue(String actionName);
public abstract Boolean IsActionPressed(String actionName);
public abstract Boolean IsActionHeld(String actionName);
// Event-based (for On.Input callbacks)
public event Action<String, LunyInputValue> OnInputAction;
}
LunyInputValue struct:
public struct LunyInputValue
{
public LunyInputActionType Type;
public Vector2 AxisValue;
public Boolean ButtonPressed;
public Boolean ButtonHeld;
}
Goal: Implement input service for both engines’ mocks.
Files to create:
Luny.Unity-Mock/Input/UnityInputServiceMock.csLuny.Godot-Mock/Input/GodotInputServiceMock.csUnityInputServiceMock (simplified for testing):
public class UnityInputServiceMock : LunyInputServiceBase
{
private Dictionary<string, LunyInputValue> _mockInputs = new();
// For tests: simulate input
public void SimulateInput(String actionName, Vector2 axisValue)
{
var value = new LunyInputValue
{
Type = LunyInputActionType.Axis,
AxisValue = axisValue
};
_mockInputs[actionName] = value;
OnInputAction?.Invoke(actionName, value);
}
public override LunyInputValue GetActionValue(String actionName)
{
return _mockInputs.TryGetValue(actionName, out var value) ? value : default;
}
}
Goal: Verify this compiles and mocks work in unit tests before touching LunyScript.
Goal: Extend existing Variable struct to handle vectors (defer generic refactor).
Files to modify:
Luny/Variables/Variable.cs - add Vector2/Vector3 casesChanges:
Goal: Implement InputApi and accessor blocks.
Files to create:
LunyScript/Api/InputApi.csLunyScript/Blocks/Input/InputValueBlock.csLunyScript/Blocks/Input/InputDirectionBlock.csLunyScript/Blocks/Input/InputIsPressedBlock.csLunyScript/Blocks/Input/InputIsHeldBlock.csKey design:
IScriptRuntimeContext.GetEventData<InputEventData>()Goal: Zero-allocation event data storage in runtime context.
Files to modify:
LunyScript/Runtime/ScriptRuntimeContext.csAdd:
public interface IScriptRuntimeContext
{
T GetEventData<T>() where T : struct;
Boolean TryGetEventData<T>(out T data) where T : struct;
}
public class ScriptRuntimeContext : IScriptRuntimeContext
{
private InputEventData _currentInputData;
private EventDataType _currentEventType;
public void PushInputEventData(in InputEventData data)
{
_currentInputData = data;
_currentEventType = EventDataType.Input;
}
public void PopEventData()
{
_currentEventType = EventDataType.None;
}
public Boolean TryGetEventData<InputEventData>(out InputEventData data)
{
if (typeof(T) == typeof(InputEventData) && _currentEventType == EventDataType.Input)
{
data = _currentInputData;
return true;
}
data = default;
return false;
}
}
Goal: Register input handlers at build time, wire to engine events at runtime.
Files to modify:
LunyScript/Api/OnApi.cs - add Input() methodLunyScript/Runtime/Events/ScriptEventScheduler.cs - add input handler registrationImplementation:
// OnApi.cs
public SequenceBlock Input(String actionName) =>
Scheduler?.ScheduleInputSequence(actionName);
// ScriptEventScheduler.cs
public SequenceBlock ScheduleInputSequence(String actionName)
{
var sequence = new SequenceBlock();
var handler = new InputEventHandler
{
ActionName = actionName,
Sequence = sequence,
EventData = new InputEventData()
};
_inputHandlers.Add(handler);
// Subscribe to LunyEngine input events
LunyEngine.Instance.Input.OnInputAction += OnEngineInputAction;
return sequence;
}
private void OnEngineInputAction(String actionName, LunyInputValue value)
{
// Find handlers for this action, execute them
foreach (var handler in _inputHandlers.Where(h => h.ActionName == actionName))
{
handler.OnInputReceived(value);
}
}
Goal: Write smoke test verifying On.Input() → Input.Direction() flow.
Test:
[Test]
public void Input_Direction_ReturnsValueFromContext()
{
var script = new TestInputScript();
var context = CreateTestContext();
script.Build(context);
// Simulate input
var inputService = LunyEngine.Instance.Input as UnityInputServiceMock;
inputService.SimulateInput("Move", new Vector2(1, 0));
// Verify Transform.Move was called with correct direction
Assert.That(script.MoveDirection, Is.EqualTo(new Vector2(1, 0)));
}
class TestInputScript : Script
{
public Vector2 MoveDirection;
public override void Build(ScriptContext context)
{
On.Input("Move").Do(
Method.Execute(() => MoveDirection = Input.Direction().GetValue(context).AsVector2())
);
}
}
Success criteria: Player can move in Unity using On.Input(“Move”) + Transform.Move(Input.Direction(), speed).
LunyScript_Demo_Plan_Feb+Mar_2026.mdLunyScript_Demo_Plan_Feb+Mar_2026__Input.md.junie/tasks.mdYou’re absolutely right - I massively overestimated. Let me reconsider.
My inflated estimate assumed:
Reality check:
Variable is already well-encapsulatedVariable as opaque type (doesn’t care about internals)Luny-Test/ and LunyScript-Test/Files that need changes:
Luny/Variables/Variable.cs → Variable<T> genericLuny/Variables/Number.cs (probably fine as-is)Vector2Variable, Vector3Variable specializationsLunyScript/Blocks/Variables/VariableBlock.csGetValue() return from Variable to IVariable or keep as Variable (base type)Luny/Variables/Table.cs - ensure it can store Variable<T>IVariable interfaceTotal: ~3 hours, maybe 4 with unexpected issues.
**Should we do Variable
Arguments FOR doing it now:
Arguments AGAINST:
**Do Variable
Rationale:
Revised timeline for tomorrow:
**Does this revised assessment change your preference? Should we tackle Variable