Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 31 additions & 27 deletions AGENTS.md

Large diffs are not rendered by default.

48 changes: 38 additions & 10 deletions MULTIPLAYER.md

Large diffs are not rendered by default.

15 changes: 9 additions & 6 deletions infra/worker/gameRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@ import {
type RoomTicket,
SERVER_VERSIONS,
ServerFrame,
StandIn,
type StandInWorld,
decodeClientFrame,
encodeServerFrame,
normalizeRoomCode,
Expand All @@ -42,6 +40,11 @@ import {
verifyToken,
} from '@munch/sync-engine'

import {
DEFAULT_ROOM_SETTINGS,
type RoomWorld,
roomWorldFor,
} from '../../src/roomWorld.ts'
import {
INTERNAL_PATHS,
InternalCreate,
Expand Down Expand Up @@ -84,13 +87,13 @@ import {
// READY makes it Playing. A public room tells the Matchmaker (matchmaker.ts)
// whenever its lobby changes. See MULTIPLAYER.md, Phase 2 and 3.
//
// The world is the stand-in (dots and walkers) until the game's own
// `stepWorld` lands; it is the one line `World` below.
// The world is the game's own (src/roomWorld.ts): `stepWorld` over the
// lobby's seats, a campaign on Normal until the lobby picks a difficulty.

export type GameRoomBindings = SyncBindings

type GameWorld = StandInWorld
const World = StandIn
type GameWorld = RoomWorld
const World = roomWorldFor(DEFAULT_ROOM_SETTINGS)

const SCOPE_HEADER = 'x-sync-scope'
const GAME_TAG = 'game'
Expand Down
11 changes: 8 additions & 3 deletions packages/netcode/src/Room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,7 +620,8 @@ export const ticksDue = (room: Room<unknown>, nowMs: number): number =>

// NOTE: one wake of the room's loop: the ticks the clock owes, one message
// to everyone if a send tick passed, then presence, then the end of the
// game. After an `Ended` out the room is done.
// game, which first sends the last world if it is not out yet, so every
// player sees how the run ended. After an `Ended` out the room is done.
export const advance = <W, Own>(
world: World<W, Own>,
room: Room<W>,
Expand All @@ -645,9 +646,13 @@ export const advance = <W, Own>(
const present = checkPresence(world, sent.room, nowMs)
const out = [...sent.out, ...present.out]
if (world.isOver(present.room.world)) {
const last =
present.room.sentTick === present.room.tick
? { room: present.room, out: [] }
: broadcast(world, present.room)
return {
room: present.room,
out: [...out, ...endRoom(world, present.room, 'GameOver')],
room: last.room,
out: [...out, ...last.out, ...endRoom(world, last.room, 'GameOver')],
}
}
if (Array.isReadonlyArrayEmpty(present.room.seats)) {
Expand Down
18 changes: 17 additions & 1 deletion packages/netcode/src/Wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { Data, Result } from 'effect'
// NOTE: the bytes under the room protocol, after the estimate in
// tools/mp-spike/wire.ts: unsigned integers as LEB128 varints, signed ones
// zigzagged first, positions and other fractions as int16 fixed point,
// strings and byte runs prefixed with their length. A Reader that runs off
// strings and byte runs prefixed with their length, and any other number
// as a little-endian float64. A Reader that runs off
// the end, or meets a malformed varint, throws a WireError, and `readAll`
// turns that into a failed Result, so callers never see an exception.

Expand Down Expand Up @@ -42,6 +43,7 @@ export interface Writer {
readonly i16: (value: number) => void
readonly fixed: (value: number, scale: number) => void
readonly position: (value: number) => void
readonly f64: (value: number) => void
readonly bool: (value: boolean) => void
readonly bytes: (value: Uint8Array) => void
readonly raw: (value: Uint8Array) => void
Expand Down Expand Up @@ -106,6 +108,11 @@ export const makeWriter = (capacity = 64): Writer => {
i16,
fixed: (value, scale) => i16(fixedOf(value, scale)),
position: value => i16(fixedOf(value, POSITION_SCALE)),
f64: value => {
const bytes = new Uint8Array(8)
new DataView(bytes.buffer).setFloat64(0, value, true)
raw(bytes)
},
bool: value => u8(value ? 1 : 0),
bytes: value => {
varint(value.length)
Expand All @@ -131,6 +138,7 @@ export interface Reader {
readonly i16: () => number
readonly fixed: (scale: number) => number
readonly position: () => number
readonly f64: () => number
readonly bool: () => boolean
readonly bytes: (maxLength: number) => Uint8Array
readonly rest: () => Uint8Array
Expand Down Expand Up @@ -197,6 +205,14 @@ export const makeReader = (bytes: Uint8Array): Reader => {
i16,
fixed: scale => i16() / scale,
position: () => i16() / POSITION_SCALE,
f64: () => {
const taken = take(8)
return new DataView(
taken.buffer,
taken.byteOffset,
taken.byteLength,
).getFloat64(0, true)
},
bool: () => u8() !== 0,
bytes: maxLength => take(lengthOf(maxLength)).slice(),
rest: () => take(bytes.length - state.at).slice(),
Expand Down
16 changes: 16 additions & 0 deletions packages/netcode/test/room.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,8 @@ describe('the room', () => {
)
expect(Room.isEnded(end.out)).toBe(true)
expect(tagsTo(end.out, 'socket-0').at(-1)).toBe('RoomClosed')
expect(tagsTo(end.out, 'socket-0').at(-2)).toBe('Delta')
expect(end.room.sentTick).toBe(end.room.tick)
const ended = end.out.find(item => item._tag === 'Ended')
expect(ended).toMatchObject({ _tag: 'Ended', reason: 'GameOver' })
expect(
Expand All @@ -238,6 +240,20 @@ describe('the room', () => {
])
})

test('a run that ends between messages sends its last world first', () => {
const endsAtTen = {
...StandIn,
isOver: (world: StandInWorld) => world.tick >= 10,
}
const started = Room.startRoom(endsAtTen, members([0, 1]), 7, T0)
const atNine = Room.advance(endsAtTen, started.room, T0 + 9 * Room.TICK_MS)
expect(atNine.room.sentTick).toBe(9)
const atTen = Room.advance(endsAtTen, atNine.room, T0 + 10 * Room.TICK_MS)
expect(Room.isEnded(atTen.out)).toBe(true)
expect(tagsTo(atTen.out, 'socket-0')).toEqual(['Delta', 'RoomClosed'])
expect(atTen.room.sentTick).toBe(10)
})

test('Leave frees the seat at once; a kick says so first', () => {
const started = start()
const left = Room.receive(
Expand Down
65 changes: 2 additions & 63 deletions src/audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,72 +18,11 @@ import {
runMusicScheduler,
} from './music'
import { generateMany, nextRandom } from './prng'
import { SoundEffect } from './soundEffect'

// SOUND

export const SoundEffect = Schema.Literals([
'Shot',
'ShotgunBlast',
'SmgShot',
'DryClick',
'MagOut',
'MagIn',
'Swap',
'ZombieHit',
'ZombieDeath',
'RunnerHit',
'RunnerDeath',
'TankHit',
'TankDeath',
'ExploderHit',
'ExploderFuse',
'Boom',
'Stomp',
'PlayerHurt',
'Start',
'ShopBell',
'Purchase',
'PowerUp',
'Denied',
'Payday',
'TurretShot',
'Thunk',
'Crack',
'Crash',
'Pop',
'Groan',
'DinnerBell',
'LootPop',
'GrabHotSauce',
'GrabMeanBeans',
'GrabZoomySocks',
'GrabKebullets',
'GrabBurp',
'GrabHeart',
'PowerDown',
'LimbPop',
'Clang',
'BossRoar',
'BossWindUp',
'BossStomp',
'BossBelch',
'BossDeath',
'BossChargeUp',
'BossRush',
'BossBonk',
'BossSpit',
'BossSplat',
'Heave',
'Whoosh',
'Thud',
'Rumble',
'Burrow',
'Clutch',
'Yank',
'Howl',
'PackBonus',
])
export type SoundEffect = typeof SoundEffect.Type
export { SoundEffect } from './soundEffect'

export const Playback = Schema.Literals(['Running', 'Suspended'])
export type Playback = typeof Playback.Type
Expand Down
107 changes: 21 additions & 86 deletions src/coop/coop.css
Original file line number Diff line number Diff line change
Expand Up @@ -941,112 +941,47 @@
letter-spacing: 0.1em;
}

/* ARENA: the world in a 16:9 frame, names over the players. */
/* ARENA: the game's own scene shows through; co-op adds only LEAVE, the
countdown, Away, and the trophy over it. */

.coop-arena {
position: relative;
display: grid;
place-items: center;
width: 100%;
height: 100%;
}

.coop-arena-frame {
position: relative;
container-type: size;
width: min(100cqw, 100cqh * 16 / 9);
aspect-ratio: 16 / 9;
overflow: hidden;
border-radius: var(--menu-radius-sm);
box-shadow:
0 0 0 3px #10090f,
var(--menu-pop);
}

.coop-arena-canvas {
display: block;
width: 100%;
height: 100%;
image-rendering: pixelated;
touch-action: none;
}

.coop-plates {
position: absolute;
inset: 0;
.coop[data-screen='Arena'] {
background: none;
backdrop-filter: none;
pointer-events: none;
animation: none;
}

.coop-plate {
.coop-arena {
position: absolute;
display: flex;
align-items: center;
gap: 4px;
padding: 1px 6px;
border: 2px solid #10240a;
border-radius: var(--menu-radius-sm);
background: var(--coop-teal);
color: #0a2220;
font: bold clamp(10px, 2.2 * var(--coop-unit), 15px) / 1.2 var(--menu-font);
white-space: nowrap;
translate: -50% calc(-100% - 9cqh);
}

.coop-plate[data-me='Me'] {
background: var(--menu-gold);
color: #241a06;
}

.coop-plate[data-away='Away'] {
opacity: 0.6;
}

.coop-plate-score {
padding: 0 4px;
border-radius: 3px;
background: rgba(0, 0, 0, 0.25);
}

.coop-plate-away {
width: 12px;
height: 10px;
background: center / contain no-repeat;
image-rendering: pixelated;
filter: brightness(0.2);
inset: 0;
container-type: size;
}

.coop-arena-hud {
position: absolute;
top: 8px;
right: 8px;
left: 8px;
top: 6px;
right: 6px;
display: flex;
align-items: center;
justify-content: space-between;
pointer-events: none;
}

.coop-arena-hud > * {
pointer-events: auto;
}

.coop-timer {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 2px solid var(--menu-gold);
border-radius: var(--menu-radius-pill);
background: var(--menu-glass);
color: var(--menu-gold);
font: bold clamp(16px, 4 * var(--coop-unit), 26px) / 1 var(--menu-font);
/* In the run LEAVE sits where PAUSE would, the size of the corner buttons. */
.coop-arena-hud .coop-leave {
width: 48px;
min-width: 48px;
height: 48px;
padding: 0;
border-radius: 9999px;
opacity: 0.8;
}

.coop-timer-icon {
width: 18px;
height: 18px;
background: center / contain no-repeat;
image-rendering: pixelated;
.coop-net {
display: none;
}

.coop-countdown {
Expand Down
Loading
Loading