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.
| Kind | Project package: linked into the builds of the projects that list it |
| Source | packages/jengine-tide |
| Version | 0.1.0 |
| Entry point | jengine_tide::TidePackage |
| Script namespace | Tide |
| Listed by | projects/arcade-classics, projects/burnline, projects/shooter-classics |
Using it
Section titled “Using it”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.
Components
Section titled “Components”| Scene key | Rust type | |
|---|---|---|
tide_run | TideRun | package only: needs a v2 scene |
tide_storm | TideStorm | package only: needs a v2 scene |
Script commands
Section titled “Script commands”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.
| Call | Flat 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 |
Scenes that use it
Section titled “Scenes that use it”projects/arcade-classics/assets/scenes/tide.sceneprojects/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).
| Module | What it is |
|---|---|
content | Heroes, weapons, charms, foes, waves and evolutions as data tables. A run’s whole shape is rows. |
draft | The level-up offer. Small, pure, and the single most able-to-ruin-a-run thing here. |
run | One tick — the spawn director, the weapon clocks, and all the collision. |
storm | A second game on the same idea at a different speed: a neon twin-stick on a warping grid. See “The storm” below. |
Almost none of it is entities
Section titled “Almost none of it is entities”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.
What a tick actually costs
Section titled “What a tick actually costs”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.
And what it costs to draw
Section titled “And what it costs to draw”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=23892328 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.
The three rules of the draft
Section titled “The three rules of the draft”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.
- No duplicates in one offer. Three choices, not two presented as three.
- Nothing already maxed.
- 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), anda_draft_is_never_empty_even_when_everything_is_maxedholds 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.
The numbers that read as mistakes
Section titled “The numbers that read as mistakes”- 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.
Component and commands
Section titled “Component and commands”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}| Call | Does |
|---|---|
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 byContent::foes, which is aBTreeMap, so they come out alphabetical rather than in the order anybody wrote the table. A caller that assumes0is 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.
The storm (PRISM STORM)
Section titled “The storm (PRISM STORM)”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.
| Module | What it is |
|---|---|
storm::content | Three 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::lattice | The 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::sim | One 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::pilot | The 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::draw | The 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]}| Call | Does |
|---|---|
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.
| File | Covers |
|---|---|
draft.rs | the three rules, the slot caps, taking, determinism, evolutions |
run.rs | the director and its ceilings, weapon clocks, haste’s cap, piercing, contact and mercy, death, levelling, and two thousand foes as an ordinary number |
tables.rs | every message Content::check can produce |
storm.rs | PRISM 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.rs | what a tick costs, and that the cost grows with the crowd rather than with its square (--ignored, in release) |