It's a roguelike (made during r/roguelikedev does the complete roguelike tutorial 2021)! It's written in WASM, with a TypeScript embedder!
- Input has to be grabbed by TypeScript (WASM has no DOM access) and there's no easy way to pass strings between the two execution contexts, so the key code is transmitted as an integer. I'm using the VK constants that
wglt
thoughtfully provides. - Pandepic dared me to write an ECS. That will give me some memory layout things to think about.
The preprocessor (src/build/preprocess.ts
) tries to be reasonably intelligent about how it transforms and understands the underlying WASM:
(global)
either defines a constant or a global variable.(local)
and(param)
define local variables.(func)
forgets all defined local variables.
Preprocessor commands:
-
[[eval expression]]
runsexpression
in JavaScript and returns the result as(i32.const whatever)
. Also aliased as[[= ]]
. -
[[eval64 expression]]
is the same thing but results in ani64
. Also aliased as[[=64 ]]
. Thinking of refactoring this (maybe[[= type ...]]
?) -
[[string expression]]
is similar but it expects the result to be a string. It stores it in the string table and returns an offset to the data. -
[[strings]]
returns the content of the string table for filling a(data)
. -
[[consts prefix start names...]]
defines an enumerated set of(global)
s. It also defines_Next
as one higher than the largest defined constant. -
[[struct name field:type...]]
defines a memory structure. It also definessizeof_name
. -
[[reserve name amount export]]
saves the position of the data pointer in a(global)
then moves the data pointeramount
ahead.export
is optional. -
[[align size]]
aligns the data pointer with the given size, or 4. -
[[data struct field=value...]]
constructs a string representing the givenstruct
with its values filled in. Useful in(data)
s. -
[[memory export]]
defines a(memory)
big enough to fit all reserved space.export
is optional. -
[[load pointer struct.field]]
reads a structure field usingpointer
as the start of the structure. -
[[store pointer struct.field value]]
writes a structure field usingpointer
as the start of the structure. -
[[component name field:type...]]
is like[[struct]]
but it also defines a mask constant and functions to check presence, get, attach and detach components from entities. -
[[attach entity struct field=value...]]
attaches a component to a given entity (saves you having to remember the field order). -
[[system Name component...]]
generates two functions:sysName()
which runs the system on all matching entitiesdoName(id, component...)
which runs the system on one entity
It is ended by
[[/system]]
, which closes the function body fordoName
.
[[string]]
relies on the following environment:
$Strings: i32
[[component]]
and [[system]]
rely on the following environment:
[[struct Entity mask:i64 ...]]
$getEntity(eid:i32): i32
$maxEntities: i32
The preprocessor's parsing mechanism is custom and weird. It shouldn't get in the way. It's okay to put commands inside other commands. It's also okay to put WASM code as command arguments, at least sometimes.
-
Entity:
i64
mask (bit mask of component presence, so up to 64 components possible)Might extend this with another
i64
later if I need more components. Maybe for Tag components? -
Component: array of data specific to component
-
System:
sys*
functions
My memory layout is dynamic because my preprocessor handles it. Here's what is in the current build.
Start | Size | Description |
---|---|---|
0 | 16 | (savefile start) |
16 | 256*8 | Entity data |
2064 | 256*10 | Appearance data |
4624 | 256*3 | AI data |
5392 | 256*1 | Carried data |
5648 | 256*4 | Consumable data |
6672 | 256*2 | Equipment data |
7184 | 256*3 | Equippable data |
7952 | 256*8 | Fighter data |
10000 | 256*1 | Inventory data |
10256 | 256*7 | Level data |
12048 | 256*2 | Position data |
12560 | 32*4 | Room data |
12688 | 100*100 | TileMap |
22688 | 100*100 | VisibleMap |
32688 | 100*100 | ExploredMap |
42688 | 100*100 | PathMap |
52688 | 105*100 | message log |
63188 | - | (savefile end) |
63188 | 1000 | Strings |
67188 | 100 | temp string |
67288 | 20 | temp (itoa) |
67308 | 100 | temp (hover) |
67408 | 100*100*2 | dijkstra queue |
87408 | 3*19 | Tile types |
87465 | 3*2 | Items per floor |
87471 | 4*2 | Monsters per floor |
87479 | 7*3 | Item chances |
87494 | 5*3 | Monster chances |
86424 | - | - |
So, my data (minus the display memory) currently fits in two WebAssembly memory pages (64kB each).
Oh yeah, I was doing a log. The tutorial is all done now. This deserves a fuller write up at some point. :D
I forgot to write logs until now. All the way up to part 10 done! Whole bunch of refactors:
- display code moved to its own file and manages its own memory
- removed Action/Result structs, they weren't doing anything useful
- WASM requests a refresh from the frontend when needed now
- fixed some bugs in the preprocessor and made it a bit smarter
- refactored interface code a tiny bit
- added a favicon :D
Added message count tracking, the cursor hover thing and history viewer. That's part 7 done!
Thought about implementing a proper allocator but put it off in favour of statically allocating enough room for messages (hopefully). Finished message log.
Finally done with part 6! There were a lot of technical things to do here and I'm almost certain I solved them in the worst possible way.
Only slightly late, started on combat (part 6). Refactored action code a bit because it was annoying me.
Forgot to write yesterday, but I added FOV (tutorial part 4) and started doing monster spawning (part 5). Today I finished that off and added a Solid "tag" component that prevents movement. Also, now it will clear the visible/explored/entity memory when generating a new dungeon.
I finished writing the ECS! I also moved rendering into the WASM, so now the interface doesn't have to give any info except for the display memory and the input function. It still does though, useful for debugging.
Week 3 has started but I'm not quite ready for it yet. I want to start using an ECS now so that later changes will be easier. In preparation, I... turned on TypeScript strict mode to give me something else to do.
Done with part 2 and part 3 now. For speed, I'm passing in a wrapper of JavaScript's Math.random
call for the RNG, but I should probably replace that with a pure WASM solution at some point. Still no signs of an ECS!
Fixed my parser but I'm not using it yet. Instead, looked at part 2 of the tutorial and implemented a few things. I also saw this post and decided to use it. Might even be able to give the engine a direct memory access somehow! Anyway, I don't have an ECS yet. Will have to fix before I get too far into the tutorial.
Spent hours today trying to write my own programming language parser and failing.
Decided to start writing a readme and this log. I wrote the rest of this repo yesterday. Initially, I was using AssemblyScript but I decided that was just too easy considering I've already made a roguelike in pure TypeScript. The only tools I'm using are rollup to bundle my modules (though redblobgames said they use esbuild
recently) and wat2wasm to compile my text-format WASM into the binary format that browsers use. Completing parts 0 and 1 of the tutorial was fairly simple after I wrote my embedder (check src/interface.ts
). I expect this interface to change dramatically over time, especially as I'm pretty new to WASM, though I have written Z80 ASM and Forth code before (relevant as a stack machine).
Later, I refactored the code to separate input and actions, much in the tutorial style. However, I'm doing it by manually poking bytes. :)