Minecraft Education

Ocean Heroes

A Minecraft Education world developed with UNESCO's Intergovernmental Oceanographic Commission, with three full minigames engineered as modular TypeScript systems instead of the usual sprawl of mcfunctions and JSON.

Client
UNESCO-IOC & Voice of the Ocean Foundation
Year
2025
Role
Lead Developer & Co-Designer
Made by
Shapescape
Ocean Heroes cover

Ocean Heroes is a Minecraft Education world I developed at Shapescape with UNESCO’s Intergovernmental Oceanographic Commission and the Voice of the Ocean Foundation. It asks students aged 11 to 15 to repair three real marine ecosystems: a coral reef overrun by crown-of-thorns starfish, a kelp forest stripped bare by sea urchins, and a polluted mangrove coast. The design brief is its own story. This one is about the part players never see: the code that holds it together.

Most Bedrock add-ons grow as a sprawl of mcfunctions and loosely related JSON that quietly accrete until no one dares touch them. Ocean Heroes could not work that way. It had to ship to two storefronts, survive a real classroom (paused mid-task, restarted by a substitute teacher, replayed out of order), and keep receiving updates long after launch. So I treated it as a software project, not a map.

1.5M+ Downloads
3 Full minigames
20+ Modular systems
2 Storefronts, one codebase

One world, more than twenty systems

The world is split into self-contained systems, each owning everything it needs. minigame_coral_crisis holds its own entities, items, dialogue, sounds, and scripts. So do abort_mission, diving_goggles, celebration, tutorial, and around twenty others. A build step (Nusiq’s system_template filter, run through Regolith) merges them into a single behaviour and resource pack, resolving collisions declaratively so two systems can both contribute to the same generated file without a manual merge. Regolith itself is an open-source build tool for Bedrock add-ons: it pipes the source through a chain of filters like system_template and writes out the finished packs.

The map below is the exact build shape: each chip is a real system folder in the workspace, and each arrow is a build handoff rather than runtime coupling.

Build · system_template
  • minigame_coral_crisis
  • minigame_kelp
  • minigame_mangroves
  • main_ship
  • diving_goggles
  • abort_mission
  • celebration
  • tutorial
  • trophies
  • cutscene
  • npcs
  • warp
  • +11 more
system_template + esbuild via Regolith
  • Behaviour pack
  • Resource pack

Each system owns its own entities, items, dialogue and scripts. One build step merges all 23 into a single pack, resolving file collisions declaratively.

Reading it left to right mirrors maintenance: edit one system, run one build, ship one coherent pack.

Build note 01

Modules over a monolith

Every system is a folder I can read, reason about, and delete in isolation. A small map file per system declares where its files land in the final pack and what to do on conflict: append, replace, or leave alone. The payoff is boring in the best way. Adding the third minigame did not mean touching the first two.

Game logic in TypeScript, not mcfunctions

Each system’s logic is TypeScript. esbuild bundles every system’s entry point into one script per namespace, with @minecraft/server marked external because Minecraft provides it at runtime. That gives me real modules, real types, and a real editor instead of a maze of mcfunctions.

The graph below is the dependency spine for Coral Crisis. It starts at main.ts, fans into mission modules, then reaches shared packages only where they are needed.

Module graph · Coral Crisis
your code modules npm packages main.ts main.ts coral-crisis.minigame coral-crisis.minigame services/ services/ repositories/ repositories/ enums · interfaces enums · interfaces entity-utils-module entity-utils-module @minecraft/server @minecraft/server @shapescape/storage @shapescape/storage @shapescape/space @shapescape/space bedrock-boost bedrock-boost

Hover a module to trace what it imports. @minecraft/server stays external; everything else esbuild bundles into one file.

I use this shape as a guardrail: if a service starts importing unrelated modules, the graph gets noisy immediately.

A minigame is a state machine. Coral Crisis moves through a fixed set of states:

// One value per phase of the mission; the world is always in exactly one.
export enum CoralCrisisState {
	BRIEFING,
	STARTING,
	STARFISH_HUNT,
	FRAGMENTS_COLLECTING,
	CORAL_PLANTING,
	BACK_TO_BOAT,
	COMPLETING,
}

A single system.runInterval(..., 1) ticks the active minigame once per game tick, and one switch decides what “one tick” means for whichever state the world is in:

// Runs once per tick: dispatch to the handler for the current state.
public tick(force?: boolean): void {
	if (!this.gameStateService.isRunning() && !force) return;

	switch (this.gameStateService.getMinigameStatus()) {
		case CoralCrisisState.STARFISH_HUNT:
			this.handleStarfishHuntState(force);
			break;
		case CoralCrisisState.FRAGMENTS_COLLECTING:
			this.handleFragmentCollectingState(force);
			break;
		case CoralCrisisState.CORAL_PLANTING:
			this.handleCoralPlantingState(force);
			break;
		// ...
	}
}

The command side of the world (an NPC dialogue button, a structure trigger, an mcfunction firing when a player clicks something) talks to the code through script events. A dialogue option runs scriptevent shapescape:coral_crisis start_hunt, and one typed handler routes everything:

// The bridge: dialogue and mcfunctions fire script events; this routes them.
private onScriptEvent(event: ScriptEventCommandMessageAfterEvent): void {
	if (event.id === "shapescape:coral_crisis") {
		switch (event.message) {
			case "start":
				this.areaService.loadArea();
				this.spawnNPCs();
				this.startGame();
				break;
			case "start_hunt":
			case "dialogue_done":
				this.startHunt();
				break;
			// ...
		}
	}
}
Build note 02

A state machine per mission

