River Raid (Activision, 1982) is a vertical shooter in which you fly up a river that never seems to end, shoot boats, helicopters and bridges, and fly over fuel depots before your tank runs dry. I wanted to rebuild it for the browser with a river generated as you fly. Shooting turned out to be the easy part. The river wasn’t, and I tried several ways to make it.
This page is a log of those tries, reconstructed from the code, the commit history and the planning documents in the repository. The game itself is a prototype, and the river is the part I spent time on.
How the original did it#
River Raid was written for the Atari 2600, a console with 128 bytes of RAM, and the whole game had to fit in a 4 KB cartridge. A long river map doesn’t fit in that, so the river isn’t stored at all. A pseudo-random number generator, a linear-feedback shift register, computes the course on the fly, and every step of the generator decides how the next piece of river looks: how wide it is, which way it turns, whether an island splits the water.
The trick is the fixed seed. The generator always starts from the same value and produces the same sequence in every game, so the river is random by construction and identical each time you play. Players learn the course like a hand-made level. Sections of the river end with a bridge, and bridges work as checkpoints. The console draws background graphics as one half mirrored onto the other, so left and right banks are symmetric as well.
That’s the bar I wanted to reach: an endless river with no dead ends, and a lane at least three tiles wide to fly through, as my planning documents put it.
Starting with a plan#
The first commit is from the afternoon of 13 May 2025, and docs/notes.md still has what I started from, a list of things the game needs and a short description: “Player will flight the plane, across the river (generated randomly) and shoot to the enemies (placed randomly), grab the powerups.” The same file has my sketch of how I’d name the tiles, a 3 by 3 block with NW, North and NE on top, West, Ground and East in the middle and WS, South and SE at the bottom, with water around it. Those names ended up as the RiverTile enum in the code.
The planning documents describe a world made of screen-sized chunks. A ChunkManager creates them ahead of the camera and recycles them behind it. A RiverGenerator draws a path through each chunk, a BridgePlacer and an EntitySpawner fill it with bridges, enemies and fuel, and a CameraFollower triggers all of it. The rules are written down too: the plane lives in the lower third of the screen, one hit kills, fuel drains over time, and the score is 100 for an enemy, 500 for a bridge and something for distance. A test is planned that checks that the river always leaves a lane at least three tiles wide.
The task list for the second phase has 16 items, and 5 of them are ticked: the player, the weapon system with a bullet pool, the 16 by 16 tile set, the tile map rendering and the river generator for tile output. The enemies, the bridges that take several hits, fuel, the collision rules, the HUD and the tests are all unticked. The chunk manager was never written. I ended up with a single buffer of rows that scrolls.
Attempt 1: the game skeleton in Excalibur.js#
My first experiment was a game skeleton in TypeScript on the Excalibur.js engine, which brings a game loop, rendering, an entity-component system and scenes. By the evening of the first day the repository had a player, shooting, a tile map that scrolled smoothly and collision with the banks.
The canvas is 480 by 720 pixels and fits the screen. Tiles are 16 by 16, so the map is 30 columns wide and 46 rows tall, 45 visible rows plus one extra that is used for the smooth scroll. The tile images come from one sprite sheet with 6 rows and 16 columns. Scrolling is 30 pixels per second, so a new row of the river is needed about every half a second.
Smooth scrolling is a small trick. A camera position moves a little every update. A RiverManager holds the visible rows in an array, and whenever the camera has crossed a whole tile it throws away the bottom row and asks the generator for a new one, which it adds at the top. In between, the tile map is moved by the fractional part of the camera position, and the camera is reset when it lands on a whole tile. After every new row, the code clears and redraws all 30 by 46 cells of the tile map.
Collision with the banks is built from the same rows. For every row, the code finds the water tile at the east edge of the left bank (WaterE) and the one at the west edge of the right bank (WaterW), and takes the last of each in that row. Then it joins the points from two rows with an EdgeCollider, one for the left bank and one for the right, each wrapped in an invisible actor. The loop runs over 45 pairs of rows, so there are at most 90 of these, and all of them are destroyed and recreated whenever a new row arrives. Rows that have no such tile get no collider, which is a hole in the shore.
Touching the shore isn’t a crash right now, though. The function that is supposed to restart the game starts with a bare return, so the collider fires and nothing happens. That line was added in the commit with my first wave function collapse attempt, so I think I switched crashes off to watch generated rivers without dying all the time. It’s still there.
The player is a 32 by 32 sprite with a circle collider of radius 6, clamped to the lower third of the screen. The speed is set to 300 pixels per second, but the movement code divides the frame time by 2000 instead of 1000, so the plane actually moves at half of that. The weapon system is a pool of 50 bullets created at start. A hidden bullet is one with its opacity at zero, firing means finding the first hidden one, and the cooldown is 0.2 seconds with Space or Z held down. Bullets fly at 600 pixels per second and collide with nothing, because there’s nothing to hit yet.
The game still starts with a splash screen that looks like the one from the Excalibur template, with its Play button hidden and a 1.5 second timer. The repository has jest and Playwright set up. Its nine test files, one per planned system, were moved into docs/mockup during the first night, and a later cleanup deleted that folder.
Attempt 2: wave function collapse#
Wave function collapse (WFC) is a popular way to generate tile maps. You give the algorithm a sample, it learns which tiles may stand next to which, and it fills an empty map where every cell starts as “any tile”. The most constrained cell is collapsed to one tile and the choice is propagated to the neighbours, until the map is full.
I tried it twice, in the early hours of 14 May. A commit from 00:20 is a placeholder that flips water and ground from one row to the next. At 00:42 comes the first real attempt, with a commit message that says the river is “now it is not OK”.
That first version reads the rules from a hand-made 5 by 5 sample: a ring of water around a 3 by 3 block of ground tiles, numbered 1 to 9. For each tile it stores four edges, and an edge is either water or ground. From those it builds lists of which tiles may stand north, south, east and west of which. To make a new row, each cell first gets the tiles that fit under the tile above. Then the constraints are propagated left and right until nothing changes, and the loop collapses the cell with the smallest number of options, picking at random, and propagates again until every cell has one tile. The river manager called it for every new row. The commit message says how that went. The same commit has the return in the crash handler, which I mentioned before.
I think the trouble is that each row sees only the row above it. Nothing knows the shape of the whole river, so a row can be perfectly valid and the river still has no continuous channel and no lane wide enough to fly through.
The next two commits, at 03:43 and 04:02, are titled “was not good, cleaned version” and “cleaned version #2”. The first removes about 100 lines of the edge-and-propagation code and, for a while, replays the sample matrix as the start of the river. The second brings in the generator that’s in the repository today. It’s a one-dimensional WFC adapted from a public gist, and it’s tiny: 87 lines. The same commit changes the scroll speed constant from 0 to 30, so scrolling was off before it.
The sample for the final version is a matrix of 6 rows and 15 columns, with 14 tile types: water, the nine pieces of the 3 by 3 block and four inner corners. From it a helper learns two things. One is which tile may stand next to which in a row, and in the code a pair seen in one order is also allowed in the other. The other is how often each tile appears, which becomes its weight.
A new row is filled from left to right. Every cell starts with all tiles, the first cell is narrowed to the tiles that may stand next to the tile above it in column zero, and then each cell picks a tile at random, weighted by frequency, from what’s still allowed. The choice limits the options of the next cell. The code throws an error if a cell has no options left. The random numbers come from Math.random(), so unlike the original there’s no seed, and every flight is a different river.
Only the first cell of a row is tied to the row above, so the banks jump from row to row, the water doesn’t form one channel, and nothing guarantees a flyable lane. A full two-dimensional WFC would keep the banks continuous, but it would still only know about neighbours and never about the shape of the whole river. After this I changed direction.
Attempt 3: plan the path, then dress it with tiles#
So I turned the problem around, to decide first where the river goes and only then worry about which tiles to draw. This experiment is a Python tool with a small window that generates an endless river row by row and lets me watch it scroll. The source of this tool isn’t in the TypeScript repository, so what follows comes from my earlier notes on this page and from a screenshot.

