Create your own Ink Action
Overview
Ink Actions are custom commands that can be executed within the narrative system. They provide a flexible framework for creating interactive story elements, game mechanics, and world modifications. Each Ink Action follows a validate-then-execute pattern and can run on either the client or server side.
What Ink Actions Can Do
Ink Actions enable developers to:
- Create custom narrative commands with specific syntax patterns
- Execute timed operations (cooldowns, delays, animations)
- Modify game world state (weather, blocks, entities)
- Handle client-side rendering and animations
- Implement complex interactive story mechanics
- Validate command parameters before execution
Core Components
InkAction Abstract Class
The InkAction class is the foundation for all custom ink actions. It provides:
- Validation system: Check command syntax and parameters before execution
- Execution lifecycle: Managed execution with proper state tracking
- Side specification: Client or server-side execution
- Rendering hooks: Integration with game rendering pipeline
- Command matching: Flexible pattern matching for command recognition
Key Properties
id: Unique identifier for the ink actionsyntax: Human-readable syntax description (e.g.,"wait %time% <second(s), minute(s), hour(s)>")side: Execution side (CLIENTorSERVER)matcher: Command pattern matcher implementationisRunning: Current execution statecanBeExecuted: Validation success flag
Lifecycle Methods
validate(): Parse and validate command parametersexecute(): Run the ink action logictick(): Called every game tick while runningpartialTick(): Called for smooth interpolationrender(): Called for custom rendering (client-side)stop(): Cleanup when action completes
Creating Custom Ink Actions
Step 1: Extend InkAction
public class MyCustomInkAction extends InkAction {
// Your custom properties
private String targetValue;
private boolean immediateMode;
public MyCustomInkAction(String id, Side side, String syntax, CommandMatcher matcher) {
super(id, side, syntax, matcher);
}
@Override
public boolean needScene() {
return true; // Return true if this action requires a scene context
}
}Step 2: Implement Validation
The doValidate method parses command arguments and validates parameters:
@Override
protected InkActionResult doValidate(List<String> arguments, Scene scene) {
// Check minimum arguments
if (arguments.size() < 2) {
return InkActionResult.error(Translation.message(MISS_ARGUMENT_TEXT, "target value"));
}
// Parse and validate arguments
targetValue = arguments.get(1);
if (targetValue.isEmpty()) {
return InkActionResult.error(Translation.message(WRONG_ARGUMENT_TEXT, "Target value cannot be empty"));
}
// Handle optional parameters
if (arguments.size() > 2) {
try {
immediateMode = Boolean.parseBoolean(arguments.get(2));
} catch (Exception e) {
return InkActionResult.error(Translation.message(NOT_VALID_BOOLEAN, arguments.get(2)));
}
}
return InkActionResult.ok();
}Step 3: Implement Execution
The doExecute method contains your action's core logic:
@Override
protected InkActionResult doExecute(PlayerSession playerSession) {
// Check if a similar action is already running and clean up conflicts
playerSession.getInkActions().removeIf(inkAction -> inkAction instanceof MyCustomInkAction);
// Validate current state before proceeding
if (targetValue == null || targetValue.isEmpty()) {
return InkActionResult.error("Target value is not properly set");
}
// Execute based on mode
if (immediateMode) {
// Immediate execution - apply changes instantly
applyTargetValue(playerSession, targetValue);
isRunning = false; // Complete immediately
} else {
// Gradual execution - start the process but keep running
initializeGradualProcess(playerSession, targetValue);
// Action continues running until manually stopped in tick()
}
return InkActionResult.ok();
}Step 4: Register Your Ink Action
Register your custom ink action in the registry:
InkActionRegistry.register(() -> new MyCustomInkAction(
"my_custom_action", // Unique ID
InkAction.Side.SERVER, // Execution side
"mycmd %target% [immediate]", // Syntax description
command -> command.startsWith("mycmd") // Command matcher
));Side Specification
CLIENT Side
- Executed during client tick
- Used for visual effects, UI updates, animations
- Render methods are available for world and 2D rendering
- Limited to client-side operations
SERVER Side
- Executed during server tick
- Used for game logic, world modifications, player state changes
- Has full access to server-side APIs
- Changes are synchronized to clients automatically
Execution States and Lifecycle
Running State Management
Ink actions use the isRunning flag to control their lifecycle:
isRunning = true: Action continues executing each tickisRunning = false: Action is automatically removed next tick- Do
setRunning(false) or isRunning = falsewhen your action completes
Validation vs Execution
- Validation Phase: Parse arguments, check syntax, validate parameters
- Execution Phase: Only runs if validation returns
InkActionResult.ok() - Continuous Execution: Use
tick()method for ongoing operations
External Usage
Access running ink actions from other classes:
// Get player's active ink actions (respects side restrictions)
List<InkAction> actions = playerSession.getInkActions();
// Find specific action type
MyCustomInkAction myAction = actions.stream()
.filter(action -> action instanceof MyCustomInkAction)
.map(action -> (MyCustomInkAction) action)
.findFirst()
.orElse(null);InkActionResult Status Types
OK: Action completed successfullyIGNORED: Action was ignored (treated as success)BLOCK: Action blocks further executionERROR: Action failed with error messageWARN: Action succeeded with warning
Utility Methods
InkActionUtil
The utility class provides helpful methods:
getArguments(String command): Parse command into argument listgetSecondsFromTimeValue(double seconds, String timeValue): Convert time units
Error Message Constants
Use predefined error constants for consistency:
MISS_ARGUMENT_TEXT: Missing required argumentWRONG_ARGUMENT_TEXT: Invalid argument valueNOT_VALID_BOOLEAN: Invalid boolean valueNOT_VALID_NUMBER: Invalid numeric valueNOT_VALID_COLOR: Invalid color value
Best Practices
- Always validate input: Check all parameters thoroughly in
doValidate() - Use appropriate side: Choose CLIENT for visual effects, SERVER for game logic
- Handle errors gracefully: Return meaningful error messages
- Manage state properly: Set
isRunning = falsewhen actions complete - Document syntax clearly: Provide clear syntax descriptions for users
- Test command matching: Ensure your matcher correctly identifies commands
- Consider performance: Avoid heavy operations in
tick()methods
Example: Complete Teleport Ink Action
public class TeleportInkAction extends InkAction {
private double x, y, z;
private boolean relative = false;
private String dimension = null;
public TeleportInkAction(String id, Side side, String syntax, CommandMatcher matcher) {
super(id, side, syntax, matcher);
}
@Override
protected InkActionResult doValidate(List<String> arguments, Scene scene) {
if (arguments.size() < 4) {
return InkActionResult.error(Translation.message(MISS_ARGUMENT_TEXT, "x y z coordinates"));
}
// Parse coordinates
try {
x = Double.parseDouble(arguments.get(1));
y = Double.parseDouble(arguments.get(2));
z = Double.parseDouble(arguments.get(3));
} catch (NumberFormatException e) {
return InkActionResult.error(Translation.message(NOT_VALID_NUMBER, "Coordinates must be numbers"));
}
// Parse optional relative flag
if (arguments.size() > 4) {
try {
relative = Boolean.parseBoolean(arguments.get(4));
} catch (Exception e) {
return InkActionResult.error(Translation.message(NOT_VALID_BOOLEAN, arguments.get(4)));
}
}
// Parse optional dimension
if (arguments.size() > 5) {
dimension = arguments.get(5);
if (!dimension.matches("overworld|nether|end")) {
return InkActionResult.error(Translation.message(WRONG_ARGUMENT_TEXT, "Dimension must be overworld, nether, or end"));
}
}
return InkActionResult.ok();
}
@Override
protected InkActionResult doExecute(PlayerSession playerSession) {
Player player = playerSession.getPlayer();
// Calculate final coordinates
double finalX = relative ? player.getX() + x : x;
double finalY = relative ? player.getY() + y : y;
double finalZ = relative ? player.getZ() + z : z;
// Handle dimension change if specified
if (dimension != null) {
ResourceKey<Level> targetDimension = switch(dimension) {
case "nether" -> Level.NETHER;
case "end" -> Level.END;
default -> Level.OVERWORLD;
};
if (!player.level().dimension().equals(targetDimension)) {
// Dimension teleportation logic would go here
return InkActionResult.warn("Cross-dimension teleport initiated");
}
}
// Perform teleportation
player.teleportTo(finalX, finalY, finalZ);
// Remove this ink action instantly, as it mainly a unique ink action.
isRunning = false;
return InkActionResult.ok();
}
@Override
public boolean needScene() {
return false; // Teleportation doesn't require scene context
}
}
// Registration example:
InkActionRegistry.register(() -> new TeleportInkAction(
"teleport",
InkAction.Side.SERVER,
"tp %x% %y% %z% [relative] [dimension]",
command -> command.startsWith("tp ")
));