Skip to content

jengine-td

Tower defense: a maze the player builds, waves that path around it with the engine’s tilemap A*, towers and upgrades; or fixed roads with soldiers, a hero and spells.

KindProject package: linked into the builds of the projects that list it
Sourcepackages/jengine-td
Version0.1.0
Entry pointjengine_td::TdPackage
Script namespaceTd
Listed byprojects/arcade-classics, projects/strategy-classics

List it in the top-level "packages" array of project.jengine:

{
"packages": [
{ "name": "jengine-td", "source": { "type": "path", "path": "../../packages/jengine-td" } }
]
}

Then rebuild the editor viewport for the project (jengine viewport-build projects/<name>, or the editor’s Rebuild viewport banner). Native and web builds link it on their own.

A scene that uses its components must be a v2 scene ("version": 2): a v1 scene has no field for a package component and keeps it as inert custom data.

A script can check that the package is linked with if Td == nil then return end.

Scene keyRust type
td_gameTdGamepackage only: needs a v2 scene
td_roadTdRoadpackage only: needs a v2 scene

Commands run in every runtime and return nothing; a package publishes what scripts read onto components (see the notes below). The flat name also works.

CallFlat name
Td.new_game(e)td_new_game
Td.build(e, col, row, tower)td_build
Td.upgrade(e, col, row)td_upgrade
Td.sell(e, col, row)td_sell
Td.target(e, col, row, mode)td_target
Td.wave(e)td_wave
Td.speed(e, x)td_speed
Td.auto(e, on, delay)td_auto
Td.road_start(e, level, hero, seed)td_road_start
Td.road_stop(e)td_road_stop
Td.road_build(e, spot, family)td_road_build
Td.road_upgrade(e, spot, choice)td_road_upgrade
Td.road_sell(e, spot)td_road_sell
Td.road_rally(e, spot, x, y)td_road_rally
Td.road_hero(e, x, y)td_road_hero
Td.road_cast(e, spell, x, y)td_road_cast
Td.road_call(e)td_road_call
Td.road_speed(e, x)td_road_speed
Td.road_pause(e, on)td_road_pause
Td.road_auto(e, on, delay)td_road_auto
Td.road_advise(e, on)td_road_advise
  • projects/arcade-classics/assets/scenes/keep.scene
  • projects/strategy-classics/assets/scenes/bastion.scene

From packages/jengine-td/README.md.

Tower defense, two ways: waves that walk a maze the player builds (td_game), and Kingdom Rush’s model - fixed roads, build spots beside them, soldiers who hold the road, a hero and spells (td_road, “The road mode” below).

The maze is the point. A tower is built on the map itself, and the creeps walk the shortest way round it - found with the engine’s own tilemap A* (jengine_core::tilemap::find_path_on_layer). A tower that would seal the way off is refused, so there is always a road; everything else you build just makes the walk longer, and every extra second is another second under fire.

Used by projects/arcade-classics (Bramble Keep, the maze) and projects/strategy-classics (Hollow Bastion, the roads).

ModuleWhat it is
contentTowers, creeps and waves, read from the project’s data tables. A new tower is a row.
simThe game: the maze, the walk, the shooting, the money, the waves.
botA player that builds and upgrades by itself: attract modes, and the balance test.
roadThe road mode, with its own content, sim and bot.

The component goes on the entity that carries the tilemap. Whatever is already in the named layer is the map’s own rock - ground a tower cannot be built on and creeps have to walk around:

"td_game": { "tables": { "towers": "data/towers", "creeps": "data/creeps", "waves": "data/waves" },
"spawn": [0, 7], "goal": [27, 7], "blocked_layer": "rock",
"start_gold": 260, "start_lives": 20, "build_time": 20.0 }

Cell positions in what it publishes are tile coordinates, fractional for anything moving, so a script turns them into world positions with the tilemap’s own origin and tile size.

  • Tower rows: cost, damage, rate (shots a second), range (in tiles), splash, slow and slow_time, chain and chain_range, shot_speed (0 is a beam that hits at once), hits_air / hits_ground, upgrade (the row it becomes), and art, color, text for the game to draw and describe it with.
  • Creep rows: hp, speed (tiles a second), armor (taken off every hit, to a minimum of 1), reward, leak (lives it costs at the gate), flying (goes straight over the maze), heal and heal_range (mends others), splits_into and splits.
  • Wave rows: number, bonus, boss, text, and groups - each a creep, a count, the seconds between them and a delay before the first.

