Skip to content

jengine-tide

A survivors-like: thousands of enemies simulated in flat arrays over the engine’s shared broadphase, weapons that fire on their own clocks, and a level-up draft that can never offer nothing.

KindProject package: linked into the builds of the projects that list it
Sourcepackages/jengine-tide
Version0.1.0
Entry pointjengine_tide::TidePackage
Script namespaceTide
Listed byprojects/arcade-classics, projects/burnline, projects/shooter-classics

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

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

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 Tide == nil then return end.

Scene keyRust type
tide_runTideRunpackage only: needs a v2 scene
tide_stormTideStormpackage 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
Tide.new_run(e)tide_new_run
Tide.who(e, id)tide_who
Tide.choose(e, i)tide_choose
Tide.input(e, dx, dy)tide_input
Tide.storm_start(e, mode, seed, wave)tide_storm_start
Tide.storm_input(e, mx, my, ax, ay, fire, bomb)tide_storm_input
Tide.storm_idle(e)tide_storm_idle
  • projects/arcade-classics/assets/scenes/tide.scene
  • projects/shooter-classics/assets/scenes/prism.scene

From packages/jengine-tide/README.md.

A survivors-like: thousands of foes simulated in flat arrays over the engine’s shared broadphase, weapons that fire on their own clocks, and a level-up draft that can never offer nothing.

Used by projects/arcade-classics (Ember Tide) and, through its storm module, by projects/shooter-classics (PRISM STORM, a Geometry Wars: Retro Evolved).

ModuleWhat it is
contentHeroes, weapons, charms, foes, waves and evolutions as data tables. A run’s whole shape is rows.
draftThe level-up offer. Small, pure, and the single most able-to-ruin-a-run thing here.
runOne tick — the spawn director, the weapon clocks, and all the collision.
stormA second game on the same idea at a different speed: a neon twin-stick on a warping grid. See “The storm” below.

This is the arcade’s largest ECS-scale test, and the interesting part is what it does not put in the ECS.

Everything that exists by the thousand lives in flat Vecs in run: Vec<Foe>, Vec<Shot>, Vec<Drop>. The package then keeps a pool of drawable entities in step with those arrays — spawning when the pool is short, parking the surplus far off-screen — so the world holds about as many entities as are on screen rather than as many as are being simulated. Nothing crosses the script bridge per-foe; the game script never sees a foe at all.

Every drawable carries [BulkEntity], which keeps it out of the script mirror, the editor’s selection and scene snapshots. Without it, two thousand sprites would be hydrated into the script world every frame to show things no script addresses.

The pool is rebuilt from the simulation each frame rather than kept in step incrementally, because the arrays are compacted with swap_remove: an index is not stable across a single frame, so any bookkeeping tying an entity to a particular foe would be wrong the first time something died.

Salt Vaults moved a few hundred chasers across the script bridge and that was the expensive lesson. This is what was learned from it.

One broadphase, not a fourth copy of the idea

Section titled “One broadphase, not a fourth copy of the idea”

Every “what is near this” question goes through [jengine_core::ecs::spatial::UniformGrid] — core’s shared grid, the same one jengine-swarm flocks on. Two thousand foes against two hundred shots is four hundred thousand pairs a frame asked the obvious way, and about six thousand asked the grid.

It is used four times a tick: foe separation, shots against foes, an aura or arc sweep, and the hero’s own contact check. The separation walk is capped at a handful of neighbours — without a cap a dense clump is O(n) per foe and the whole thing is quadratic again, and with it a foe in a crowd is shoved by the nearest few, which looks identical.

Measured, not asserted. cargo test -p jengine-tide --release --test perf -- --ignored --nocapture on the development machine:

2000 foes, 36 shots: 0.601 ms/tick (1663 ticks a second)
500 foes: 0.130 ms/tick
2000 foes: 0.600 ms/tick
crowd x4.00 -> cost x4.60 (quadratic would be x16.0)

So the simulation is about 3.6% of a 16.6 ms frame at the hard ceiling, and four times the crowd costs four and a half times the work rather than sixteen. Everything converges on one hero, so the crowd is dense and that is the worst case for a spatial structure, not a flattering one.

The shot fixture uses an unrealistically deep pierce on purpose: with normal pierce a bolt fired into two thousand foes is spent within a frame or two, the field carries two live shots, and the shot-against-foe query

  • the thing most likely to be slow - is barely exercised. A flattering benchmark is worse than none.

Measured on the same machine with the display awake, in the native runtime, with the shipped tables’ every wave delivered at minute zero so the field reaches its designed maximum immediately:

[stress] wall=12.0 fps=165 frame=2.22ms alive=2328 ents=2389
[stress] wall=24.0 fps=165 frame=2.40ms alive=2328 ents=2389
[stress] wall=38.1 fps=165 frame=2.31ms alive=2328 ents=2389

