Stackflow supports mods through the Godot Mod Loader (GML). This guide shows you how to build a mod that adds new blocks and hooks into existing game logic — no core files edited.
If you've never used GML before, read the official Mod Developer wiki first. This document covers the parts that are specific to Stackflow.
- Quick start (no Godot needed)
- Examples
- API reference
- How mods load
- Mod folder structure
- The manifest
- Your mod entry script
- Adding a new block
- Changing core blocks
- Hooking existing logic
- Textures
- Packing & publishing to the Steam Workshop
You only need a text editor and the game.
- Open Stackflow → Mods (bottom-right of the main menu) → Open folder. This opens
mods-source/next to the game executable. - Copy
Stackflow-TestBlock/from this repo into it and rename itYourName-YourMod(also updatenamespace/nameinmanifest.json). - Edit
mod_main.gd, drop PNGs intextures/(plain PNGs work — no import step). - Back in the Mods screen, click Pack: your folder is zipped into
mods/YourName-YourMod.zip. - Restart the game. Your mod shows under Loaded mods (red = failed; details in the log, see below).
- When it works, click Publish to upload it to the Steam Workshop.
Logs: every load error is written to modloader.log in the game's user data folder (%APPDATA%/Stackflow/logs/ on Windows, ~/.local/share/Stackflow/logs/ on Linux).
Each folder in this repo is a complete mod: copy one into mods-source/ and click Pack.
| Mod | Shows |
|---|---|
Stackflow-TestBlock |
Minimal new block (Ruby) with its own PNG, translations, and a script hook. Start here. |
Stackflow-FuseBlock |
Per-instance state: set_on_place starts a Timer that charges the block every second. |
Stackflow-CrowBlock |
Reacting to other blocks: set_on_block_destroyed, plus a recipe gate. |
Stackflow-VanillaTweaks |
Changing core blocks: edit metadata and wrap a vanilla effect. |
API.md lists every class, method, property, signal and enum a mod is meant to use (BaseBlock, PlacedBlock, GameManager, GameData, ...), with their doc comments. Anything not listed there is internal and may change between game versions.
API.md is generated from the game's source with Godot's doctool:
python3 tools/generate_api.py --game ../stackflow --godot godotAt boot Stackflow registers all of its core blocks through a single public API (BaseBlock). Every block — core or modded — lives in one place: the BlockRegistry autoload.
The boot order that matters to you:
BlockRegistryautoload loads (the central store).CoreBlocksautoload runs and registers all vanilla blocks.- When done,
CoreBlockssetsBlockRegistry.core_blocks_ready = trueand emits theBlockRegistry.blocks_readysignal. - Godot Mod Loader runs your mod, which reacts to that signal and registers your content.
Why wait for the signal? By registering after the core blocks exist, your mod can safely add, override, or extend vanilla blocks without racing the core.
A mod is a folder inside mods-source/ (next to the game executable). Inside the packed zip it lives under mods-unpacked/, which is the path your code sees at runtime:
mods-source/
└── YourName-YourMod/
├── manifest.json # required metadata
├── mod_main.gd # required entry script
└── textures/ # your PNGs (optional)
└── ruby.png
The folder name must be Namespace-Name and match the namespace + name in your manifest (e.g. Stackflow-TestBlock).
manifest.json describes your mod to GML. Minimal Stackflow example:
{
"name": "TestBlock",
"namespace": "Stackflow",
"version_number": "1.0.0",
"website_url": "https://github.com/stackflow",
"description": "Registers a new block (Ruby) and hooks PlacedBlock.",
"dependencies": [],
"extra": {
"godot": {
"authors": ["YourName"],
"compatible_mod_loader_version": ["7.0.0"],
"compatible_game_version": ["1.3.0"],
"incompatibilities": []
}
}
}namespace+nameform your mod id (Stackflow-TestBlock). Keep it unique.compatible_game_version— the Stackflow version(s) you tested against.dependencies— other mod ids you require.
mod_main.gd extends Node and is instanced by GML. Two lifecycle points:
_init()— install script hooks here (they must be registered before the target script runs)._ready()— register your blocks here, gated on theblocks_readysignal.
extends Node
const MOD_ID := "YourName-YourMod"
func _init() -> void:
# Hooks must be added before the hooked script executes.
ModLoaderMod.add_hook(_my_hook, "res://scripts/placed_block.gd", "execute_destroy_effect")
func _ready() -> void:
# blocks_ready guarantees the core blocks already exist.
if BlockRegistry.core_blocks_ready:
_register_blocks()
else:
BlockRegistry.blocks_ready.connect(_register_blocks)Important: always guard with the
core_blocks_readycheck and the signal connect. If your mod loads late the signal may have already fired — the boolean handles that case; otherwise the connect handles the normal case.
Blocks are code-driven — no .tres resources. You build a BaseBlock and it auto-registers itself in the BlockRegistry the moment you construct it.
BaseBlock.new(id, groups, min_amount, max_amount, recipe = [], price = 0, unlock_round = 0)| Param | Type | Meaning |
|---|---|---|
id |
StringName |
Unique id. Namespace it ("yourmod.ruby") to avoid collisions. |
groups |
Array[GameData.BlockGroups] |
Synergy groups (e.g. MINERALS, CASINO). |
min_amount / max_amount |
int |
How many of this block a piece can contain. |
recipe |
Array[StringName] |
Unlock requirement — the block only appears in the Block Reward once the player already owns at least one of these block ids in their original deck. Empty = no requirement, always eligible. See Recipe. |
price |
int |
Cost in the Block Selection screen (0 = free). |
unlock_round |
int |
Round the block becomes eligible (0 = from the start). |
Each setter returns the block, so they chain. All are optional:
set_destroy_effect(func(ctx: DestroyEffectContext) -> void)— the block's own effect when cleared by a line.ctx.blockis thePlacedBlock;ctx.payloadis aDictionaryfor extra data.set_on_place(func(block: PlacedBlock) -> void)— per-instance setup when the block is placed (e.g. start a Timer).set_on_block_destroyed(func(owner: PlacedBlock, destroyed: PlacedBlock) -> void)— observer fired whenever any block is destroyed, for blocks that react to others.
func _register_blocks() -> void:
if BlockRegistry.has_block(&"yourmod.ruby"):
return # already registered
var ruby := BaseBlock.new(&"yourmod.ruby", [GameData.BlockGroups.MINERALS], 3, 5)
ruby.texture_path = ModLoaderMod.get_unpacked_dir() + MOD_ID + "/textures/ruby.png"
ruby.set_destroy_effect(func(ctx: DestroyEffectContext) -> void:
GameManager.add_points(50)
PointNotification.create_and_slide(ctx.block.get_center_position(), PointNotification.BLUE, 50)
)That's it — the block now shows up in rolls and runs its effect on line clear.
recipe controls when a block becomes eligible to appear in the Block Reward, based on what the player already has. It is not a crafting recipe.
The rule is a logical OR: the block is only offered once the player owns at least one of the listed block ids in their original deck. If the list is empty there's no requirement and the block is eligible from the start (subject to unlock_round and price).
# Only offered once the player already runs iron OR bronze.
var ruby := BaseBlock.new(&"yourmod.ruby", [GameData.BlockGroups.MINERALS], 3, 5, [&"iron", &"bronze"])Use it to gate synergy blocks behind their partners — the reward stays hidden until owning it makes sense.
Note that recipe is one of several eligibility gates. A block is offered only when all of these pass: max_amount > 0, current_round >= unlock_round, the player can afford price, the recipe requirement (above) is met, and — for blocks with more than one group — the player already owns a block from at least one of those groups.
Block texts come from translation keys built from the upper-cased id: BLOCK_<ID> and BLOCK_<ID>_DESCRIPTION (yourmod.ruby → BLOCK_YOURMOD.RUBY). Register them at runtime in _ready(); English is the fallback for languages you don't provide:
var english := Translation.new()
english.locale = "en"
english.add_message("BLOCK_YOURMOD.RUBY", "Ruby")
english.add_message("BLOCK_YOURMOD.RUBY_DESCRIPTION", "Gives [points:+50] when destroyed.")
TranslationServer.add_translation(english)Descriptions support the game's tags: [points:+50], [multiplier:+10], [group:minerals], and colors such as [yellow]text[white].
Inside your blocks_ready handler, BlockRegistry.get_block(&"iron") returns the live core block. Change its fields directly (price, min_amount, max_amount, recipe, ...) or extend its effect by wrapping the current one, so other mods' changes are kept:
var stone: BaseBlock = BlockRegistry.get_block(&"stone")
var previous_effect: Callable = stone.get_destroy_effect()
stone.set_destroy_effect(func(ctx: DestroyEffectContext) -> void:
previous_effect.call(ctx)
GameManager.add_points(5)
)To change behavior of a core method (not just add a block), use script hooks. Stackflow's core scripts use class_name, so you must use ModLoaderMod.add_hook (not install_script_extension).
func _init() -> void:
ModLoaderMod.add_hook(
_on_execute_destroy_effect,
"res://scripts/placed_block.gd",
"execute_destroy_effect"
)
func _on_execute_destroy_effect(chain: ModLoaderHookChain) -> void:
var block := chain.reference_object as PlacedBlock
if block and block.type == "yourmod.ruby":
print("Ruby about to run its destroy effect")
chain.execute_next() # <-- REQUIRED: runs the vanilla method + other mods' hooksAlways call
chain.execute_next()or the original method (and every other mod's hook) never runs.chain.reference_objectis the instance the method was called on.
Common hook targets in Stackflow:
res://scripts/placed_block.gd→execute_destroy_effect— every block's clear effect.res://scripts/moving_piece.gd— piece movement / placement.res://scripts/board.gd— board-level logic.
Point a block's texture_path at a PNG inside your mod folder:
ruby.texture_path = ModLoaderMod.get_unpacked_dir() + MOD_ID + "/textures/ruby.png"get_unpacked_dir() is the res://mods-unpacked/ path your zip is mounted at. Plain PNGs work as-is (no Godot import step); block sprites are 12×12 pixels. You can also reuse a core sprite ("res://images/blocks/default/red.png") if you don't ship your own art.
Everything happens in the in-game Mods screen; you never build the zip by hand.
mods-source/
└── YourName-YourMod/
├── manifest.json
├── mod_main.gd
├── textures/ruby.png
├── preview.png # optional: Workshop thumbnail (under 1 MB, not packed)
└── workshop.json # created on first publish: keeps your item id (not packed)
- Pack builds the zip GML expects (
mods-unpacked/YourName-YourMod/...inside) and installs it inmods/for local testing. - Publish packs, then uploads to the Workshop. The first publish creates the item (title and description come from
manifest.json) and opens its page in the Steam overlay: accept the Workshop agreement there if Steam asks, then edit the description, tags and visibility. Later publishes update the same item (viaworkshop.json) and only replace the files; the change note is yourversion_number. - Players subscribe on the Workshop page; Steam downloads the item and the game loads it on the next launch.
Remove your local
mods/YourName-YourMod.ziponce you're subscribed to your own Workshop item, or the mod loads twice.
You can still share the zip from mods/ directly: players drop it into their own mods/ folder.