Loading checks every reference: a tower upgrading into nothing, a creep splitting into nothing, a wave sending a creep that is not there. All of it is an error at load (TdState.load_error), not a hole in the middle of a game.

  • Build phase between waves, with a clock. Calling the next wave early pays a gold for every second left on it.
  • Towers are bought, upgraded in place (the line is however deep the table makes it) and sold back for three quarters of what went in.
  • Targeting: first, last, strongest, weakest or closest, per tower.
  • A leak costs lives; at zero the keep falls. Clearing the last wave wins, and the lives left are worth score.
CallDoes
Td.new_game(e)start again
Td.build(e, col, row, tower)build one
Td.upgrade(e, col, row)upgrade the tower there
Td.sell(e, col, row)sell it
Td.target(e, col, row, mode)first, last, strongest, weakest, closest, or next
Td.wave(e)call the next wave early
Td.speed(e, x)run at x times speed
Td.auto(e, on, delay)the bot plays

A refused build says why in the refused event (and in TdState.error): the cell is taken, the gold is short, or it would seal the way.

TdState every frame: phase (build, wave, victory, defeat), wave, waves, lives, gold, score, build_left, speed, error, the current path as cells, and the lists:

  • creeps: id, kind, x, y, hp, max_hp, slow, flying, facing, flash
  • towers: id, kind, col, row, aim, level, target, kills, spent
  • shots: id, x, y, kind

TdLibrary once the tables load: the towers that can be built, each with its cost, what it does, and the whole upgrade line behind it - what a shop panel needs.

TdEvents.events: build, upgrade, sell, refused, wave_start, wave_end, fire (with beam and where it aimed), kill, split, leak, victory, defeat.

  • src/sim.rs: the road goes round the towers and may never be sealed; creeps walk it and cost lives at the gate; towers shoot and are paid; splash catches a crowd and a blob splits; fliers ignore the maze and only some towers reach them; the last wave won is a victory; calling a wave early pays; each targeting mode picks what it says.
  • tests/balance.rs, on the arcade’s real tables: every reference resolves and the waves grow; the bot gets deep into the run and wins only narrowly; a keep with no towers falls in the first waves; and the same game played twice goes the same way.

Kingdom Rush’s model beside the maze. The roads are fixed polylines - one or more, which may fork and merge (roads that share points share that stretch) - and towers go only on the build spots beside them. Positions are world units, so a road point is where it is drawn - Hollow Bastion paints its maps from the same points the package walks.

"td_road": { "tables": { "towers": "data/bastion_towers", "creeps": "data/bastion_creeps",
"heroes": "data/bastion_heroes", "spells": "data/bastion_spells",
"levels": "data/bastion_levels" } }

Nothing plays until a script starts a level.

  • Towers come in four familys - archer, mage, artillery, barracks - each a line of rows joined by upgrades: one upgrade for tiers 1 and 2, two at tier 3 (the specialisations). A row has cost (to build it or to upgrade into it), damage [min, max], kind (physical, stopped by armour; magic, stopped by magic resistance; true), rate (seconds between volleys), range, shot (arrow, bolt, shell fly in arcs; lightning strikes at once), shot_speed, arc, splash, shots (targets a volley), hits_air, and effects: crit_chance / crit_mult, poison / poison_time, slow / slow_time, chain / chain_range, instakill, armor_shred, stun_chance / stun_time. A barracks fires nothing: soldiers, soldier_hp, soldier_damage, soldier_rate, soldier_armor, soldier_respawn, soldier_regen and soldier_heal (mends the soldiers round it, even mid-fight).
  • Creeps: hp, speed, armor and magic_resist (0 to 1, the share shrugged off), reward, lives (lost at the gate), flying (nobody blocks it), damage / attack_rate (its melee against a blocker), regen, boss, and abilities on timers: spawn / spawn_count / spawn_every (calls others), heal / heal_range / heal_every, and stun_every / stun_range / stun_time (silences the nearest tower).
  • Heroes: hp, damage, rate, ranged and range (a ranged hero shoots what passes while nothing holds him), cleave, armor, speed, respawn, regen, cumulative experience levels, and hp_per_level / damage_per_level.
  • Spells: a meteor (count meteors of damage and radius scattered about the aim) and reinforce (soldiers who hold the road for duration), each with a cooldown.
  • Levels: number, gold, lives, paths, spots, hero (where he stands, and comes back), first_wait, gap (seconds after a wave has all come in before the next comes by itself), call_rate (gold a second left on the clock when a wave is called early), road_width, and waves - each a list of groups: creep, count, spacing, delay and path (-1 takes the roads in turn).