2328 foes, 2389 entities, a steady 165 fps and ~2.4 ms a frame, flat for the whole run. 2328 is not an arbitrary number: it is the sum of the shipped timetable’s own alive ceilings, so it is the most this game ever intends to have on screen.

The 165 is the monitor’s refresh rate, so the honest figure is the 2.4 ms frame time - of which the simulation is 0.6 ms and the rest is the renderer, the script and the engine’s own tick. At 60 Hz that is about 14% of the budget.

One number in that log is worth explaining: 0 SHOTS. With two thousand foes packed around the hero, a bolt with single pierce is consumed on the frame it is fired, so the field genuinely carries no travelling shots. It is not a bug, and it is why the headless benchmark above has to force a deep pierce to exercise that path at all.

They are all quiet when broken. A duplicate looks like bad luck, a maxed item looks like a wasted pick, and an empty offer looks like the game has frozen — none of them throws.

  1. No duplicates in one offer. Three choices, not two presented as three.
  2. Nothing already maxed.
  3. Never empty. Late in a run everything can be at its ceiling, and an offer of nothing is a modal dialogue with no buttons. There is always a fallback (Offer::Heal, Offer::Coin), and a_draft_is_never_empty_even_when_everything_is_maxed holds it to that against a deliberately exhausted inventory.

There is also a slot cap. Without one, every draft can start something new, a player takes the shiny one every time, and the run ends with eight things at level one — which does less damage than three at level six and feels like bad luck rather than a bad choice. The cap is what turns the draft into a decision.

An evolution is earned rather than drafted: max a weapon while holding the right charm and the next level-up gives it to you instead of an offer. Anything that is the result of an evolution is left out of the draft pool entirely, which is the only thing stopping a player being handed the payoff without making the choices that lead to it.

  • Contact damage is per hit, not per second, and only the largest touching foe bills you. Summing thirty bodies makes a crowd instantly fatal and turns the genre’s central situation from pressure into a coin flip. Being surrounded is dangerous because you cannot leave.
  • Cooldown reduction is capped (MAX_HASTE). Uncapped it reaches zero, every weapon fires every frame, and the run stops being a game — the classic way a stacking stat ruins a build system.
  • Charms stack additively. Multiplicative stacking makes the order of picks matter, and nobody can do that arithmetic while being chased.
  • There is a hard ceiling on living foes (MAX_ALIVE). A timetable is allowed to want more than the machine can draw; the refusal belongs in one place rather than in the frame rate.
  • A bolt with nothing to shoot fires along the facing anyway. The alternative is a weapon that goes silently idle whenever the field is clear, which reads as broken rather than as a quiet moment.

tide_run on one entity, in a "version": 2 scene:

"tide_run": {
"tables": { "heroes": "data/tide_heroes", "weapons": "data/tide_weapons",
"charms": "data/tide_charms", "foes": "data/tide_foes",
"waves": "data/tide_waves", "evolutions": "data/tide_evolutions" },
"who": "warden", "seed": 5, "sheet": "atlases/tide.png", "view": 430.0
}
CallDoes
Tide.new_run(e)start over
Tide.who(e, id)choose a hero
Tide.input(e, dx, dy)one frame of intent; dy is positive up
Tide.choose(e, i)take offer i and resume

Scripts read TideState, TideHand, TideOffers and TideEvents. Deliberately not the foes — there are two thousand of them and the package draws them itself.

Two things are public that a package would normally keep to itself, and both for the same reason:

  • Run::kind_index — the foe kinds are ordered by Content::foes, which is a BTreeMap, so they come out alphabetical rather than in the order anybody wrote the table. A caller that assumes 0 is the first row gets the wrong foe, which is how two of these tests failed the first time they ran.
  • Content::check — a guard nobody has ever seen fire is not a guard.

storm is a Geometry Wars: Retro Evolved, and the same lesson as the tide at twin-stick speed: the ship, every foe, shot, geom, spark and Pacifism gate is a flat Vec in storm::sim, collided through UniformGrid, and drawn by the package - a pool of additive BulkEntity sprites for everything with a shape, and Mesh2Ds for the two things that come by the thousand. Nothing crosses the script bridge per foe. It is off unless a scene has a tide_storm component; tide_run and Ember Tide are untouched.