The picture shows what it makes. The banks are stepped, with a darker strip of earth along the water and grass behind it, and the river bends and changes width. A small rectangular island sits in the middle of the water, in the lower part.
The course is planned in patches. An A* search finds a path through the grid from one point to the next, so the river always continues and never dead-ends. The path is widened into a river of varying width, and sharp corners are filleted to make the banks look natural. Sometimes an island is left in the middle.
Every land cell then gets its tile from a rule table. The program looks at the 3 by 3 neighbourhood, which neighbours are water and which are land, and picks the matching edge or corner tile. Eight neighbours give 256 combinations in principle, and a tile set has only a few edge and corner tiles (the Excalibur prototype has 13 land tiles), so many neighbourhoods have to share a tile.
Writing that table by hand was tedious, so I made the tool help with the job. You click a tile and cycle through the tile types with Space until the spot looks right, and press G to print a new rule for exactly that neighbourhood. Later I ported the generator to Godot (GDScript) with the same rules and the same editing keys. This was the first version whose rivers looked like rivers.
Where it is now#
The river generator works in Python and in Godot, and the TypeScript game has the weaker WFC version of the river plus the player, bullets and collisions that go nowhere. The two lines of work haven’t met. The Excalibur code doesn’t use the path-and-rules generator yet.
Enemies with health, bridges that take several hits, fuel and scoring are designed down to class names and haven’t been built. The newest commit in the repository, from 19 October 2025, adds docs/notes.md and removes older planning files and mockups.