let’s refine the Input action API first
generally, as much as possible I prefer blocks not to return values but rather internalize the standard behaviour at least I’d also like to look ahead and consider local multiplayer before deciding on the actual API
if we can set up input action maps in both engines on a per-object level, the input events should be: On.Input(“Fire”).Do(blocks); this would send the event only when the scripted object is bound to the device that emits “Jump”
for global input not directed at the current object: When.Input(“Pause”).Do(blocks);
Question is: how do we pass the input value to blocks inside the Do()? We can use the ScriptRuntimeContext but we’d also need to tell those blocks that they run within the input context so that a Move() uses the input value rather than a literal or variable.
I wonder if this is good design though. It may not scale well. What do you think?
Note: event handling within a block would generally work this way: register/unregister with the engine event handler, store the values it receives in fields, provide/use those values when the block executes and reset those values at the end of the frame or before the next frame begins. Whereas the On.* and When.* blocks execute exactly when that event occurs.
Excellent question. This is a critical design decision that affects LunyScript’s ergonomics and scalability. Let me think through the implications.
Input values need to flow into blocks, but:
Your approach:
On.Input("Fire").Do(blocks); // per-object, player-bound
When.Input("Pause").Do(blocks); // global
// Inside Do(), blocks magically know about input context?
On.Input("Move").Do(
Transform.Move(???) // How does Move know about input value?
);
Problem: How do blocks access the input value without explicit parameters?
Your instinct is correct: This won’t scale well because:
Move() is called in non-input context?)On.Input("Move").Do(
Transform.Move(Input.Value()) // reads from current input context
);
On.Input("Fire").Do(
Prefab.Instantiate("Projectile")
.At(Object.Position())
.Facing(Input.Direction()) // Direction = normalized Value
);
How it works:
On.Input() sets ScriptRuntimeContext.CurrentInputValueInput.Value() block reads from contextPros:
Input.Value())Collision.Other(), Trigger.Point(), Mouse.Position()Cons:
On.Input("Move").Do(Transform.Move(Event.Data()));
On.Collision().Do(
Debug.LogInfo(Event.Other()), // Other = collided object
Object.Destroy(Event.Other())
);
How it works:
Event.Data() returns current event’s payloadPros:
Cons:
Event.Data() is too generic (what type is it?)On.Input("Move").As("moveInput").Do(
Transform.Move(Var["moveInput"])
);
On.Collision().As("hit").Do(
Object.Destroy(Var["hit"].Other)
);
How it works:
.As(name) creates a temporary local variablePros:
Cons:
.As() step)// Input API provides specialized context accessors
On.Input("Move").Do(
Transform.Move(Input.Value()) // Input.Value() is InputValueBlock
);
On.Input("Fire").IsPressed().Do( // condition: only when pressed, not held
Prefab.Instantiate("Projectile").Facing(Input.Direction())
);
// Collision API provides collision-specific accessors
On.Collision().With("Enemy").Do(
Var["health"].Subtract(Collision.Damage()), // reads from collision data
Object.Destroy(Collision.Other())
);
// Mouse API provides mouse-specific accessors
On.Mouse("Click").Do(
Prefab.Instantiate("Effect").At(Mouse.WorldPosition())
);
How it works:
Input.Value(), Collision.Other(), Mouse.Position() are blocksScriptRuntimeContext.CurrentEventDataPros:
Cons:
This pattern extends cleanly:
// Physics events
On.Collision().With("Wall").Do(
Var["speed"].Set(0),
VFX.Play("Dust").At(Collision.Point())
);
On.TriggerEnter().Do(
Debug.LogInfo("Entered: ", Trigger.Other().Name()),
Var["itemsCollected"].Increment()
);
// UI events
On.UI("Button_Start").Clicked().Do(
Scene.Load("Gameplay")
);
// Animation events
On.Animation("Walk").Frame(5).Do(
Audio.Play("Footstep")
);
// Lifecycle events with parameters (e.g., damage taken)
On.DamageTaken().Do(
Var["health"].Subtract(Event.Amount()),
VFX.Play("Blood").At(Event.HitPoint())
);
Your On.Input() vs When.Input() distinction is good:
// Per-player (this script's bound input device)
On.Input("Jump").Do(
Physics.AddImpulse(0, 10, 0)
);
// Global (any device, typically for pause/menu)
When.Input("Pause").Do(
Time.Pause(),
Menu.Open("Pause")
);
Engine binding:
PlayerInput component per player, action callbacksMultiplayerInput or manual device ID mappingAlternative naming (if you want symmetry):
On.PlayerInput("Jump") vs On.GlobalInput("Pause")When.* for global events is cleanerAPI Design:
// 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 currently pressed? (not held)
public ScriptConditionBlock IsPressed() => InputIsPressedBlock.Create(_script);
// Condition: is button currently held?
public ScriptConditionBlock IsHeld() => InputIsHeldBlock.Create(_script);
}
// Usage
On.Input("Move").Do(
Transform.Move(Input.Direction(), 5.0) // direction * speed
);
On.Input("Fire").IsPressed().Do( // only trigger once, not continuous
Prefab.Instantiate("Projectile")
);
// Global input
When.Input("Pause").IsPressed().Do(
Time.TogglePause(),
Menu.Toggle("Pause")
);
Implementation pattern:
// InputValueBlock.cs
internal sealed class InputValueBlock : VariableBlock
{
internal static InputValueBlock Create(IScript script) => new(script);
private InputValueBlock(IScript script) : base(script) {}
public override Variable Execute(IScriptExecutionContext context)
{
var inputData = context.CurrentEventData as InputEventData;
if (inputData == null)
{
LunyLogger.LogError($"Input.Value() called outside On.Input() context");
return Variable.Zero; // graceful degradation
}
return Variable.FromVector2(inputData.Value);
}
}
ScriptRuntimeContext changes:
public interface IScriptExecutionContext
{
object CurrentEventData { get; } // set by event handlers
}
public class InputEventData
{
public Vector2 Value;
public string ActionName;
public int PlayerIndex; // for local multiplayer
}
Q: What if Input.Value() is called outside On.Input()?
Option A: Runtime error (strict)
Option B: Return zero (forgiving, my preference)
Documentation must be clear:
⚠️
Input.Value()only returns valid data insideOn.Input()orWhen.Input()blocks. Outside this context, it returns zero.
With this approach, Transform.Move() becomes:
Transform.Move(direction, speed)
Where direction can be:
Input.Direction() (inside input event)Var["movementDir"] (variable)Transform.DirectionTo("Enemy") (computed)This is composable and scales:
// Complex movement combining input and AI
On.Input("Move").Do(
Var["inputDir"].Set(Input.Direction()),
Var["finalDir"].Set(Var["inputDir"].Add(Var["knockbackDir"])),
Transform.Move(Var["finalDir"], 5.0)
);
Use Option 4: Specialized Event Blocks
API structure:
On.Input("action").Do(blocks) - per-objectWhen.Input("action").Do(blocks) - globalInput.Value(), Input.Direction(), Input.Magnitude() - context accessorsInput.IsPressed(), Input.IsHeld() - condition blocksBenefits:
Next steps:
Does this approach feel right? Any concerns about the .Value() pattern or local multiplayer binding?
yes let’s try that design but I have some concerns
the Execute method currently returns void VariableBlock is a ScriptConditionBlock which only has Evaluate so far
For this code, Transform.Move would have to take a VariableBlock as first parameter (I think that’s okay unless this also creates a block when you pass a literal value): On.Input(“Move”).Do( Transform.Move(Input.Direction(), 5.0) // direction * speed );
Then Move needs to “get” that variable’s value in its Execute() method. VariableBlock should have to call a GetValue() or Value property. Which should be on VariableBlock.
The Variable struct currently doesn’t handle Vector types. I noted that this needs design. Assume the Variable struct is “Variable
How does On.Input know what value to store in CurrentEventData? Say we hook up “Move” event, in Unity the InputValue doesn’t have a specific type, it’s generic. So we need to ensure that both engines tell us what the primitive type of their InputValue is. Or we make an assumption: axis are Vector2, buttons are bool or float in the range 0-1.
Since CurrentEventData is an object, we should instantiate an event reference at build time to avoid runtime allocations, including boxing.
Perfect, now I understand your architecture fully. Let me address your concerns:
Agree completely. Variable needs generic support without boxing:
// Variable<T> where T : struct
public readonly struct Variable<T> : IVariable where T : struct
{
private readonly T _value;
public T Value => _value;
// Implicit conversions
public static implicit operator Variable<T>(T value) => new(value);
public static implicit operator T(Variable<T> v) => v._value;
}
// Concrete types
public readonly struct Vector2Variable : IVariable { /* wraps Vector2 */ }
public readonly struct Vector3Variable : IVariable { /* wraps Vector3 */ }
For demo: You can defer full generics, just add Vector2/Vector3 cases to existing Variable struct as immediate workaround.
Good news: VariableBlock.GetValue(IScriptRuntimeContext) already exists (line 96). So blocks can call it:
// TransformMoveBlock.cs
internal sealed class TransformMoveBlock : ScriptActionBlock
{
private readonly VariableBlock _direction;
private readonly VariableBlock _speed;
public override void Execute(IScriptRuntimeContext runtimeContext)
{
var dir = _direction.GetValue(runtimeContext); // calls GetValue
var speed = _speed.GetValue(runtimeContext);
// Convert Variable to Vector3 (assumes Variable supports Vector types)
var dirVector = dir.AsVector3(); // or dir.As<Vector3>()
var speedFloat = speed.AsDouble();
// Move the object
var transform = runtimeContext.LunyObject.Transform;
transform.Position += dirVector * speedFloat * Time.DeltaTime;
}
}
Transform.Move() should accept VariableBlock:
// TransformApi.cs
public ScriptActionBlock Move(VariableBlock direction, VariableBlock speed) =>
TransformMoveBlock.Create(direction, speed);
// Implicit conversions handle literals
public ScriptActionBlock Move(VariableBlock direction, Double speed) =>
TransformMoveBlock.Create(direction, speed); // speed auto-converts to ConstantVariableBlock
Usage:
// All of these work thanks to implicit conversions:
Transform.Move(Input.Direction(), 5.0) // VariableBlock, literal
Transform.Move(Var["moveDir"], Var["speed"]) // VariableBlock, VariableBlock
Transform.Move(Vector3.forward, 5.0) // literal vector, literal speed
This already works via your existing implicit operator VariableBlock(T value) operators (lines 17-22).
Q: How does On.Input() populate CurrentEventData?
Answer: OnApi registers event handlers at build time, captures values at runtime.
Implementation:
// OnApi.cs
public SequenceBlock Input(String actionName) =>
Scheduler?.ScheduleInputSequence(actionName, LunyObjectEvent.OnInput);
// ScriptEventScheduler.cs (inside LunyScript)
public SequenceBlock ScheduleInputSequence(String actionName, LunyObjectEvent eventType)
{
var sequence = new SequenceBlock();
// Create an input event handler (allocated once at build time)
var handler = new InputEventHandler
{
ActionName = actionName,
Sequence = sequence,
EventData = new InputEventData() // pre-allocated, reused
};
_inputHandlers.Add(handler);
return sequence;
}
// InputEventHandler.cs (internal to LunyScript runtime)
internal class InputEventHandler
{
public String ActionName;
public SequenceBlock Sequence;
public InputEventData EventData; // reused, no per-frame allocation
// Called by engine adapter when input occurs
public void OnInputReceived(Vector2 value)
{
EventData.Value = value; // store value
EventData.IsActive = true;
// Execute sequence with event data in context
var context = GetRuntimeContext();
context.PushEventData(EventData); // sets CurrentEventData
Sequence.Execute(context);
context.PopEventData(); // clears CurrentEventData
EventData.IsActive = false; // reset for next frame
}
}
// InputEventData.cs (pre-allocated struct, no boxing)
internal struct InputEventData
{
public Vector2 Value;
public String ActionName;
public Boolean IsActive;
}
Key points:
Q: How do we know if input is Vector2 (axis) or bool/float (button)?
Option A: Convention-based (my recommendation for MVP)
// Unity InputAction configuration dictates type:
// - "Move" action configured as Value<Vector2> → Vector2
// - "Fire" action configured as Button → bool/float
// LunyEngine adapter reads Unity's InputAction.type:
public void OnInputAction(InputAction action, InputValue value)
{
switch (action.type)
{
case InputActionType.Value:
var vec2 = value.Get<Vector2>();
NotifyInputHandlers(action.name, vec2);
break;
case InputActionType.Button:
var pressed = value.isPressed;
NotifyInputHandlers(action.name, pressed);
break;
}
}
Option B: Explicit type registration (defer until post-demo)
// User declares expected type (too much ceremony for beginners)
Input.DeclareAxis("Move");
Input.DeclareButton("Fire");
For demo: Use Option A (convention-based). Unity’s InputSystem already distinguishes action types, just read it.
Your concern about boxing is critical. Here’s the allocation-free approach:
// IScriptRuntimeContext (interface for execution context)
public interface IScriptRuntimeContext
{
// Event data accessor (returns struct, no boxing)
T GetEventData<T>() where T : struct;
Boolean TryGetEventData<T>(out T data) where T : struct;
}
// ScriptRuntimeContext (implementation)
public class ScriptRuntimeContext : IScriptRuntimeContext
{
// Pre-allocated event data (struct, on stack or in class field)
private InputEventData _currentInputData;
private CollisionEventData _currentCollisionData;
// ... other event types
private EventDataType _currentEventType;
public void PushInputEventData(in InputEventData data)
{
_currentInputData = data;
_currentEventType = EventDataType.Input;
}
public T GetEventData<T>() where T : struct
{
if (typeof(T) == typeof(InputEventData) && _currentEventType == EventDataType.Input)
return Unsafe.As<InputEventData, T>(ref _currentInputData);
// ... other types
throw new InvalidOperationException($"No event data of type {typeof(T).Name} available");
}
}
// InputValueBlock.cs (reads from context, zero-allocation)
internal sealed class InputValueBlock : VariableBlock
{
public override Variable GetValue(IScriptRuntimeContext runtimeContext)
{
if (!runtimeContext.TryGetEventData<InputEventData>(out var inputData))
{
LunyLogger.LogWarning("Input.Value() called outside On.Input() context");
return Variable.Zero; // or Vector2.zero if Variable supports it
}
return Variable.FromVector2(inputData.Value); // assumes Variable<Vector2> exists
}
}
Zero allocations:
Unsafe.As or type checks)objectPutting it all together:
// User script
public class PlayerController : Script
{
public override void Build(ScriptContext context)
{
// Per-player input (local multiplayer)
On.Input("Move").Do(
Transform.Move(Input.Direction(), 5.0),
Animation.Play("Walk")
);
On.Input("Fire").Do(
Prefab.Instantiate("Projectile")
.At(Transform.Position())
.Facing(Transform.Forward())
);
// Global input
When.Input("Pause").Do(
Time.Pause(),
UI.Show("PauseMenu")
);
}
}
Behind the scenes (LunyScript runtime):
On.Input("Move") registers InputEventHandler with ActionName=”Move”OnMove(InputValue value) → adapter extracts Vector2handler.OnInputReceived(vector2)Input.Direction() → reads from context’s InputEventDataZero allocations per frame (all structs/pre-allocated handlers).
Design decisions:
VariableBlock.GetValue() already exists (use it)object)Next steps:
Should I start with Variable Vector2/Vector3 support, or proceed with Input API assuming Variable will support vectors?
Continues in: LunyScript_Demo_Plan_Feb+Mar_2026__Input_IMPLEMENTATION.md