neofetch + falling sand engine for your terminal
Future improvements will focus on customizability.
Make sure to have Python installed.
Recommended Terminal Emulator: kitty
git clone https://github.com/datavorous/dunefetch
cd dunefetch
python -m venv .venv
source .venv/bin/activate
pip install .
dunefetch
dunefetch --help
dunefetch --help-controls
dunefetch --version
| Key(s) | Action |
|---|---|
| Arrow Keys | Move spawn cursor |
Space |
Place selected material |
1 |
Select SAND |
2 |
Select WATER |
3 |
Select WALL |
4 |
Select OIL |
5 |
Select FIRE |
6 |
Select PLANT |
7 |
Select STEAM |
8 |
Select WOOD |
p |
Pause/unpause simulation |
c |
Clear particles near cursor |
r |
Reset simulation |
q |
Quit |
There are 3 important modules:
cursu-> visual I/O, color, character renderingsand_core-> simulation logic, material specific behavioursand_utils-> grid setup, access helperselements-> pariticle definitions (name, symbol, color, index)
cursu is a small wrapper around python's curses to provide an easier api for drawing elements on the terminal, more info can be found here.
sand_utils handles grid creation, index validation, and get, set, swap cell values functionalities.
sand_core is the main heart. The class SandCore inherits the properites from SandUtils class.
It contains the update rules for each type of element, allows adding particles, and updating them.
+----------------------+
| Terminal Display | -> (cursu.py)
+----------------------+
| Particle Engine | -> (sand_core.py)
+----------------------+
| Grid Manager | -> (sand_utils.py)
+----------------------+
| Material Database | -> (elements.py)
+----------------------+The grid is the main data structure around which everything revolves around.
self.grid = [[EMPTY for x in range(width)] for y in range(height)]It is a 2D array of dimensions height x width; each cell is an integer containing the index number of the particle in ELEMENTS, viz.
EMPTY = 0
SAND = 1
WATER = 2Additionally there is a life state buffer used for managing fire life time,
self.life = [[0 for x in range(width)] for y in range(height)]Every tick/frame, the engine runs the following:
for y in reversed(range(height)):
for x in range(width):
update_cell(y, x)Bottom up traversal prevents particles (which go downwar) to directly teleport at the bottom. Similarly for the particles of type STEAM, we do top down traveral, to prevent teleporting directly at the top.
Each cell's type is checked, and corresponding update logic is run.
The system avoids out-of-bounds errors via is_valid(y, x) checks.
Each particle has a set of local rules based on its neighborhood:
neighbors = {
"below": (y+1, x),
"left": (y, x-1),
"right": (y, x+1),
"below_left": (y+1, x-1),
"below_right": (y+1, x+1),
}Then, based on the material type at y, x, we apply material-specific rules.
We'll take the example of SAND, WATER and FIRE here.
We check three neighbouring cells (below, below_left, below_right), if there is any EMPTY cell, we swap that with our sand particle.
if cell_below == EMPTY:
swap(y, x, y+1, x)
elif below_left == EMPTY:
swap(y, x, y+1, x-1)
elif below_right == EMPTY:
swap(y, x, y+1, x+1)This gives those natural looking piles or dunes.
These flow downward, then sideways, and seeks equilibrium (spreads horizontally). We added randomness to prevent gridlocked water. Additionally, checking column wise water level would be another idea to make it more natural, but we are yet to try that out.
if below == EMPTY:
swap(y, x, y+1, x)
elif left == EMPTY and right == EMPTY:
swap with random(left or right)
elif left == EMPTY:
swap(y, x, y, x-1)
elif right == EMPTY:
swap(y, x, y, x+1)Fire is perhaps the hardest one to implement. It burns for a few frames and spreads to flammable neighbors. Eventually leaves empty cell behind. The life time grid is updated accordingly.
def update_fire(self, y, x):
if self.life[y][x] <= 0:
self.set(y, x, EMPTY)
return False
for dy, dx in [(-1, -1), (-1, 1), (1, -1), (1, 1), (-1, 0), (1, 0), (0, -1), (0, 1)]:
# checking the neighbours
target = self.get(y + dy, x + dx)
if target == WATER:
# changing the particle type upon interaction
self.set(y + dy, x + dx, STEAM)
if target in (OIL, PLANT):
self.set(y+dy, x+dx, FIRE)
# giving fire a full life or a boosted one
self.life[y+dy][x+dx] = self.max_fire_life + 2
if target == WOOD:
if random.random() < 0.7:
self.set(y+dy, x+dx, FIRE)
self.life[y+dy][x+dx] = self.max_fire_life + 5
# wood burns longer hence this
# occasionally it can leap two cells, the more randomised the more chaotic
if random.random() < 0.0001:
dy, dx = random.choice([(-2,0), (2,0), (0,-2), (0,2)])
ny, nx = y + dy, x + dx
if 0 < ny < self.height-1 and 0 < nx < self.width-1:
if self.get(ny, nx) == EMPTY:
self.set(ny, nx, FIRE)
self.life[ny][nx] = self.max_fire_life
decay = random.randint(1, 2)
self.life[y][x] = max(0, self.life[y][x] - decay)
return TrueThe sand_core.py is pretty much self explanatory, do check out the code for a better grasp of the concepts.
Totally dependent upon keys(pressed) matching.
We had tried to develop a sand based game 4 years ago (but failed, unfortunately), and had watched/read these videos/articles:
-
MARF's Youtube Video :: How To Code a Falling Sand Simulation (like Noita) with Cellular Automata
-
Winterdev's Youtube Video :: Making games with Falling Sand part 1
-
John Jackson's Youtube Video :: Recreating Noita's Sand Simulation in C and OpenGL | Game Engineering
Some recent additions:
- Jason's Blog :: Adding fire to our falling sand simulator
