A datapack framework port of the guigenmc logic (a JSON -> datapack generator).
Instead of describing menus in JSON, you write .mcfunction files and call guikit: functions.
Target: Minecraft Java 26.3 (min_format / max_format 121). Older versions need a matching pack.mcmeta value.
Widget items are stamped onto a chest_minecart with item replace. left-clicking moves the item out of the
cart, so #guikit:fill re-runs after every click to refill the empty slot. When the player
left-clicks one, the item lands in their inventory, clear detects it, the handler runs,
the item is removed, and the menu is redrawn if needed.
| guigenmc (generator) | guikit (framework) |
|---|---|
| JSON -> functions generated into one pack | Fixed core + menu code you write |
| All slots re-stamped every tick | Redrawn after every click (and whenever a handler sets guikit.dirty=1), skipped if the handler closed the menu |
Cart found with sort=nearest |
Cart bound to its player by uid (multiplayer safe) |
| Toggle/counter/cycle/random/cost/cooldown as text templates | Shared guikit:widget/* helpers |
| Menus live in the same pack | Menus can live in separate datapacks/namespaces (#guikit:register, ...) |
#guikit:register->data modify storage guikit:reg menus."ns:id" set value {alias:"ns_id", container:"chest_minecart"}#guikit:fill-> draws the current page (guikit:widget/pad, thenguikit:widget/draw)#guikit:probe-> one line per clickable widget:{id, fn}+guikit:widget/probe#guikit:clear_tags->tag @s remove guikit.m.<alias>function guikit:internal/clear_in, thendata merge storage guikit:in {menu:"ns:id"}, thenfunction guikit:api/open
Full working example: the guikit-demo datapack (3 pages: state widgets on page 0/1 -- button,
toggle, counter, cycle, progress, nav, close, random, confirm; command buttons, radio, meter and
links to the container demos on page 2 -- plus tiny one-page menus for the ender chest and barrel
presets, a 5-slot hopper and a container registered by the demo itself). It adds its entries to the #guikit:* tags above from its own pack, so this core
pack contains no menus and its tags are empty. Install both, then /function demo:open.
The five tag files here (
register,fill,probe,clear_tags,on_close) must stay{"values": []}withoutreplace: true, otherwise menu packs can no longer add themselves. They must exist even when empty:function #guikit:fillon a missing tag is an error.
The container key of a menu (default chest_minecart) is a name in the container registry,
storage guikit:reg containers.<name>. If the name is not registered it is taken as a raw vanilla entity id
(27 slots, gray pad), exactly as before. Built-ins (internal/containers_builtin, filled on every load before
#guikit:register runs):
| name | entity | slots | pad | title |
|---|---|---|---|---|
chest_minecart |
chest_minecart |
27 | gray | |
hopper_minecart |
hopper_minecart |
5 | gray | |
ender_chest |
chest_minecart |
27 | purple | Ender Chest |
barrel |
chest_minecart |
27 | brown | Barrel |
trapped_chest |
chest_minecart |
27 | red | Trapped Chest |
shulker_box |
chest_minecart |
27 | magenta | Shulker Box |
copper_chest |
chest_minecart |
27 | orange | Copper Chest |
oak_chest_boat, spruce_..., birch_..., jungle_..., acacia_..., dark_oak_..., mangrove_..., cherry_... _chest_boat, bamboo_chest_raft |
the same id | 27 | gray | |
donkey, mule |
the same id | 15 | gray | registered, not usable yet (see below) |
The themed ones are cosmetic: Minecraft has no ender chest / barrel entity, and the framework needs
/summon + item replace entity @s container.N, so they are a chest_minecart with a different pad and title.
Register your own from a #guikit:register listener (runs after the built-ins, so it can also replace one):
data modify storage guikit:reg containers.shop set value {entity:"chest_minecart", slots:27, pad:"minecraft:cyan_stained_glass_pane", title:{text:"Shop"}}
data modify storage guikit:reg menus."ns:shop" set value {alias:"ns_shop", container:"shop"}| key | meaning | default |
|---|---|---|
entity |
vanilla entity id without minecraft:; must be in the entity type tag #guikit:container |
the registry name |
slots |
inventory size: how many slots widget/pad fills (a cart never has more than 27) |
27 |
pad |
item id widget/pad puts in every slot |
minecraft:gray_stained_glass_pane |
title |
optional SNBT text component ({text:"Shop"}), becomes the container title |
none |
- Chest boats / rafts are in
#guikit:container, but only minecarts follow their owner (internal/followteleportschest_minecartandhopper_minecart). A boat stays where it was summoned, so the owner has to ride it:ride @s mount <the owner's cart>right afterapi/open(seedemo:click/open_chest_boat; match the cart byguikit.uid, the nearest cart can belong to another player). donkey/muleare registered but deliberately not in#guikit:container(their inventory slot numbering undercontainer.Nis unconfirmed), so opening such a menu is refused byinternal/open_fail. There is nooffsetkey.- Registry names are plain keys (
shop,ns_shop); quote the path if you use a colon:containers."ns:shop". titlemust be a compound, not'{"text":"Shop"}': in 26.3 a quoted string is a plain string and the title would show the JSON itself.- To use another entity type, add it to
#guikit:containerfrom your own pack (tag file withoutreplace: true). Only vanilla entities whose inventory is addressed ascontainer.Nwork. If the entity is not in the tag, or the summon fails,api/openremoves what it created, tells the player could not open the menu ... and returns 0 (before, such a cart would have stayed in the world forever). - While a menu is open,
@s guikit.slotsis its slot count, e.g.execute if score @s guikit.slots matches 27.. run ...in a#guikit:filllistener. A widget aimed at a slot the cart does not have showscould not draw widget. - A plain 27-slot gray
chest_minecartkeeps using the staticwidget/pad_cart; anything else is taggedguikit.styledat summon and filled bywidget/pad_cart_dynfrom the definition kept per owner uid (guikit:cont bound.u<uid>, dropped again byapi/close).
draw pad probe toggle counter cycle progress roll goto_page cooldown_start pay_item pay_score say
button button_probe radio meter_draw meter_probe sound (see below)
function guikit:internal/clear_cond
data merge storage guikit:cond {type:"score", obj:"coins", min:10}
function guikit:cond/check
execute if score #cond guikit.tmp matches 1 run say enough coinsRuns as the player. Result: #cond guikit.tmp = 1 / 0 (also the return value).
| type | keys | notes |
|---|---|---|
score |
obj, optional min, max |
an unset score fails |
item_count |
item, optional min (default 1) |
plain id or #tag only, no [components]; widget items of the same type are not counted |
tag |
tag |
|
gamemode |
mode |
survival / creative / adventure / spectator |
advancement |
adv |
e.g. minecraft:story/root |
predicate |
pred |
any predicate, so anything not listed can still be a condition |
level |
optional min, max |
XP level (the number above the hotbar, not a scoreboard); with neither key any level passes. Not verified in a live client |
all |
of:[{...}, {...}] |
passes when every element passes (empty list passes); stops at the first failure |
any |
of:[{...}, {...}] |
passes when at least one element passes (empty list fails); stops at the first pass |
data merge storage guikit:cond {type:"all", of:[{type:"tag", tag:"vip"}, {type:"level", min:5}, {type:"tag", tag:"banned", not:1b}]}
function guikit:cond/checkall/any are one level deep: an element must be a plain type, an element that is itself all/any is not
evaluated and counts as failed. Every element can carry its own not, and so can the all/any itself.
A missing of fails (not is not applied to it).
not:1b inverts. An unknown type or a missing key fails (result 0), except that a not:1b next to an
unknown type / missing key flips that 0 to 1 (known quirk of the leaf types; the all/any path does not have it). Every type is its own small function
(guikit:cond/t_*); min/max are two open-ended ranges, never a closed A..B.
New key -> add it to guikit:internal/clear_cond and to guikit:cond/load_cur (that copy list is shared by buttons and by all/any).
Three parts, each one line or one pair of lines:
- Definition, in your
#guikit:registerlistener (rebuilt on every reload):data modify storage guikit:btn defs."ns:buy" set value {cmd:"function ns:buy", timer:1200, cond:{type:"score", obj:"coins", min:5}, deny:"You need 5 coins."}
- Draw, in
#guikit:fill(same keys aswidget/drawminustype; callclear_wfirst):data merge storage guikit:w {slot:14, item:"minecraft:iron_sword", id:"ns:buy", name:{text:"Buy",italic:false}, lore:[]} function guikit:widget/button
- Probe, in
#guikit:probe:data merge storage guikit:p {id:"ns:buy"} function guikit:widget/button_probe with storage guikit:p
Definition keys:
| key | meaning |
|---|---|
cmd |
command run as @s at @s. Use function ns:name to run several commands |
url |
prints a clickable open_url link in chat instead (combine with close:1b, chat is hidden behind the menu) |
cond |
any condition from the table above (same keys, incl. not). Failing: deny message, nothing runs |
deny |
message when cond fails, default Not available. (no double quotes in it) |
cost |
{obj:"coins", amount:5} (score) or {item:"minecraft:diamond", count:3} (item, count defaults to 1). Charged after cond passed and before cmd / url; not enough -> poor message, nothing runs, nothing is taken |
poor |
message when the cost cannot be paid, default You can't afford that. (no double quotes) |
close |
1b = close the menu after the command |
timer |
reset the menu timeout to N ticks on a successful click |
locked_item |
what is drawn while cond fails or the cost is not affordable (default minecraft:barrier). Cosmetic: the click re-checks both |
Notes:
cmdis macro-expanded raw. If it contains double quotes, write it as a single-quoted SNBT string:cmd:'tellraw @s {"text":"hi"}'.cmdruns with the permission level datapack functions get (function-permission-level, default 2), not the player's.cmd/urlmust come from your menu code, never from player input (command injection).- An item
costfirst removes the widget items from the inventory (the clicked widget is still there while the click is handled), so a button that costs its own item type neither counts nor takes the widget instead of your items. The look check (locked_item) uses the same rule as theitem_countcondition. Score cost =pay_score, item cost =pay_item. - A working example is on page 2 of
examples/guikit-demo(demo:apple,demo:coin,demo:sword,demo:vip,demo:lvl,demo:combo=allcondition + score cost,demo:link)./function demo:open, then navigate to page 2.
Generalizes toggle from a bool to N states: each option in the group calls it with its own
literal value (menu code decides which slot maps to which value -- same trust model as
toggle / cycle, no defs registry).
function guikit:internal/clear_in
data merge storage guikit:in {obj:"mode", value:2}
function guikit:widget/radioLike progress, but the cells are clickable: clicking cell i (0-based) sets the objective
directly to (i+1) * max / width (floor division) instead of incrementing it. Three parts:
- Definition, in your
#guikit:registerlistener:data modify storage guikit:mtr defs."ns:vol" set value {obj:"volume", width:5, max:5, full:"minecraft:lime_dye", empty:"minecraft:gray_dye", name:{text:"Volume",italic:false}}
- Draw, in
#guikit:fill:data merge storage guikit:w {slot:20, id:"ns:vol"} function guikit:widget/meter_draw with storage guikit:w
- Probe, in
#guikit:probe-- ONE registration per meter, not per cell:data merge storage guikit:p {id:"ns:vol"} function guikit:widget/meter_probe with storage guikit:p
Definition keys: obj (objective set on click), width/max (same scaling as progress),
full/empty (item ids for filled/empty cells), name (SNBT text component, same as
progress's). Not verified in a live client.
Plays a sound to the clicking player only (ui category, so it follows the client's Master volume
and nothing else). Call it from a click handler, as @s at @s:
function guikit:internal/clear_in
data merge storage guikit:in {sound:"minecraft:ui.button.click", volume:1.0, pitch:1.0}
function guikit:widget/soundsound is required (a missing one returns 0 and plays nothing); volume / pitch default to 1.0.
It does not mark the menu dirty. sound is macro-expanded raw, so it must come from your menu code,
never from player input. Working example: demo:click/sound (via demo:internal/click_pling).
execute as <player> run function guikit:api/refreshFor code outside guikit (another datapack, a trigger handler, a timer) that changed something the
menu shows. Result 1 = redraw scheduled, 0 = that player has no open menu (nothing is touched).
It only sets guikit.dirty; the redraw itself happens on the player's next tick_player, so calling it
several times in one tick still draws once. Widget handlers do not need it -- they already mark the
menu dirty.
Called at the end of guikit:api/close, as the player whose menu just closed, for cleanup of your own
per-menu state. Two things to know before you write a listener:
api/opencallsapi/closein the middle of its own run to dispose a previous menu, soon_closealso fires when the player merely switches menus. Only reset state that is safe to lose then.- It runs after the cart is gone and the player's guikit scores are reset. Do not call
api/open/api/refreshfrom a listener expecting the old menu to still exist.
Same rules as the other tags: must exist (even empty) and must not use replace: true. Example:
demo:on_close (disarms the danger button).
widget/cooldown_start returns 0 while a cooldown runs but tells the player nothing. To show the
remaining time (rounded up to whole seconds, guikit.cd counts ticks):
execute unless function guikit:widget/cooldown_start run function guikit:internal/cd_notifycd_notify reads @s guikit.cd directly and prints [GUI] Wait Ns. to that player. Not used by the demo.
mecha .passing does NOT mean the pack loads. mecha 0.101 accepteddemo:click/lootbox(now inguikit-demo) while Minecraft 26.3 rejected it (Whilst parsing command on line 5 ... at position 48, right beforerun). So mecha is not a substitute for loading the pack in the real game. It also does not validate macro lines (lines starting with$).- Verified in a real 26.3 client (from
latest.log): the only load error wasdemo:click/lootbox(in what is nowguikit-demo). It used the patternexecute if score ... matches A..B run give .... It was rewritten to use only open-ended ranges and a separate function per reward. The exact grammar reason 26.3 rejects the old line was not identified; the rewrite removes both suspects (the closed range andrun give). Re-load the pack in 26.3 and checklatest.logto confirm. data modify storage X {} set value {...}was removed. It parses (mecha and the wiki's NBT-path table accept{}as the root path) but in a real 26.3 game it did not write: the run reported "nothing changed" and the storage stayed untouched. The exact 26.3 rule behind this was not identified. All 43 uses were replaced bydata merge storage X {...}.mergekeeps old keys, anddata remove storage Xneeds a path, so scratch storages are cleared key by key withguikit:internal/clear_in/clear_wbefore each merge. Otherwise keys likewrap,min,maxfrom a previous widget call would leak into the next one (e.g. intocounter_step). When you add a new key to aguikit:in/guikit:wcall, add it to the matchingclear_*function too.- Conditions / command buttons (
cond/*,widget/button*,internal/btn_*):mecha .passes and every macro line was expanded with sample values and re-linted with mecha (all parse). That is all that was checked: mecha does not catch everything here: it acceptedif data storage guikit:btn cur {close:1b}(path, space, compound) which 26.3 rejects at load (Incorrect argument for command). A compound filter on a non-root path must be attached to it:cur{close:1b}. Fixed. Loaded in a real 26.3 client: only that load error was seen; clicks, cond types and the demo menu are still untested. Checklatest.log, and try each cond type once. Root-leveldata modify storage X {} ...is avoided (buttons copycondkey by key). condtypelevel,widget/sound,api/refresh,#guikit:on_close,internal/cd_notifyare new and untested beyondmecha .(which does not validate macro lines). Specifically unchecked in a real 26.3 client:experience query @s levelsstoring into a score,playsound ... ui @sargument order, theexecute unless function ...form used in thecd_notifyexample, and theon_closefiring on menu switch described above.all/anyconditions, buttoncost/poor, theguikit.dropdetection branch intick_player, chest boats / rafts and the hopperfolloware new and untested beyondmecha .(all 83 macro lines in the pack were also expanded with sample values and linted). Not checked in a real client: thecomp_steprecursion, thecndscratch storage,costcharging order (cond -> cost -> cmd), whether a boat can be ridden while the menu is open, and whether theminecraft.custom:minecraft.dropstatistic reacts to Q-dropping a widget out of the cart.- Still not done: behavior in a real game (
clear+custom_datamatch,summon, tick ordering, multiplayer). Only the load step has been observed. - The container registry (
internal/containers_builtin,summon,pad_cart_dyn,open_fail), theradio/meterwidgets and the container demos are new and untested beyondmecha .(which does not validate macro lines and is not a substitute for loading the pack; the macro lines were expanded with sample values and linted). Not checked in a real client: whether aCustomNamechanges the container screen title, the 5-slot hopper menu, theguikit.styledpad path (recursion inpad_step), theopen_failrollback, and the fullmeter_proberecursion.
Scratch state is wiped so one menu session cannot leak into the next.
| when | what | function |
|---|---|---|
menu closes (api/close) |
guikit:w, guikit:cond, guikit:ctx (menu, alias, ctype), guikit:p, guikit:mtr scratch keys |
guikit:internal/cleanup_player |
| end of every button click | guikit:btn cur |
guikit:internal/clear_btn_cur |
end of api/open |
guikit:ctx menu / alias |
inline |
every /reload |
all of the above + guikit:in + every transient fake-player score (#uid, #hit, #gui, #wcount, the #m* meter temps, ...) |
guikit:internal/cleanup_scores |
every /reload |
carts whose owner is gone (relog / death / uid lost) are disposed | guikit:internal/sweep_orphans |
Never touched: guikit:reg menus and guikit:btn defs (rebuilt by #guikit:register on load),
#next_uid / #version (uid counter must stay unique across reloads).
Two ordering traps this design avoids (both are easy to reintroduce):
api/opencallsapi/closein the middle of its own run, then readsguikit:in. Socleanup_playermust not clearguikit:in.- A button
cmdmay befunction guikit:api/close, andbtn_clickreadsguikit:btn curafter the command. Soapi/closemust not clearguikit:btn cur;btn_clickclears it itself at the end.
The cleanup_scores list was never exhaustive (progress's own #f/#i/#d/#f2/#abs aren't
in it either) -- every temp score is always overwritten before it's read, so this is hygiene on
/reload, not a correctness fix.
Reported: running /function cmddemo:open (now page 2 of demo:open) clears the player's inventory (it should
only ever remove a widget item after a GUI click).
The root cause was not identified by reading the code. Every clear in the pack is filtered by
custom_data~{guikit:{w:1b}}, none targets a plain inventory. Working hypothesis (unverified): in 26.3 the
*[custom_data~{...}] filter is not applied as expected, so clear @s *[...] wipes everything. That fits
"it happens right after open" because tick_player used to run an unconditional clear every tick.
What changed, regardless of the cause:
- The unconditional
clearevery tick is gone. Deletion now happens only after a count (clear ... 0) reports >= 1, and only throughguikit:internal/safe_clear(the same count-then-delete pattern was already used bywidget/probe,widget/button_probeandinternal/meter_hit-- the fix is specifically the old unconditionalclearat the end oftick_player/ insideapi/close). - Added
guikit:internal/selftest. Run it once in a real 26.3 world:/execute as @s run function guikit:internal/selftestPASS-> the filter works; the wipe has another cause (please sendlatest.logand the exact steps).FAIL - filter matches NON-widget items-> hypothesis confirmed; the*[custom_data~...]form must be replaced. Note:selftestusesgive, so test in a world where a stray stick is acceptable.
Not verified in a real game: everything in this section. mecha . passes, and every function guikit: reference
resolves, but per the validation notes above that does not prove the pack loads or behaves correctly.
guikit:widget/padalways stamps 27 slots -> do not use withhopper_minecart(5 slots).- If a menu draws a widget under
execute if score ... matches N run function guikit:internal/clear_w+... run data mergeand no range matches,guikit:wis empty andguikit:widget/drawfails on missing macro arguments instead of redrawing the previous widget. Initialize scores before drawing. name/loreare SNBT text components, not JSON strings:name:{text:"Bob's",color:"gold",italic:false},lore:[{text:"line",color:"gray",italic:false}],lore:[]. In 26.3 a quoted string such ascustom_name='{"text":" "}'is a plain string, so the tooltip shows the JSON text literally (that is what the pad panes did before this fix). Compound values are expanded into the macro as SNBT, so'in text needs no escaping. If a widget cannot be placed,widget/drawnow tells nearby playerscould not draw widget <id> in slot N.confirmis simplified (inguikit-demo) to a "click twice to confirm" flow instead of a separate page as in the source.condon a button is evaluated on every redraw (once per button) and again on click; keep conditions cheap.- The older
pay_itemhelper also counts the clicked widget item when it is the same item type as the price (clearruns while the widget is still in the inventory).condtypeitem_countsubtracts widget items.