Graaly
Write Minecraft plugins in the language you already want to use.
Graaly runs TypeScript, JavaScript, and Python inside the server through GraalJS and GraalPy. The public API is shaped like a real library for each language. Normal plugin code does not resolve Java class names, call string-based type helpers, or navigate Java package trees.
TypeScript imports from graaly:
import { PlayerJoinEvent, events, text } from "graaly";
events.on(PlayerJoinEvent, event => {
event.player.sendMessage(text.color("&aWelcome to the server."));
});
Python imports from the same library using ordinary Python syntax:
from graaly import PlayerJoinEvent, event, text
@event(PlayerJoinEvent)
def welcome(join: PlayerJoinEvent) -> None:
join.player.send_message(text.color("&aWelcome to the server."))
The concepts match, but the style belongs to the selected language. TypeScript uses camelCase, ESM imports, inference, and typed callbacks. Python uses decorators, snake_case, type hints, exceptions, native collections, and real async and await.
What Graaly provides
- Events with typed event objects.
- Commands with sender narrowing, arguments, permissions, and completion.
- Tick-safe tasks and Python coroutines.
- Players, worlds, locations, chunks, blocks, items, inventories, and entities.
- Custom world generators and block populators.
- Configuration and plugin-owned data paths.
- HTTP and WebSocket clients.
- A genuine React 19 renderer for native game interfaces.
- Optional FastAPI, Pydantic, and SQLAlchemy architecture for large projects.
- Typed PacketEvents constants and wrappers when PacketEvents is installed.
- One stable source-level API with explicit version capabilities.
Installation
1. Install the runtime plugin
Stop the server and put Graaly-1.0.0.jar inside its plugins/ directory.
server/
├── server.jar
└── plugins/
└── Graaly-1.0.0.jar
Start the server once. Graaly creates its dedicated directory and downloads only the enabled language runtimes:
plugins/
├── Graaly-1.0.0.jar
└── Graaly/
├── config.yml
├── runtime/
│ └── 25.2.4/
└── scripts/
Every downloaded file is pinned to the Graaly release and verified by exact size and SHA-256. A truncated or modified JAR is not loaded.
2. Add a script plugin
TypeScript and JavaScript bundles use a .jsplugin folder:
Welcome.jsplugin/
├── plugin.yml
└── dist/
└── main.mjs
Python bundles use a .pyplugin folder:
Welcome.pyplugin/
├── plugin.yml
└── main.py
Copy the bundle into:
plugins/Graaly/scripts/
Restart the server or run:
/graaly reload
Confirm the loaded languages and plugin count with:
/graaly status
3. Configure runtime downloads
The generated plugins/Graaly/config.yml stays intentionally small:
runtime:
auto-download: true
languages:
javascript: true
python: true
connect-timeout-seconds: 20
request-timeout-seconds: 180
retry-attempts: 2
Disable a language when the server will not use it. For an offline installation, copy a complete verified cache from another Graaly server, then set auto-download to false.
Your first TypeScript plugin
Create Welcome.jsplugin/plugin.yml:
name: WelcomeTS
version: 1.0.0
main: dist/main.mjs
commands:
welcome:
description: Send a welcome message
usage: /welcome [name]
Then write src/main.mts:
import {
PlayerJoinEvent,
commands,
events,
info,
text,
} from "graaly";
events.on(PlayerJoinEvent, event => {
event.joinMessage = text.color(`&8[&a+&8] &f${event.player.name}`);
event.player.sendMessage(text.color("&aYour TypeScript plugin is running."));
});
commands.on("welcome", context => {
const selectedName = context.args[0] ?? context.sender.name;
context.reply(`&aWelcome, ${selectedName}.`);
return true;
});
export function onEnable(): void {
info("WelcomeTS enabled");
}
export function onDisable(): void {
info("WelcomeTS disabled");
}
Bundle it as ESM:
npx esbuild src/main.mts \
--bundle \
--platform=neutral \
--format=esm \
--target=es2024 \
--outfile=dist/main.mjs
The graaly import remains an external runtime module exposed by the plugin context. The development package supplies types and editor completion.
JavaScript without a build step
TypeScript is the best choice for a large plugin because the compiler catches invalid event properties and argument types before the server starts. JavaScript remains useful for a quick command, an experiment, or a small utility.
Create ServerClock.jsplugin/plugin.yml:
name: ServerClock
version: 1.0.0
main: main.mjs
commands:
clock:
description: Show the current world time
usage: /clock
Then create ServerClock.jsplugin/main.mjs:
import { commands, players } from "graaly";
commands.on("clock", context => {
if (!players.isPlayer(context.sender)) {
context.reply("This command must be used by a player.");
return true;
}
const player = context.sender;
const ticks = player.world.time;
context.reply(`&eWorld time: &f${ticks} ticks`);
return true;
});
Copy this folder directly into plugins/Graaly/scripts/ and run /graaly reload. There is no transpilation step because main.mjs is already an ESM module.
Cancel an event and complete a command
Event properties are writable when the underlying action supports modification. This listener protects diamond ore while leaving every other block untouched:
import {
BlockBreakEvent,
Material,
events,
text,
} from "graaly";
events.on(BlockBreakEvent, event => {
const isProtectedOre = event.block.type === Material.DIAMOND_ORE;
const canMine = event.player.hasPermission("mine.diamond");
if (isProtectedOre && !canMine) {
event.cancelled = true;
event.player.sendMessage(text.color("&cYou cannot mine this ore."));
}
});
Command completion receives the same typed context as the command handler. It returns an ordinary iterable of strings:
import { commands, players } from "graaly";
commands.complete("welcome", context => {
const query = (context.args.at(-1) ?? "").toLowerCase();
return players
.online()
.map(player => player.name)
.filter(name => name.toLowerCase().startsWith(query));
});
Python uses a decorator and a normal list comprehension:
from graaly import CommandContext, players, tab_complete
@tab_complete("welcome")
def complete_welcome(context: CommandContext) -> list[str]:
query = context.args[-1].lower() if context.args else ""
return [
player.name
for player in players.online()
if player.name.lower().startswith(query)
]
Locations without Java interop
A location comes from worlds.location. The developer never resolves a host class or remembers a package path:
import { commands, players, worlds } from "graaly";
commands.on("lobby", context => {
if (!players.isPlayer(context.sender)) return false;
const lobby = worlds.get("world");
if (lobby === null) {
context.reply("&cThe lobby world is not loaded.");
return true;
}
const destination = worlds.location(lobby, 0.5, 65, 0.5, 90, 0);
context.sender.teleport(destination);
context.reply("&aTeleported to the lobby.");
return true;
});
The Python version has the same contract and uses Python naming:
from graaly import CommandContext, command, players, worlds
@command("lobby")
def lobby(context: CommandContext) -> bool:
if not players.is_player(context.sender):
return False
world = worlds.get("world")
if world is None:
context.reply("&cThe lobby world is not loaded.")
return True
destination = worlds.location(world, 0.5, 65, 0.5, 90, 0)
context.sender.teleport(destination)
context.reply("&aTeleported to the lobby.")
return True
A command with permissions and persistent configuration
Configuration values remain owned by the script plugin:
import { commands, config, players } from "graaly";
commands.on("setspawnmessage", context => {
if (!context.hasPermission("welcome.admin")) {
context.reply("&cYou do not have permission.");
return true;
}
const message = context.args.join(" ").trim();
if (message.length === 0) return false;
config.set("welcome.message", message);
config.save();
context.reply("&aMessage saved.");
return true;
});
commands.on("who", context => {
const names = players.online().map(player => player.name);
context.reply(`&7Online: &f${names.join(", ") || "nobody"}`);
return true;
});
No Java collection conversion is required. players.online() is a normal TypeScript array.
Native Python with async and await
Create AsyncWelcome.pyplugin/plugin.yml:
name: AsyncWelcome
version: 1.0.0
main: main.py
commands:
countdown:
description: Start a short countdown
usage: /countdown
Write main.py:
from __future__ import annotations
import asyncio
from graaly import CommandContext, PlayerJoinEvent, command, event, info, tasks
@event(PlayerJoinEvent)
async def welcome_later(join: PlayerJoinEvent) -> None:
await tasks.sleep_ticks(20)
join.player.send_message("One second passed without blocking the server.")
@command("countdown")
async def countdown(context: CommandContext) -> bool:
for remaining in range(3, 0, -1):
context.reply(f"&e{remaining}...")
await asyncio.sleep(1)
worker_result = await tasks.to_thread(sum, range(10_000))
context.reply(f"&aDone. Worker result: {worker_result}")
return True
async def on_enable() -> None:
info("AsyncWelcome enabled")
await asyncio.sleep(0)
def on_disable() -> None:
info("AsyncWelcome disabled")
The event loop is integrated with the server lifecycle. Pending plugin coroutines are cancelled when the plugin is disabled or reloaded. tasks.to_thread is appropriate for isolated CPU or blocking work, while world and entity changes stay on the main thread.
Spawn an entity and clean it up
Entity helpers use named constants and ordinary option objects:
import {
EntityTypes,
PlayerJoinEvent,
entities,
events,
tasks,
worlds,
} from "graaly";
events.on(PlayerJoinEvent, event => {
const origin = event.player.location;
const location = worlds.location(
origin.world,
origin.x + 3,
origin.y,
origin.z,
);
const guide = entities.spawn(location, EntityTypes.SHEEP, {
name: "&bServer guide",
nameVisible: true,
});
tasks.later(20 * 30, () => entities.remove(guide));
});
Python keeps the same behavior with Python naming:
from graaly import EntityTypes, entities, tasks, worlds
async def remove_later(entity) -> None:
await tasks.sleep_ticks(20 * 30)
entities.remove(entity)
def spawn_guide(player):
origin = player.location
location = worlds.location(
origin.world,
origin.x + 3,
origin.y,
origin.z,
)
guide = entities.spawn(
location,
EntityTypes.SHEEP,
name="&bServer guide",
name_visible=True,
)
tasks.create_task(
remove_later(guide),
name="remove-server-guide",
)
Version-safe attributes
The source API remains stable, but Graaly does not pretend that a missing mechanic exists. Check a capability before using a feature introduced by a later game version:
import { Attributes, compatibility, entities, info } from "graaly";
if (compatibility.supports("attributes")) {
const health = entities.attribute(entity, Attributes.MAX_HEALTH, 40);
info(`Maximum health: ${health.baseValue}`);
}
Or require it and handle the native Graaly exception:
from graaly import GraalyUnsupportedFeature, compatibility
try:
compatibility.require("display_entities")
except GraalyUnsupportedFeature as unavailable:
print(unavailable.feature, unavailable.minecraft_version)
Typos remain normal missing-property errors. Only a real symbol from the stable Graaly contract that is unavailable on the running version becomes GraalyUnsupportedFeature.
Create a custom world in TypeScript
World generators are normal functions. The callback receives typed chunk, biome, coordinate, and random values:
import { Biome, Location, Material, commands, worlds } from "graaly";
const orePopulator = worlds.populator(({ chunk, random }) => {
for (let vein = 0; vein < 3; vein++) {
const block = chunk.getBlock(
random.nextInt(16),
12 + random.nextInt(28),
random.nextInt(16),
);
block.type = Material.DIAMOND_ORE;
}
});
const generator = worlds.generator({
generate({ biomes, chunk, chunkX, chunkZ }) {
chunk.setRegion(0, 0, 0, 16, 1, 16, Material.BEDROCK);
for (let x = 0; x < 16; x++) {
for (let z = 0; z < 16; z++) {
biomes.setBiome(x, z, Biome.PLAINS);
}
}
if (chunkX === 0 && chunkZ === 0) {
chunk.setRegion(4, 60, 4, 12, 63, 12, Material.STONE);
chunk.setRegion(4, 63, 4, 12, 64, 12, Material.GRASS_BLOCK);
}
},
canSpawn: ({ x, z }) => Math.abs(x) <= 8 && Math.abs(z) <= 8,
defaultPopulators: [orePopulator],
fixedSpawn: ({ world }) => Location(world, 8.5, 65, 8.5),
});
commands.on("voidworld", context => {
const world = worlds.get("graaly_void") ?? worlds.create("graaly_void", {
seed: 42,
environment: "NORMAL",
generateStructures: false,
generator,
});
world.setSpawnLocation(8, 65, 8);
context.reply(`&aWorld ${world.name} is ready.`);
return true;
});
The developer writes chunk generation logic instead of extending a Java class manually. Graaly creates the server adapter internally.
React interfaces without a browser DOM
@graaly/react is a genuine React 19 custom renderer built with react-reconciler. Hooks, state, context, reducers, memoization, keys, effects, and reconciliation behave like React. The host elements are native game surfaces instead of div and button.
import React, { useState } from "react";
import {
BossBar,
Inventory,
Item,
Line,
Scoreboard,
Tab,
createRoot,
} from "@graaly/react";
import type { Player } from "graaly";
function Wallet() {
const [coins, setCoins] = useState(100);
function buyDiamond(): void {
setCoins(current => Math.max(0, current - 10));
}
return (
<>
<Scoreboard title="&aGraaly">
<Line id="coins">Coins: {coins}</Line>
</Scoreboard>
<BossBar progress={coins / 100}>
&aWallet: &e{coins} coins
</BossBar>
<Tab header="&aGraaly" footer="&7React renderer active" />
<Inventory title="&2Shop" rows={3}>
<Item
slot={13}
material="DIAMOND"
name="&bBuy a diamond"
lore={["&7Price: 10 coins"]}
onClick={buyDiamond}
/>
</Inventory>
</>
);
}
export function mountWallet(player: Player): () => void {
const root = createRoot(player);
root.render(<Wallet />);
// Call the returned cleanup function when the player leaves.
return () => root.unmount();
}
When coins changes, React creates a new render tree. Graaly compares the previous and next immutable snapshots, then updates only the scoreboard line, boss bar, or inventory values that changed.
This is not HTML inside the normal player client. It is React controlling inventories, messages, scoreboards, boss bars, tab content, and chat input through the game protocol.
Use FastAPI for a large plugin backend
FastAPI is optional. A small plugin can keep everything in TypeScript or Python. A large network can separate interface rendering from durable data, permissions, purchases, or external integrations.
Pydantic validates the request and response contracts:
from typing import Literal
from pydantic import BaseModel, Field
class PurchaseRequest(BaseModel):
item: Literal["diamond", "gold", "speed"]
class Reward(BaseModel):
material: Literal["DIAMOND", "GOLD_INGOT", "SUGAR"]
amount: int = Field(ge=1, le=64)
class ProfileResponse(BaseModel):
id: str
name: str
coins: int
purchases: int
class PurchaseResponse(BaseModel):
profile: ProfileResponse
reward: Reward
message: str
FastAPI resolves authentication and database dependencies before calling the endpoint:
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, status
from .dependencies import Actor, SessionDep, require_purchase_permission
from .schemas import PurchaseRequest, PurchaseResponse
from .services import IdempotencyConflict, InsufficientCoins, purchase_item
router = APIRouter(prefix="/v1/shop", tags=["shop"])
@router.post("/purchase", response_model=PurchaseResponse)
async def purchase(
command: PurchaseRequest,
actor: Annotated[Actor, Depends(require_purchase_permission)],
session: SessionDep,
idempotency_key: Annotated[
str,
Header(alias="Idempotency-Key", min_length=8, max_length=128),
],
) -> PurchaseResponse:
try:
return await purchase_item(
session,
actor,
command,
idempotency_key,
)
except InsufficientCoins as error:
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail="Not enough coins",
) from error
except IdempotencyConflict as error:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Idempotency key already used for another purchase",
) from error
The service layer owns the transaction. The endpoint owns HTTP parsing, authorization, status codes, and response validation. This keeps database logic out of React components and game event handlers.
Call FastAPI from TypeScript
Graaly exposes an HTTP client that returns a typed response wrapper:
import { http } from "graaly";
type PurchaseResponse = {
profile: {
id: string;
name: string;
coins: number;
purchases: number;
};
reward: {
material: "DIAMOND" | "GOLD_INGOT" | "SUGAR";
amount: number;
};
message: string;
};
async function purchaseDiamond(
accessToken: string,
idempotencyKey: string,
): Promise<PurchaseResponse> {
const response = await http.post(
"http://127.0.0.1:8000/v1/shop/purchase",
{ item: "diamond" },
{
headers: {
authorization: `Bearer ${accessToken}`,
"idempotency-key": idempotencyKey,
},
},
);
const body = await response.json<PurchaseResponse | { detail?: string }>();
if (!response.ok) {
const detail = "detail" in body ? body.detail : undefined;
throw new Error(detail ?? `FastAPI returned ${response.status}`);
}
return body as PurchaseResponse;
}
Create the idempotency key once for a logical click and reuse it for every retry. This prevents a timeout or repeated click from charging the same purchase twice.
React can place the request inside a custom Hook or TanStack Query mutation. On success, update the cached profile. On error, restore the previous optimistic state. The database remains the source of truth.
Typed PacketEvents integration
PacketEvents stays a separate optional dependency. Install PacketEvents 2.13.0 when packet-level behavior is required.
TypeScript uses direct constants and wrappers:
import {
ClientPacket,
WrapperPlayClientChatMessage,
packets,
tasks,
text,
} from "graaly";
packets.onReceive(ClientPacket.CHAT_MESSAGE, context => {
const chat = context.wrap(WrapperPlayClientChatMessage);
if (chat.message.toLowerCase() === "show packet") {
context.cancel();
tasks.run(() => {
context.player?.sendMessage(
text.color(`&aIntercepted ${context.packetName}`),
);
});
}
});
Python keeps packet work synchronous, then schedules game work safely:
from graaly import (
ClientPacket,
WrapperPlayClientChatMessage,
packets,
tasks,
)
async def notify_player(player, packet_name: str) -> None:
player.send_message(f"Intercepted {packet_name}")
@packets.listen_receive(ClientPacket.CHAT_MESSAGE)
def intercept_chat(context) -> None:
chat = context.wrap(WrapperPlayClientChatMessage)
if chat.message.lower() != "show packet":
return
context.cancel()
if context.player is not None:
tasks.create_task(
notify_player(context.player, context.packet_name),
name="notify-intercepted-chat",
)
Packet listeners must finish synchronously because the network pipeline consumes their result immediately. World, inventory, and entity work belongs on the server thread through tasks.
When to use each architecture
For a small utility plugin, use one TypeScript or Python bundle and keep the design simple.
For a gameplay plugin, divide code into events, commands, services, repositories, and UI components only when those boundaries provide real value.
For a large network, a practical structure is:
Player action
↓
Graaly event or React callback
↓
Typed TypeScript client
↓
FastAPI endpoint
↓
Pydantic validation and dependencies
↓
SQLAlchemy transaction
↓
Validated response
↓
React state and native game UI update
React and FastAPI are not required to make Graaly useful. They are available when a project needs component composition, state management, validated service boundaries, persistence, concurrency control, or external APIs.
Requirements
- Java 17 or newer for Graaly itself.
- A Java version accepted by the selected server release.
- Maven 3.9 or newer only when building Graaly from source.
- Node.js 22.13 or newer for TypeScript and JavaScript development.
- Python 3.12 or newer for Python tooling and FastAPI projects.
- PacketEvents 2.13.0 only for packet listeners and wrappers.
Node.js and system Python are development tools. The server executes plugins in-process through GraalJS and GraalPy.
Historical launchers running on a modern JVM may require the matching release asset:
graaly-1.0.0-legacy-launcher-agent.jar
Use it only when the launcher rejects the JVM before plugins can load:
java \
-javaagent:graaly-1.0.0-legacy-launcher-agent.jar \
-jar server.jar nogui
Runtime safety
- Every script bundle receives an isolated language context.
- Data paths are confined to the plugin data directory.
- Runtime artifacts are pinned and hash-verified.
- Reload closes old contexts and cancels owned tasks.
- Missing version features fail explicitly.
- Packet callbacks preserve their synchronous cancellation contract.
- Graaly does not redistribute a game server, PacketEvents, GraalJS, or GraalPy.
Links
- Documentation: https://sk8erboi17.github.io/Graaly/
- Source code: https://github.com/sk8erboi17/Graaly
- Releases: https://github.com/sk8erboi17/Graaly/releases
- Issues and support: https://github.com/sk8erboi17/Graaly/issues
No gallery available for this project.