Loading checks every reference, that each family is a proper line (one first tower, upgrades one tier up within the family, at most two), and that every wave sends real creeps down real roads.

  • A creep walks its road by distance, a little to one side. A soldier (from a barracks, or a reinforcement) or the hero picks a ground creep near its post - an unheld one first, the one furthest along - walks to it and holds it: the creep stops and the two fight until one falls. A fallen soldier comes back from its barracks, the hero to his post. Fliers go over all of them.
  • Towers fire at whatever is furthest along within reach. Arrows and bolts follow their target; shells are aimed at where it will be and burst there.
  • The first wave comes by itself after first_wait, each later one gap seconds after the last has all arrived. Calling one early pays for the time left, and takes half of it off the spells’ cooldowns.
  • A sale gives back 60% of what went into a tower. Winning gives stars by the lives left: 3 for 90% or more, 2 for 30% or more, else 1.

Spots count from 1 here, as Lua does.

CallDoes
Td.road_start(e, level, hero, seed)start a level (a row id) with a hero (a row id)
Td.road_stop(e)end it (the state goes back to idle)
Td.road_build(e, spot, family)build the first tower of a family
Td.road_upgrade(e, spot, choice)upgrade into upgrade number choice (1, or 2 at the top)
Td.road_sell(e, spot)sell
Td.road_rally(e, spot, x, y)move a barracks’ rally point to the road nearest (x, y), within its reach
Td.road_hero(e, x, y)send the hero
Td.road_cast(e, spell, x, y)meteor or reinforce
Td.road_call(e)bring the next wave now
Td.road_speed(e, x), Td.road_pause(e, on)time
Td.road_auto(e, on, delay)the bot plays
Td.road_advise(e, on)the bot publishes its next move as advice without making it

TdRoadState every frame: phase (idle, ready, battle, victory, defeat), gold, lives, wave, waves, next_in, can_call, call_bonus, incoming (the roads the next wave takes, with their first points), next_kinds, next_text, stars, error, advice, and the lists creeps (position, health, armor, mr, flying, boss, fighting, facing, flash, slow, poison, stun), towers (spot counted from 0, kind, family, tier, range, rally_x / rally_y, fired - seconds since its last volley - stunned, sell and its upgrades), soldiers, shots (with h, the height above the ground, and angle), meteors, hero and spells. TdRoadLibrary once the tables load: every row, and every level’s roads and spots. TdRoadEvents.events: build, upgrade, sell, refused, wave, call, fire, hit (the damage dealt, crit, and by: a spot, -2 the hero, -3 a spell, -4 a soldier), boom, bolt (a lightning’s points), miss, kill, leak, summon, heal, stun, meteor, spell, rally, smite, hero_move, hero_strike, hero_level, hero_dead, hero_back, soldier_dead, victory, defeat.

road::bot::decide ranks the spots by how much road they see, and gives them families by what the level sends (fliers want arrows and magic, armour wants magic). It spends in buckets - three towers, their first upgrades, more towers, the third tier, the rest, the specialisations - saving for the next thing rather than buying something worse; it rains meteors on crowds and bosses, drops reinforcements in front of whatever is about to get through, sends the hero there, and calls the next wave once the field is clear. Two things tried and dropped, for whoever improves it: spending on every spot before any third tier lost more games, and a greedy “damage a second per gold” chooser lost more still - upgrades are worth more than they look.

  • src/road/sim.rs: creeps walk the road and cost lives; the first wave comes by itself and calling early pays; armour turns arrows and magic goes through; a soldier holds a creep until one falls, and comes back; fliers go over soldiers and shells; a shell bursts on a crowd; towers climb into two specialisations and sell for 60%; a rally point snaps to the road within reach; the hero walks, fights, levels up and comes back; spells wait on their cooldowns and reinforcements leave; bosses call, heal and silence; stars by the lives left.
  • tests/road_balance.rs, on Hollow Bastion’s real tables: every family climbs to two specialisations and every boss ability is used; the bot wins every level with both heroes over three seeds, with two stars or more on average; the hero alone loses every level; the same game goes the same way twice. TD_LEVEL=l5 TD_HERO=seren TD_SEED=21 cargo test -p jengine-td --test road_balance --release trace -- --ignored --nocapture prints one game wave by wave (TD_LEAKS=1 names every leak).