ModuleWhat it is
storm::contentThree data tables: foes (one row per kind; a kind is a behaviour - wander, seek, split, orbit, snake, rocket, hole - and numbers: speed, size, score, geoms, dodge, what it splits or bursts into), the endless modes’ director (rows by mode and at seconds: batch size, kinds by weight, formations, max_alive, speed, and Pacifism’s gate rate) and waves. StormContent::check refuses a row naming a kind or a formation that does not exist - a director pointing at nothing looks like a quiet stretch, not an error.
storm::latticeThe background grid: nodes held by their four neighbours (the grid Laplacian, so the flat grid is the rest shape and there are no rest lengths to tune) and weakly by home, stepped at a fixed 120 Hz. blast (explosions), wake (each shot, the ship’s engine) and pull (a black hole, held while it lasts). A node never strays past max_offset, so the grid dents but never folds.
storm::simOne run: three modes (Evolved: endless, lives and bombs at escalating scores; Waves: a fixed list cleared in turn; Pacifism: no gun, fly through gates), the twin-stick gun growing with time alive (twin, spread, back shots, the storm) and dropping a level on death, smart bombs, geoms that raise the multiplier, warp-in that is harmless and unhittable, a death that clears the field.
storm::pilotThe autopilot. It chooses only one of eight directions or standing still - exactly what W A S D can say - by looking at three horizons ahead (where each foe will be, a chaser closing on that spot), with walls, corners, geoms and the far side of a gate weighed in; it aims at the nearest worthwhile foe, led. The attract demo flies by it, and a test plays a whole game through it with real keys.
storm::drawThe sprite pool and the meshes: the grid is one Mesh2D (~1,700 quads, coloured by how far each node is from rest, so a dent glows), the sparks a few (a sprite-batch flush holds 8,192 quads; each mesh keeps well under), and the shockwave rings one more (a bomb’s, a death’s, a gate’s drawn at the reach that killed, a big kill’s). A black hole’s ring burns whiter and pulses harder as it fills, so an overfeed is seen coming.

tide_storm on one entity, in a "version": 2 scene:

"tide_storm": {
"tables": { "foes": "data/prism_foes", "director": "data/prism_director", "waves": "data/prism_waves" },
"art": "sprites/prism", "half": [610.0, 320.0], "cells": [40, 21]
}
CallDoes
Tide.storm_start(e, mode, seed, wave)a run of evolved, pacifism or waves, from wave wave (1-based; 0 is the first)
Tide.storm_input(e, mx, my, ax, ay, fire, bomb)one frame of intent: move and aim y-up, fire / bomb 0 or 1; a bomb is taken once
Tide.storm_idle(e)clear the field back to the bare grid

Scripts read StormState - phase, mode, score, mult, lives, bombs, weapon, wave, level, the ship, counts, and the pilot’s advice (adv_mx, adv_my, adv_ax, adv_ay, adv_fire, adv_bomb) - and StormEvents: this frame’s kills, big, fired, geoms, spawned, hits (counts, for sounds that come by the dozen) and list (start, death, respawn, bomb, life, bombup, weapon, burst, absorb, gate, wave, over, won) and pops, a flat x, y, points, base per big kill or gate (at most four a frame): where the points were made, for a score popup.

The effects have their own randomness. Sparks, rings and glints added for feel draw from a second stream (fx_burst), so a new effect never moves the rules’ random sequence: a seeded run spawns what it always spawned (effects_never_move_the_rules_random_sequence). The first effects still draw from the rules’ stream, as they always have; leave them there, or every seeded result moves.

The art is the game’s: <art>/<kind art>.png per foe plus ship, shot, geom, segment, ring, orb, beam, line (the grid’s profile across a segment) and spark. Textures the package names only at run time want preloading - PRISM STORM lists them in a sprite_library.

Numbers that read as mistakes:

  • A dodger jinks, it does not steer. A sidestep blended into the chase at the chase’s acceleration came too late for a shot at 1,150 units a second; while a shot is lined up the dodge overrides the chase.
  • Lives and bombs cost three times the last gap (100k, 400k, 1.3M…) and stock at nine: the multiplier runs into the hundreds, and a flat threshold handed out a life every few seconds.
  • The grid is stepped at a fixed rate, a few steps at most per frame. An explicit spring is stable only below a step size; and a long frame must not be simulated as hundreds of steps, which is also why a test that advances the lattice by eight seconds in one call sees a few frames.

cargo test -p jengine-tide — 41 for the tide plus 21 for the storm (storm.rs: the shipped tables, the grid, every behaviour, the rules of a run, and the autopilot playing Waves, Evolved and Pacifism), plus 2 benchmarks that are #[ignore]d because they assert on wall-clock time.

FileCovers
draft.rsthe three rules, the slot caps, taking, determinism, evolutions
run.rsthe director and its ceilings, weapon clocks, haste’s cap, piercing, contact and mercy, death, levelling, and two thousand foes as an ordinary number
tables.rsevery message Content::check can produce
storm.rsPRISM STORM, over the game’s shipped tables: the grid ripples, holds a black hole’s dent and settles; each behaviour (a dodger sidesteps a shot a seeker takes, splitters split, a snake’s tail eats shots, holes wake, pull, swallow and burst); geoms and the multiplier, death, bombs, lives by score, the gun’s growth, Pacifism’s gates, Waves’ progression; the pilot clearing Waves
perf.rswhat a tick costs, and that the cost grows with the crowd rather than with its square (--ignored, in release)