Where's the code?
Sorry, no public repository yet! The engine code is a WIP and isn’t ready for distribution, and the game repository has at least one copyrighted asset. I’ll share the relevant snippets for now.
Like every year, the subreddit /r/roguelikedev is organizing its summer tutorial series. The goal is to follow a Python-based roguelike tutorial in 8 parts — one per week — and have a playable game at the end of it. Obviously you can use whatever tech stack you’d like and not follow the tutorial at all. It kind of acts as a gamejam with mutual emulation and weekly reports from participants. For many, it’s a great summer side project. The announcement post is here.
I’ll try to keep up, but it’s the third of fourth time I’m doing it, and each time I either lose interest or fall of the bandwagon because I’m straying too far from the source material and end up with 0 ideas to wrap up in time… Anyway.
Week 0 - Preparation
Since my favorite hobby is to make half baked engines instead of games, I actually already have everything on hand. I’ll make the game in TypeScript, with a <canvas>
element. No rust this time, I want something as frictionless as possible, to focus only on the code (and not on some build issue). I also want to iterate quickly.
So I have this pico8-slash-love2d-inspired library that’s absolutely ready for something as graphically simple as a roguelike.
Week 1 - Draw the @
and move it around
So far so good.
I bought the tileset “Oh no, more goblins!” a few weeks ago, for an unrelated prototype, so I’m going for something prettier than a simple @
. The CRT shader I took from here helps a lot, too.
Look at them little arms full of chromatic aberrations 📺
I’ve already over-engineered some things, but I’ll probably rewrite the actor/entities/composition code a few times before settling on something that kinda works.
export class Position extends Component {
constructor(
public x: number,
public y: number
) {
super()
}
}
export class Sprite extends Component {
constructor(
public spritePos: [number, number],
public color: Color
) {
super()
}
public draw() {
const { x, y } = this.actor.getComponent(Position)
E.draw.sprite(this.spritePos, x * 12, y * 12, E.FLIP.Horizontal)
}
}
class Hero extends Actor {
constructor() {
super()
this
.addComponent(new Position(1, 1))
.addComponent(new Sprite([15, 8], Color.Green)
)
}
}
A full ECS is probably overkill, and I don’t want to use inheritance for actors, so I’ll go with a more classical composition pattern, where each component is responsible to update()
and draw()
itself.
Web build - Week 1
There’s not much to do yet, but you can try the “game” here.
Week 2 - The dungeon
First, a bit of refactoring
Actually, that Actor
thing sucked, so I’ve already refactored that part to use my in-house ECS library.
// Component factories
export const CmpSprite = Component<{ sprite: [number, number]; color: Color }>()
export const Position = Component<{ x: number; y: number }>()
export const IsPlayer = Component()
// Create a player entity
export function makePlayer(): Entity {
return world.spawn(
CmpSprite({ sprite: Sprite.Goblin, color: Color.White }),
Position({ x: 1, y: 1 }),
IsPlayer()
)
}
// Render drawable entities
export function sysDrawSprites(world: World) {
for (const [_e, sprite, pos] of world.query([CmpSprite, Position])) {
E.draw.setColor(Color.White, sprite.color)
E.draw.sprite(sprite.sprite, pos.x * 12, pos.y * 12, E.FLIP.Horizontal)
}
E.draw.resetPalette()
}
Level generation
The algorithm isn’t too complicated, but gives interesting results:
- Fill the map with wall tiles, and randomly place a few non-overlapping rooms.
- Create a weighted graph of our map with the following rules (adapt the values as needed):
- Dungeon outer walls are impassible
- Rooms walls are
100_000
- Floor tiles are
1_000
- Other walls are a random pick of
[1, 1, 100, 100_000]
- Use a pathfinding algorithm (in this case, A-star) to find a corridor’s path from the center of a room to the next
- Dig a tile out of this corridor
- Go to 2
The weightings used to make the graph force the corridors to only dig into rooms when necessary (they prefer to go around them), but merge with existing paths when possible. The random values make them a bit wobbly for a more organic look. To make the rooms less blocky, I also fill a few corners at random without blocking the path. This algorithm has a tendency to create dead-ends, but it should be easily mitigated by adding a straight path between one or two pairs of close rooms.
I also started implementing doors, but they don’t open yet ¯\(ツ)/¯
Web build - Week 2
This week’s version is playable here. Use F5 to get a new dungeon, and F6 to toggle the CRT shader.
Week 3 - Field of view (FoV) and enemies
For the Field of View (and Fog of War), I’m quite fond of the “symmetric shadowcasting” algorithm, as I’ve written on it before, and the original python code is relatively easy to translate into any other language.
And for the enemies, Instead of goblins and trolls (our hero is already a goblin), let’s spawn bats and snakes.
export function makeSnake(x: number, y: number): Entity {
return world.spawn(
Name({ name: 'Snake' }),
IsEnemy(),
IsBlockingTile(),
CmpSprite({ sprite: Sprite.Snake, color: Color.Green }),
Position({ x, y }),
)
}
Tile bitmasking
I feel like I’m becoming an expert on tile bitmasking. Each time I implement it (and I think it’s the 4th time, at least), I refine my existing code.
Depending on the surrounding cardinal (N S W E) and diagonal (NW NE SW SE) neighbors, there are 47 possible relevant combinations for every tile in your dungeon. So if you want to be thorough, you need 47 different sprites to represent all those possibilities. But if you don’t, having 16 different sprites is just enough. And conveniently enough, you can use CP437 characters to represent them all.
// Spritesheet references
const walls = {
'╣': [0, 20],
'║': [1, 20],
'╗': [2, 20],
'╝': [3, 20],
'╚': [4, 20],
'╔': [5, 20],
'╩': [6, 20],
'╦': [7, 20],
'╠': [8, 20],
'═': [9, 20],
'╬': [10, 20],
'╥': [11, 20],
'╨': [12, 20],
'╞': [13, 20],
'╡': [14, 20],
'█': [15, 20],
} as const
And then you can map them to the 47 combinations, with duplicates where applicable.
const masks: Record<number, Readonly<[number, number]>> = {
0b01111111: walls['╔'],
0b00111101: walls['═'],
0b10111111: walls['╗'],
0b10001010: walls['╔'],
0b01000110: walls['╗'],
0b00001010: walls['╔'],
// etc.
A bit tedious, but 100% worth it.
Bumping into enemies
First, a system to keep an up-to-date list of who is where:
export function sysRefreshMapOccupations(world: World) {
map.emptyOccupiedTiles()
for (const [e, pos] of world.query([Position])) {
map.occupyTile(pos.x, pos.y, e)
}
}
And then it was just a matter of checking the destination before moving. I added this small method on my map instance:
public canbeBumped(x: number, y: number): boolean {
// Not a wall, but something else that can be interacted with (a door or another entity)
return !this.isWall(x, y) && (this.occupiedTiles[this.xy_idx(x, y)].length > 0 || this.isDoor(x, y))
}
🤔 I’m still debating if I should use entities for doors, instead of treating them as special dungeon tiles…
So, what happens when bumping into something? I’m using a small PubSub object to dispatch an event: eventBus.publish(Event.Bump, { pos })
. Usually I’m not a fan of this pattern, as it can make it difficult to follow the logic, but the alternatives were spaghetti code that does everything, or “event components” passed through the ECS which would add another layer of indirection.
Web build - Week 3
This week’s version is playable here. You can now open doors and bump into enemies.
Week 4 - Fighting and UI
- Implement a camera
- Add a message log
- Add a HP bar
- Add a minimap
- Display information when hovering an entity with the mouse
- Rework the RNG module from my engine
- Copy the combat logic from Brogue
- Semi-random movement for the bats
- The snakes follow
- Take damage
- Proper death (= the game doesn’t crash when the player dies)
Writing hard, checklists easy.
I’ll try to complete the tutorial, but will certainly stop the official schedule of 2 parts per week here. This is taking too much of my time and I have other priorities ✌
Web build - Week 4
This week’s version is playable here