Bedrock has no built-in notion of “which part of the level the player is in right now.” The enum is that notion, made explicit and saved. The same shape is reused across all three minigames, with a KelpsState and a MangrovesState of their own, which made the second and third missions mostly a matter of filling in handlers rather than reinventing the loop.

Services, not scripts

The service map on the right mirrors constructor order: inputs at the top, the minigame orchestrator in the middle, persistent storage at the bottom.

Inside the mission, behaviour is split into focused services: timer, dialogue, play area, entities and game state. Each owns one concern, and nothing else needs to know how it works.

Composition · CoralCrisisMinigame
system.runInterval · every tick scriptevent · dialogue & mcfunctions
CoralCrisisMinigame
World storage · CachedStorage

Hover a service to see what it owns. Each takes its dependencies in the constructor, so the minigame only has to orchestrate them.

The minigame’s constructor wires them together once, passing each its dependencies explicitly:

// Wire the minigame together: storage, then repositories, then services.
private constructor() {
	const minigameStorage = new CachedStorage(world, "coral-crisis-minigame");
	const runtimeStorage = minigameStorage.getSubStorage("runtime");

	const starfishRepository = new StarfishRepository(
		minigameStorage.getSubStorage("starfish"),
	);
	const fragmentRepository = new FragmentRepository(
		minigameStorage.getSubStorage("fragment"),
	);

	this.gameStateService = new GameStateService(minigameStorage, runtimeStorage);
	this.dialogueService = new DialogueService();
	this.timerService = new TimerService(runtimeStorage, this.minigameSettings);
	this.areaService = new AreaService(minigameStorage, this.minigameSettings);
	this.starfishService = new StarfishService(
		starfishRepository,
		this.minigameSettings,
		this.DEBUG,
	);
	this.fragmentService = new CoralFragmentService(
		fragmentRepository,
		runtimeStorage,
		this.minigameSettings,
	);
	this.eventService = new EventService();

	this.registerEvents();
}
Build note 03

Compose services, reuse across missions

Each service does one thing and takes its dependencies as arguments, so I can reason about the timer without ever thinking about starfish. It also means the handlers in tick() stay short: they orchestrate services instead of doing the work themselves. When the third minigame needed a different win condition, that was a change to one service, not a hunt through a thousand-line file.

Remembering the world

A classroom world gets reloaded constantly. Chunks unload, the host leaves, a teacher restarts the session halfway through. Anything that lives only in memory is gone the moment that happens, so every piece of progress is written to persistent, world-backed storage. A state change is just a read and a write, and nothing outside the service knows or cares how it is stored:

// Status lives in storage, not memory, so it survives a world reload.
public getMinigameStatus(): CoralCrisisState {
	return this.runtimeStorage.get("status") ?? CoralCrisisState.BRIEFING;
}

public setMinigameStatus(status: CoralCrisisState): void {
	this.runtimeStorage.set("status", status);
}

For the things there are many of, like the dozens of coral fragments waiting to be planted, I wrapped storage in a repository that hashes a block position into a key and keeps a hot copy in memory, so the per-tick loop is not constantly reaching for disk:

// A block position becomes a stable string key, cached after the first read.
private getLocationKey(location: Vector3): string {
	const blockLocation = Vec3.from(location).toBlockLocation();
	return `${blockLocation.x}|${blockLocation.y}|${blockLocation.z}`;
}

getFragmentByLocation(location: Vector3): CoralFragment | undefined {
	const key = this.getLocationKey(location);
	if (this.cache.has(key)) return this.cache.get(key);
	const fragment: CoralFragment = this.storage.get(key);
	if (fragment) this.cache.set(key, fragment);
	return fragment;
}
Build note 04

Persist state, cache reads, budget ticks

The loop runs every tick, but not everything needs to. Cheap checks happen every tick; the expensive ones, like recomputing the nearest target or refreshing the on-screen timer, are gated behind a frame counter so they run a few times a second instead of twenty:

// Cheap checks run every tick; the expensive work only every 5th.
private handleStarfishHuntState(force?: boolean): void {
	this.starfishService.updateStarfishes(true);

	if (system.currentTick % 5 === 0 || force) {
		this.timerService.updateTimer();
		if (this.timerService.getTimeLeft() === 0) this.fail();
		// ...nearest-target work...
	}
}

That single modulo is part of the difference between a world that runs smoothly on a school laptop and one that stutters.

The design the code had to serve

None of this is architecture for its own sake. The design leans hard on letting players fail safely: you can abandon any mission at any moment and return to the ship with no penalty, and the world has to put itself back exactly as it was. Progress is the reef itself recovering, not a number going up. Both of those only hold together if the world reliably remembers and restores its own state, which is the entire job of the storage layer.

The cleanest payoff is the last one. The same codebase ships to both the Minecraft Marketplace and Education Edition, and a single runtime check is all it takes to tell which one it is running in:

// The camera command exists only in Education Edition: try it, catch the failure.
export function isEduEdition(): boolean {
	try {
		world
			.getDimension("overworld")
			.runCommand("give @p[tag=shapescape_unobtanium] minecraft:camera");
		return true;
	} catch (e) {
		return false;
	}
}

The camera command only exists in Education Edition, so attempting it and catching the failure is a reliable, dependency-free way to branch behaviour without maintaining two versions of the world by hand.

Ocean Heroes shipped to the global Minecraft Education catalogue in 2025, and it kept shipping: the modular structure meant updates and fixes could land without destabilising the rest of the world. The lesson I took from it is that “it’s just a Minecraft map” is exactly the assumption that sinks an ambitious one. Treating it as software is what let three minigames, two editions, and a classroom’s worth of edge cases coexist.

I talked about Ocean Heroes, and about what it takes to turn Minecraft into a teaching tool, in an interview with Enter, Loading (June 2026, in Italian).