This is a reimplementation of the classic game ‘hunt’ from the BSD Games collection. Players maneuver through a top-down view of a destructible maze, trying to stay alive and kill each other.
We’re going to start with a design fairly close to the original. Our server will be turn-based – when no inputs arrive, the action stops. Our clients (for now) will be dumb – they send input events to the server and receive draw events.
def [[_width, _height], [tIn, tOut]] := activateTerminal(stdio)
object outputHandler:
to draw(row, col, txt :Bytes):
tOut.move(row, col)
tOut.write(txt)
to drawMultiple(drawList):
var lastCol := 0
var lastRow := 0
for [col :Int, row :Int, txt :Bytes] in (drawList):
if (!(col == (lastCol + 1) && row == lastRow)):
tOut.move(row + 1, col + 1)
tOut.write(txt)
lastCol := col
lastRow := row
to message(msg :Str):
tOut.move(HEIGHT, 0)
tOut.write(UTF8.encode(msg, throw))
tOut.eraseRight()
to quit():
tIn.quit()
endR.resolve(0)
This client-side code drives the terminal. Hunt’s needs are pretty simple; this object provides methods for writing to locations on the display. A `drawMultiple` method allows coalescing adjacent writes efficiently. The `endR` resolver signals completion to the top level when done.
def player := gameController.createPlayer(outputHandler, "Player", 1, null)
object stdinResponder as Sink:
to run([kind, content]):
return if (kind == "KEY" && keyMapping.contains(content)) {
M.sendOnly(player, keyMapping[content], [], [].asMap())
} else if (kind == "DATA") {
var ps := []
for b in (content) {
def s := _makeStr.fromChars(['\x00' + b])
if (keyMapping.contains(s)) {
M.sendOnly(player, keyMapping[s], [], [].asMap())
}
}
}
to complete():
endR.resolve(0)
to abort(problem):
endR.smash(problem)
The client’s input listener decodes keys and maps them to event names such as “faceLeft”, “fire”, etc. Note that it’s using `sendOnly` here, and so will work the same whether `player` is a reference to a local object or one hosted remotely. Again, `endR` is used to signal normal or erroneous disconnection from stdin.
def p0 := tIn.flowTo(stdinResponder)
Ref.whenBroken(p0, traceln.exception)
tOut.enterAltScreen()
tOut.clear()
tOut.hideCursor()
player <- start()
return when (end) -> {
stdio.stdout().complete()
tOut.leaveAltScreen()
tOut.showCursor()
traceln(`Press any key to exit. Sorry.`)
end
} catch p {
tOut.leaveAltScreen()
tOut.showCursor()
traceln.exception(Ref.optProblem(end))
traceln.exception(Ref.optProblem(p0))
traceln.exception(Ref.optProblem(p))
1
}
Finally we plumb all our IO pieces together. `tIn` is an object wrapping stdin that decodes terminal escape sequences into key names such as `F1`, `RIGHT_ARROW`, etc. `tOut` provides methods emitting terminal escape codes such as `move`, `insertLines`, `eraseRight`, and so forth. We set up the display and signal the player object representing the user that we are ready to begin.
The `end` value is a promise that resolved by `endR` above, and here we perform either a clean exit or displaying error info as needed.
<<digMaze>>
<<smoothMaze>>
<<makeMaze>>
The maze is generated by carving tunnels through the gameboard. A starting point is picked and tunnels are dug in random directions. After the structure is determined, the walls are adjusted to look right. Our current implementation builds a map of [x, y] coordinates to cell contents for convenience.
def mazeArray := [for _ in (0..!HEIGHT) ([b`#`] * WIDTH).diverge()]
digMaze(entropy, mazeArray,
entropy.nextInt(WIDTH),
entropy.nextInt(HEIGHT))
remap(entropy, mazeArray)
def mazeMap := {
def pairs := [].diverge()
for y => row in (mazeArray) {
for x => b in (row) {
pairs.push([[x, y], b])
}
}
_makeMap.fromPairs(pairs)
}
The starting space is dug, directions are selected randomly, then two steps are
taken in the direction chosen.
def digMaze(entropy, maze, x, y) as DeepFrozen:
def [NORTH, SOUTH, EAST, WEST] := [1, 2, 4, 8]
def order := [NORTH, 0, 0, 0].diverge()
maze[y][x] := SPACE
for i in (1..!4):
def j := entropy.nextInt(i + 1)
order[i] := order[j]
order[j] := 1 << i
for i in (0..!4):
def [tx, ty] := switch (order[i]) {
match ==NORTH {[x, y - 2]}
match ==SOUTH {[x, y + 2]}
match ==EAST {[x + 2, y]}
match ==WEST {[x - 2, y]}
}
If an existing tunnel or the edge of the board is hit, end this one.
if (tx < 0 || ty < 0 || tx >= WIDTH || ty >= HEIGHT):
continue
if (maze[ty][tx] == SPACE):
continue
Otherwise dig out the space between the previous location and the current one. Continue the tunnel at the current space.
maze[(y + ty) // 2][(x + tx) // 2] := SPACE
digMaze(entropy, maze, tx, ty)
Look at all non-wall tiles and categorize them by what wall tiles are adjacent.
def remap(entropy, maze) as DeepFrozen:
def [NORTH, SOUTH, EAST, WEST] := [1, 2, 8, 16]
for y in (0..!HEIGHT):
for x in (0..!WIDTH):
if (maze[y][x] == SPACE):
continue
var stat := 0
if (y - 1 >= 0 && maze[y - 1][x] != SPACE):
stat |= NORTH
if (y + 1 < HEIGHT && maze[y + 1][x] != SPACE):
stat |= SOUTH
if (x + 1 < WIDTH && maze[y][x + 1] != SPACE):
stat |= EAST
if (x - 1 >= 0 && maze[y][x - 1] != SPACE):
stat |= WEST
Tiles with only east and west neighbors get `-`, tiles with only north and south neighbors get `-`, and all others get `+`.
if ([WEST | EAST, EAST, WEST].contains(stat)):
maze[y][x] := WALL1
else if ([NORTH | SOUTH, NORTH, SOUTH].contains(stat)):
maze[y][x] := WALL2
else if (stat == 0):
#ifdef RANDOM
# maze[y][x] := DOOR
#ifdef REFLECT
maze[y][x] := if (entropy.nextBool()) {WALL4} else {WALL5}
else:
maze[y][x] := WALL3
def makeMaze(entropy) as DeepFrozen:
<<generateMaze>>
def damageTiles := [].asMap().diverge()
return object maze:
to get(x, y):
if (damageTiles.contains([x, y])):
return SPACE
return mazeArray[y][x]
<<mazeCollide>>
<<mazeRender>>
The maze object consists of the generated “permanent” contents and a list of damaged tiles representing walls shot down. It provides a `get` method so `maze[someX, someY]` will return the current contents of that cell, taking damage into account.
def makeItemStocker(maze, entropy) as DeepFrozen:
def items := [].asMap().diverge()
return object itemStocker:
to restockMines():
while (true):
def x := entropy.nextInt(WIDTH)
def y := entropy.nextInt(HEIGHT)
if (maze[x, y] == SPACE):
items[[x, y]] := GMINE
break
while (true):
def mx := entropy.nextInt(WIDTH)
def my := entropy.nextInt(HEIGHT)
if (maze[mx, my] == SPACE):
items[[mx, my]] := MINE
break
<<itemsCollide>>
<<itemsRender>>
The `itemStocker` manages pickup items, like mines and boots.
def makeShotTracker(gameboard, theEventArena, _entropy) as DeepFrozen:
def shots := [].asSet().diverge()
return object shotTracker:
to remove(shot):
shots.remove(shot)
to add(var x, var y, type, direction, charge, owner):
var live := true
object shot:
to getKind():
return "shot"
to getCoords():
return [x, y]
to getCharge():
return charge
<<singleShotRender>>
to hitMaze():
shots.remove(shot)
live := false
to move():
<<shotMove>>
theEventArena.add(shotMove)
shots.include(shot)
shot.move()
<<shotCollide>>
<<shotsRender>>
The `shotTracker` manages `shot` objects, which represent every type of moving projectile.
def makePlayer(eventArena, outputHandler, name, team, enterStatus):
# stplayer, answer.c
var x := entropy.nextInt(WIDTH)
var y := entropy.nextInt(HEIGHT)
var flying := 0
var cloak := 0
var scan := 0
var score := 0.0
var numBoots := 0
var damage := 0
var damageCap := MAXDAM
var ammo := ISHOTS
var ncshot := 0
# if (enterStatus == "FLY"):
# flying := entropy.nextInt(20)
# flyX := 2 * entropy.nextInt(6) - 5
# flyY := 2 * entropy.nextInt(6) - 5
# else if (enterStatus == "SCAN"):
# scan := gameController.numPlayers() * SCANLEN
# else if (enterStatus == "CLOAK"):
# cloak := CLOAKLEN
while (maze[x, y] != SPACE):
x := entropy.nextInt(WIDTH)
y := entropy.nextInt(HEIGHT)
var face := MOVEGRID.getKeys()[entropy.nextInt(3)]
def player
def move(tx, ty):
<<playerMove>>
eventArena.add(moveEvent)
def faceTo(o ? (MOVEGRID.getKeys().contains(o))):
<<faceEvent>>
eventArena.add(faceEvent)
def statChar():
return if (flying > 0) {
"&"
} else if (scan > 0) {
"*"
} else if (cloak > 0) {
"+"
} else {
" "
}
def showExpl(ey, ex, type):
null
def fire
object inputHandler:
to start():
null
to moveUp():
move(x, 0.max(y - 1))
to moveDown():
move(x, (HEIGHT - 1).min(y + 1))
to moveLeft():
move(0.max(x - 1), y)
to moveRight():
move((WIDTH - 1).min(x + 1), y)
to faceUp():
faceTo(b`^`)
to faceDown():
faceTo(b`v`)
to faceLeft():
faceTo(b`<`)
to faceRight():
faceTo(b`>`)
to shot():
fire(0)
to grenade():
fire(1)
to quit():
return outputHandler <- quit()
def playerViewArray := [for _ in (0..!HEIGHT) ([b` `] * WIDTH).diverge()]
var viewUpdates := [].asMap().diverge()
var viewFilter := [].asSet()
def rotate([x, y]):
return [[x.abs() ^ 1, y.abs() ^ 1],
[-(x.abs() ^ 1), -(y.abs() ^ 1)]]
def seeNearby():
def localCoords := [
[x, y],
[x + 1, y],
[x - 1, y],
[x, y + 1],
[x, y - 1],
[x + 1, y + 1],
[x + 1, y - 1],
[x - 1, y + 1],
[x - 1, y - 1]
]
return [for [sx, sy] in (localCoords)
? (inMazeBounds(sx, sy)) [sx, sy]].asSet()
def canSeeOver(tile):
return ![DOOR, WALL1, WALL2, WALL3, WALL4, WALL5].contains(tile)
def see([dx, dy]):
var sx := x + dx
var sy := y + dy
def cells := [].diverge()
while (inMazeBounds(sx, sy)):
cells.push([sx, sy])
def [[lx, ly], [rx, ry]] := rotate([dx, dy])
def leftX := sx + lx
def leftY := sy + ly
def rightX := sx + rx
def rightY := sy + ry
if (inMazeBounds(leftX, leftY)):
cells.push([leftX, leftY])
if (inMazeBounds(rightX, rightY)):
cells.push([rightX, rightY])
if (!canSeeOver(maze[sx, sy])):
break
sx += dx
sy += dy
return cells.asSet()
bind player:
to getKind():
return "player"
to getInputHandler():
return inputHandler
to damage(amount):
damage += amount
to look():
def [left, right] := rotate(MOVEGRID[face])
for dir in ([MOVEGRID[face], left, right]):
viewFilter |= see(dir)
viewFilter |= seeNearby()
<<playerRender>>
to updateViewmap(tilemap):
def points := viewFilter# | tilemap.getKeys().asSet()
for point in (points):
def [x, y] := point
def tile := tilemap.fetch(point, fn {
maze[x, y]})
if (playerViewArray[y][x] != tile):
viewUpdates[point] := tile
playerViewArray[y][x] := tile
to pushViewChanges():
outputHandler <- drawMultiple([for [x, y] => tile in (viewUpdates) [x, y, tile]])
viewUpdates := [].asMap().diverge()
to getScore():
return score
to statusLine():
def scoreS := M.toString(score)
return `${scoreS.slice(0, scoreS.size() - 4)}${statChar()}$name $team`
bind fire(var weaponN):
while (weaponN >= 0 && ammo < shotCost[weaponN]):
weaponN -= 1
if (weaponN < 0):
outputHandler <- message("Not enough charges.")
return
if (ncshot > MAXNCSHOT):
return
shotTracker.add(x, y, shotCost[weaponN], face, null, player)
# ncshot += 1
# if (ncshot == MAXNCSHOT):
# outputHandler <- draw(STAT_GUN_ROW, STAT_VALUE_COL, b` `)
# ammo -= shotCost[weaponN]
# outputHandler <- draw(STAT_AMMO_ROW, STAT_VALUE_COL,
# UTF8.encode(`$ammo`, throw))
# addShot(shotType[weaponN], y, x, face, shotCost[weaponN], player,
# false, face, liveShots)
# underShot := true
# showExpl(y, x, shotType[weaponN])
# gameController.refreshPlayers()
move(x, y)
return [inputHandler, player]
def shotMove.execute():
def [dx, dy] := MOVEGRID[direction]
for _ in (0..!BULSPD):
if (live):
x += dx
y += dy
if (!gameboard.collide(theEventArena, shot, x, y)):
theEventArena.damageView(x, y)
if (live):
theEventArena <- add(shotMove)
The shot moves `BULSPD` spaces in the direction it’s travelling, and collides with each cell along its route, marking them for view update. If the collision process didn’t deactivate the shot, it queues another move event for next turn.
def moveEvent.execute():
if (!gameboard.collide(eventArena, player, tx, ty)):
eventArena.damageView(x, y)
eventArena.damageView(tx, ty)
x := tx
y := ty
player.look()
Move the player and update view for its new and old locations, then update the area visible to the player once in the new location.
def faceEvent.execute():
if (face != o):
face := o
eventArena.damageView(x, y)
player.look()
Change the player’s orientation, which changes how it’s displayed and what’s visible to it.
def hitMineEvent.execute() { }
def hitGMineEvent.execute() { }
It wouldn’t surprise me if these all need to be handled in a single function instead of done as methods on each kind of thing. There’s no single clear allocation of who does what when two objects collide.
Maze edges count as walls but are indestructible.to collide(eventArena, item, x, y):
if (!inMazeBounds(x, y)):
if (item.getKind() == "shot"):
item.hitMaze()
return false
The `damageTiles` overlay directs us to treat damaged spots in the maze as clear.
if (damageTiles.contains([x, y]) || mazeArray[y][x] == SPACE):
return false
But if this is an in-bounds undamaged spot, we add it to the damage overlay (making the oldest damaged tile heal if we’re past `MAXMAZEDAMAGE` tiles destroyed), tell the item it hit the maze, and get ready to inform the client that a tile needs redrawing as gone.
if (item.getKind() == "shot"):
damageTiles[[x, y]] := SPACE
eventArena.damageView(x, y)
if (damageTiles.size() > MAXMAZEDAMAGE):
def pair := damageTiles.getKeys()[0]
damageTiles.removeKey(pair)
item.hitMaze()
return true
to collide(ev, item, x, y):
if (!items.contains([x, y]) || item.getKind() != "player"):
return false
switch (items[[x, y]]):
match ==MINE:
<<hitMineEvent>>
ev.add(hitMineEvent)
match ==GMINE:
<<hitGMineEvent>>
ev.add(hitGMineEvent)
return true
Currently, only when players collide with an item does anything happen. The “hit mine” events are scheduled when a player steps on one.
to collide(theEventArena, item, x, y):
if (item.getKind() == "player"):
for s in (shots):
if (s.getCoords() == [x, y]):
#s.explode()
item.damage(s.getCharge())
s.remove()
return true
return false
Hitting a player causes damage according to the shot’s power level then removes the shot from the board.
object gameboard:
to collide(eventArena, item, x, y):
"An item has moved, give all other pieces of the game an opportunity
to generate an event"
return (maze.collide(eventArena, item, x, y) ||
itemStocker.collide(eventArena, item, x, y) ||
shotTracker.collide(eventArena, item, x, y)
)#|| playerTracker.collide(eventArena, item, x, y))
def allPlayers := [].asMap().diverge()
def advanceGame():
# Process events
when (theEventArena <- execute()) ->
# Collect coordinates updated by events
def viewdamageTiles := theEventArena.collectViewDamage()
var output := [].asMap()
<<render>>
# Update player views
for p in (allPlayers.getKeys()):
p.updateViewmap(output)
# Send player-view changes to clients
p.pushViewChanges()
catch p:
traceln.exception(p)
object gameController:
to createPlayer(outputHandler, name, team, enterStatus):
def [player, playerView] := makePlayer(theEventArena, outputHandler, name, team, enterStatus)
itemStocker.restockMines()
allPlayers[playerView] := outputHandler
gameController.drawPlayerStats(outputHandler)
return object playerWrapper:
match msg:
M.callWithMessage(player, msg)
advanceGame()
to removePlayer(p):
allPlayers.remove(p)
to drawPlayerStats(out):
for i => p in (allPlayers.getKeys()):
out <- draw(STAT_PLAY_ROW + 1 + i, STAT_NAME_COL,
UTF8.encode(p.statusLine(), throw))
def makeEventArena() as DeepFrozen:
var events := [].diverge()
var pendingViewDamage := [].asSet().diverge()
return object theEventArena:
to add(event):
events.push(event)
to execute():
for e in (events):
e.execute()
events := [].diverge()
to damageView(x, y):
pendingViewDamage.include([x, y])
to collectViewDamage():
def vd := pendingViewDamage
pendingViewDamage := [].asSet().diverge()
return vd
Recall that the events above all reported which tiles needed to be redrawn as a result of their actions. After all of these events have run, we can collect the definitive version of the updated tiles, in order to send only the changed contents to our clients.
for unit in ([maze, itemStocker, shotTracker] + allPlayers.getKeys()):
output := unit.render(viewdamageTiles) | output
We’re going to ask each game component to contribute to the output; each one will look at `viewDamageTiles` to decide what parts of its state to report. Note that `viewDamageTiles` will be `null` when a client first connects; this is used to request a full draw of everything.
to render(viewdamageTiles :NullOk[Set[Pair[Int, Int]]]):
def view := damageTiles | mazeMap
if (viewdamageTiles == null):
return view
return [for k in (viewdamageTiles) ? (view.contains(k)) k => view[k]]
The maze itself contributes walls or spaces, taking damage into account.
to render(viewdamageTiles :NullOk[Set[Pair[Int, Int]]]):
if (viewdamageTiles == null):
return items
return [for k in (viewdamageTiles) ? (items.contains(k)) k => items[k]]
Show any pickup items that might have spawned in this turn.
to render(viewdamageTiles :NullOk[Set[Pair[Int, Int]]]):
var output := [].asMap()
for s in (shots):
output := s.render(viewdamageTiles) | output
return output
Multiple shots can be in flight at once, so the shot tracker queries all of them.
to render(viewdamageTiles :NullOk[Set[Pair[Int, Int]]]):
if (viewdamageTiles == null || viewdamageTiles.contains([x, y])):
return [[x, y] => b`*`]
return [].asMap()
Currently all shots look the same. We’ll probably display different types differently, later.
to render(viewdamageTiles :NullOk[Set[Pair[Int, Int]]]):
if (viewdamageTiles == null || viewdamageTiles.contains([x, y])):
return [[x, y] => face]
else:
return [].asMap()
Show the player’s location and facing direction.
import "lib/codec/utf8" =~ [=> UTF8 :DeepFrozen]
import "lib/streams" =~ [=> Sink :DeepFrozen]
import "lib/entropy/entropy" =~ [=> makeEntropy :DeepFrozen]
import "lib/entropy/pcg" =~ [=> makePCG :DeepFrozen]
import "mcurses" =~ [=> activateTerminal :DeepFrozen]
exports (main)
def DOOR :Bytes := b`#`
def WALL1 :Bytes := b`-`
def WALL2 :Bytes := b`|`
def WALL3 :Bytes := b`+`
def WALL4 :Bytes := b`/`
def WALL5 :Bytes := b`\\`
def KNIFE :Bytes := b`K`
def SHOT :Bytes := b`:`
def GRENADE :Bytes := b`o`
def SATCHEL :Bytes := b`O`
def BOMB :Bytes := b`@@`
def MINE :Bytes := b`;`
def GMINE :Bytes := b`g`
def SLIME :Bytes := b`$$`
def LAVA :Bytes := b`~`
def DSHOT :Bytes := b`?`
def FALL :Bytes := b`F`
def BOOT :Bytes := b`b`
def BOOT_PAIR :Bytes := b`B`
def SPACE :Bytes := b` `
def ABOVE :Bytes := b`i`
def BELOW :Bytes := b`!`
def RIGHT :Bytes := b`}`
def LEFTS :Bytes := b`{`
def defaultKeyMapping :Map[Str, Str] := [
"h" => "moveLeft",
"H" => "faceLeft",
"j" => "moveDown",
"J" => "faceDown",
"k" => "moveUp",
"K" => "faceUp",
"l" => "moveRight",
"L" => "faceRight",
"f" => "shot",
"1" => "shot",
" " => "shot",
"g" => "grenade",
"2" => "grenade",
"F" => "satchel",
"3" => "satchel",
"G" => "bomb7",
"4" => "bomb7",
"5" => "bomb9",
"6" => "bomb11",
"7" => "bomb13",
"8" => "bomb15",
"9" => "bomb19",
"@" => "bomb21",
"o" => "slime",
"O" => "sslime",
"p" => "slime2",
"P" => "slime3",
"s" => "scan",
"c" => "cloak",
"q" => "quit"
]
def [WIDTH :Int, HEIGHT :Int] := [51, 23]
def STAT_LABEL_COL :Int := 60
def STAT_VALUE_COL :Int := 74
def STAT_NAME_COL :Int := 61
def STAT_SCAN_COL :Int := (STAT_NAME_COL + 5)
def STAT_AMMO_ROW :Int := 1
def STAT_GUN_ROW :Int := 2
def STAT_DAM_ROW :Int := 3
def STAT_KILL_ROW :Int := 4
def STAT_PLAY_ROW :Int := 6
def BULSPD :Int := 2 # `hunt` has 5 but this is more impressive looking atm
def ISHOTS :Int := 15
def NSHOTS :Int := 5
def MAXNCSHOT :Int := 2
def MAXDAM :Int := 10
def CLOAKLEN :Int := 20
def SCANLEN :Int := 20
def MAXMAZEDAMAGE :Int := 40
# define BULREQ 1
# define GRENREQ 9
# define SATREQ 25
# define BOMB7REQ 49
# define BOMB9REQ 81
# define BOMB11REQ 121
# define BOMB13REQ 169
# define BOMB15REQ 225
# define BOMB17REQ 289
# define BOMB19REQ 361
# define BOMB21REQ 441
def shotCost :List[Int] := [1, 9, 25, 49, 81, 121, 169, 225, 289, 361, 441]
def shotType :List[Bytes] := [SHOT, GRENADE, SATCHEL, BOMB, BOMB, BOMB,
BOMB, BOMB, BOMB, BOMB, BOMB]
def MAXBOMB :Int := shotType.size()
def dec(x :Int) :Bytes as DeepFrozen:
def s := M.toString(x)
return UTF8.encode(s, null)
def MOVEGRID :Map[Bytes, List[Int]] := [
b`^` => [0, -1],
b`v` => [0, 1],
b`>` => [1, 0],
b`<` => [-1, 0]
]
def inMazeBounds(x, y) as DeepFrozen:
return (x < WIDTH && x >= 0 &&
y < HEIGHT && y >= 0)
def main(_args, => stdio) as DeepFrozen:
def entropy := makeEntropy(makePCG(42, 5))
def keyMapping := defaultKeyMapping
def [end, endR] := Ref.promise()
def theEventArena := makeEventArena()
def maze := makeMaze(entropy)
def itemStocker := makeItemStocker(maze, entropy)
def shotTracker := makeShotTracker(maze, theEventArena, entropy)
<<gameboard>>
<<makePlayer>>
<<advanceGame>>
<<gameController>>
<<ioBlock>>
<<preface>>
<<definitions>>
<<maze>>
<<items>>
<<shots>>
<<events>>
<<main>>