This guide is for anyone, human or AI agent, who wants to take a plugin written in PHP for PocketMine-MP and turn it into a Go plugin for PocketMine-go. This repository is the reference result: a working plugin that uses every piece described below. Read example.go alongside this file.
PocketMine-go has the same plugin API as PocketMine-MP, in Go: the same manifest keys (in
plugin.toml), the same events, commands, permissions, configs and scheduler, and the same class and method names in Go
style (getServer() → GetServer()). Most conversions are a straight translation, file by file.
| PocketMine-MP | PocketMine-go |
|---|---|
A plugin is a folder or .phar dropped in plugins/ |
A plugin is a Go module, compiled into the server (go get + one import line) |
plugin.yml, config.yml and other YAML files |
TOML: plugin.toml, config.toml (see §2 and §7) |
main: vendor\plugin\Main names the class to load |
init() calls plugin.RegisterGoPlugin, which creates your main type; main in plugin.toml is only informational |
resources/ is read from the plugin folder |
plugin.toml and resources/ are embedded with //go:embed |
Classes and inheritance (extends PluginBase) |
Structs and embedding (struct{ plugin.PluginBase }) |
| Exceptions | error return values; a panic is a bug |
array |
slices ([]T) and maps (map[K]V) |
null |
nil, or a second ok bool return (w, ok := GetWorldByName(...)) |
$x instanceof Player |
type assertion: p, ok := x.(*player.Player) |
| Virions (libraries bundled into the phar) | Normal Go modules in go.mod |
AsyncTask on worker threads |
scheduler.AsyncTask on worker goroutines, same rules (see §8) |
- Read the whole PHP plugin first:
plugin.yml, every class undersrc/,resources/. List its events, commands, permissions, config keys, tasks and any virions. - Create the module: copy this repository, then set your own module path in
go.mod(module github.com/you/yourplugin) and rename the package in the.gofiles. Keepgo.workfor developing next to a server clone.go.modneeds norequirefor the server; addrequirelines only for other modules your plugin uses (its former virions, ...). - Turn
plugin.ymlintoplugin.tomlwith the same keys:name,version,api,depend,softdepend,loadbefore,load,commandsandpermissionswork the same. Changemainto something likeyourplugin.Main(informational).src-namespace-prefixisn't used. Keep the permissions in the same order: adefaultcarries forward to the permissions declared after it, as in PocketMine-MP. See the side-by-side example below. - Copy
resources/(language files, ...), turn the defaultconfig.ymlintoconfig.tomlwith the same keys (§7), and embed them://go:embed plugin.toml resources. - Translate the main class into a
Mainstruct embeddingplugin.PluginBase(§3). - Translate each listener, command and task (§4 to §8), keeping the plugin's behaviour.
- Replace virions and PHP-only tricks (§10).
- Write a test like example_test.go: it starts a real server in a temporary folder, checks that the plugin loads, and runs its commands.
- Run
go vet ./...andgo test ./...until both are clean. - Tag a release matching
versioninplugin.toml(git tag v1.0.0). - Add it to a server (see README.md) and try it in game.
name: HomePlugin
version: 2.1.0
main: alex\home\Main
api: [5.0.0]
depend: [EconomyAPI]
commands:
home:
description: Teleports you home
usage: /home [name]
aliases: [h]
permission: home.command.home
permissions:
home.command.home:
default: true
home.command.sethome:
description: Set your homename = "HomePlugin"
version = "2.1.0"
main = "home.Main"
api = ["5.0.0"]
depend = ["EconomyAPI"]
[commands.home]
description = "Teleports you home"
usage = "/home [name]"
aliases = ["h"]
permission = "home.command.home"
[permissions."home.command.home"]
default = true
[permissions."home.command.sethome"]
description = "Set your home"class Main extends PluginBase{
protected function onLoad() : void{ ... }
protected function onEnable() : void{ ... }
protected function onDisable() : void{ ... }
}//go:embed plugin.toml resources
var files embed.FS
func init() {
plugin.RegisterGoPlugin(files, func() plugin.Plugin { return &Main{} })
}
type Main struct {
plugin.PluginBase
// the PHP class's properties go here
}
func (m *Main) OnLoad() { ... } // optional
func (m *Main) OnEnable() error { ...; return nil } // optional; an error disables the plugin
func (m *Main) OnDisable() { ... } // optionalEverything PluginBase offers keeps its name: GetServer(), GetLogger(), GetDataFolder(),
GetConfig(), SaveDefaultConfig(), ReloadConfig(), SaveResource(), GetResource(),
GetScheduler(), GetDescription(), GetName(), IsEnabled().
GetServer() returns a small interface. For the whole server API, assert it once:
func (m *Main) srv() *server.Server { return m.GetServer().(*server.Server) }class EventListener implements Listener{
/** @priority HIGH */
public function onJoin(PlayerJoinEvent $event) : void{
$event->getPlayer()->sendMessage("Hi");
}
}
$this->getServer()->getPluginManager()->registerEvents(new EventListener($this), $this);type listener struct{ plugin *Main }
func (l *listener) OnJoin(e *playerevent.PlayerJoinEvent) {
if p, ok := e.GetPlayer().(*player.Player); ok {
p.SendMessage("Hi")
}
}
// The docblock tags (@priority, @handleCancelled, @notHandler) become this method.
func (l *listener) EventHandlerTags() map[string]map[string]string {
return map[string]map[string]string{"OnJoin": {event.ListenerTagPriority: "HIGH"}}
}
// in OnEnable:
err := m.srv().GetPluginManager().RegisterEvents(&listener{plugin: m}, m)- Every exported method with one parameter, a pointer to an event, and no return value is a handler. The parameter type chooses the event.
- Events live in one package per PHP namespace, with the same class names:
pocketmine\event\player\PlayerJoinEvent→playerevent.PlayerJoinEventfrompocketmine-go/pocketmine/event/player. The same goes forevent/block,event/entity,event/inventory,event/world,event/serverandevent/plugin. $event->cancel()→e.Cancel(),$event->isCancelled()→e.IsCancelled().$event->getPlayer()returns a small interface (name, position, ...). Assert it to reach the full player, as above:p, ok := e.GetPlayer().(*player.Player).- A single handler without a listener type:
plugin.RegisterEvent(pm, func(e *playerevent.PlayerQuitEvent) { ... }, event.Normal, m, false). - Priorities:
event.Lowest,Low,Normal,High,Highest,Monitor.
Commands declared in plugin.toml are registered for you, exactly like PocketMine-MP.
public function onCommand(CommandSender $sender, Command $command, string $label, array $args) : bool{
if(!$sender instanceof Player){
$sender->sendMessage("Run this in game");
return true;
}
...
}func (m *Main) OnCommand(sender command.Sender, cmd command.CommandLike, label string, args []string) bool {
p, ok := sender.(*player.Player)
if !ok {
sender.SendMessage("Run this in game")
return true
}
...
return true // false shows the usage message
}With several commands, switch cmd.Label() { case "heal": ... }.
Declare them in plugin.toml as before. $sender->hasPermission("x.y") →
sender.HasPermission("x.y").
$this->saveDefaultConfig();
$max = $this->getConfig()->get("max-homes", 3);
$this->getConfig()->set("max-homes", 5);
$this->getConfig()->save();
$data = new Config($this->getDataFolder() . "homes.yml", Config::YAML, []);m.SaveDefaultConfig()
max := configInt(m.GetConfig().Get("max-homes", 3))
m.GetConfig().Set("max-homes", 5)
err := m.GetConfig().Save()
data, err := utils.NewConfig(filepath.Join(m.GetDataFolder(), "homes.toml"), utils.ConfigTOML, map[string]any{})The plugin's config is config.toml in its data folder, copied from resources/config.toml.
Server owners who used the PocketMine-MP version of your plugin keep their settings: a
config.yml already in the data folder is converted to config.toml on first start (and kept as
config.yml.bak). Use the same key names as the original.
Get returns any, so assert the type: name, _ := cfg.Get("name", "").(string). Whole numbers
come back as int, decimals as float64; a small helper such as configInt in
example.go accepts both. GetNested/SetNested work with dotted keys.
YAML to TOML, the parts that change:
| YAML | TOML |
|---|---|
key: value |
key = "value" (strings are quoted) |
list: [a, b] or - a lines |
list = ["a", "b"] |
section: + indented keys |
[section] header, then key = value lines |
a.b.c: as a key (dots in the name) |
quoted key: "a.b.c" = ... or [permissions."a.b.c"] |
key: ~ (null) |
leave the key out (TOML has no null) |
# comment |
# comment |
$this->getScheduler()->scheduleRepeatingTask(new ClosureTask(function() : void{ ... }), 20);
$this->getScheduler()->scheduleDelayedTask(new ClosureTask(fn() => ...), 100);m.GetScheduler().ScheduleRepeatingTask(scheduler.NewClosureTask(func() { ... }), 20)
m.GetScheduler().ScheduleDelayedTask(scheduler.NewClosureTask(func() { ... }), 100)
m.GetScheduler().ScheduleDelayedRepeatingTask(task, delay, period)Times are in ticks (20 ticks = 1 second). The returned *scheduler.TaskHandler has Cancel().
The plugin's tasks are cancelled when it's disabled.
Async work. PocketMine-MP's AsyncTask becomes a struct embedding scheduler.AsyncTaskBase:
type lookupTask struct {
scheduler.AsyncTaskBase
name string
}
func (t *lookupTask) OnRun() { t.SetResult(slowLookup(t.name)) } // worker goroutine
func (t *lookupTask) OnCompletion() { use(t.GetResult()) } // main thread
m.srv().GetAsyncPool().SubmitTask(&lookupTask{name: "Steve"})The same rule as in PocketMine-MP applies, and Go makes it easier to break: game state
(players, worlds, entities, inventories) may only be touched on the main thread, that is in event
handlers, commands, scheduled tasks and OnCompletion. Do slow I/O (HTTP, databases) in OnRun or
your own goroutine, and bring the result back through OnCompletion or a scheduled task. Never call
a player or world method from a goroutine you started.
| PocketMine-MP | PocketMine-go |
|---|---|
$this->getLogger()->info("x") |
m.GetLogger().Info("x") (also Warning, Error, Debug) |
TextFormat::GREEN . "Hi" |
utils.Green + "Hi" |
$server->getOnlinePlayers() |
srv.GetOnlinePlayers() |
$server->getPlayerExact($name) / getPlayerByPrefix |
srv.GetPlayerExact(name) / srv.GetPlayerByPrefix(name) (nil if offline) |
$server->broadcastMessage($msg) |
srv.BroadcastMessage(msg, nil) |
$server->dispatchCommand($sender, "say hi") |
srv.DispatchCommand(sender, "say hi", false) |
$server->getWorldManager()->getWorldByName("w") |
w, ok := srv.GetWorldManager().GetWorldByName("w") |
$server->getPluginManager()->getPlugin("X") |
srv.GetPluginManager().GetPlugin("X") |
$player->sendMessage / sendTip / sendPopup / sendTitle |
p.SendMessage / SendTip / SendPopup / SendTitle(title, subtitle, fadeIn, stay, fadeOut) |
$player->getName() / getPosition() / getWorld() |
p.GetName() / p.GetPosition() / p.GetWorld() |
$player->teleport(new Vector3(0, 70, 0)) |
p.Teleport(math.NewVector3(0, 70, 0)) |
$player->setGamemode(GameMode::CREATIVE()) |
p.SetGamemode(player.GameModeCreative) |
$player->getInventory()->addItem($item) |
p.GetInventory().AddItem(it) (returns what didn't fit) |
$player->kick("reason") |
p.Kick("reason", nil, nil) |
$player->sendForm($form) |
p.SendForm(f), where f implements form.Form |
VanillaItems::DIAMOND() |
item.VanillaItem("diamond") |
VanillaBlocks::STONE() |
block.VanillaBlock("stone") |
StringToItemParser::getInstance()->parse("diamond_sword") |
it, ok := item.GetStringToItemParser().Parse("diamond_sword") |
$item->setCount(16) / setCustomName("x") |
SetCount(16) / SetCustomName("x") |
$player->getNetworkSession()->sendDataPacket($pk) |
p.GetNetworkSession().SendDataPacket(&packet.X{...}) with packets from gophertunnel (minecraft/protocol/packet) |
When a name isn't in this table, look for the PHP method in Go style on the same type: the server
code is organised like PocketMine-MP (pocketmine/player, pocketmine/world, pocketmine/item,
pocketmine/block, ...) and every ported type says which PHP class it comes from
(grep -rn "is a port of pocketmine" pocketmine).
- Loading plugins at runtime (
.phar,plugins/folder,/reload-style tricks): plugins are compiled in. Changing a plugin means rebuilding the server. - Virions: replace each with a Go module, or port the parts you use into your plugin.
- PHP-only features:
eval, reflection over private members,__get/__callmagic, dynamic class names. Write the logic out explicitly. - Packet classes from
pocketmine\network\mcpe\protocol: use gophertunnel's packets instead (same names in Go style). - Blocking the main thread:
sleep()or synchronous HTTP in a handler freezes the whole server, as in PocketMine-MP. Use a task orAsyncTask.
-
plugin.tomlhas the same name, commands and permissions as the original. - Every event handler, command and task of the original has a Go counterpart.
- Default config keys and file names match, so existing server owners keep their settings.
- No game state is touched from a goroutine.
- A test starts a server and loads the plugin (example_test.go).
-
gofmt -l .,go vet ./...andgo test ./...are clean. - The README says how to add the plugin to a server.