Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
a5764f3
Add immutable useRoomState hook
FTWinston Dec 18, 2025
ab4d115
Extend schema, add components for displaying render count of each part
FTWinston Dec 18, 2025
79b07d6
Disable strict mode so render count doesn't go up by two with every c…
FTWinston Dec 18, 2025
f07f26f
Memoised components so they don't rerender if their props don't change
FTWinston Dec 18, 2025
455d2a8
Highlighting the most recent rendered components, instead of displayi…
FTWinston Dec 18, 2025
a573471
Tidy appearance, add inventory actions
FTWinston Dec 18, 2025
0d3f778
Add an initial test
FTWinston Dec 19, 2025
829f36e
Highlight items when they are created, as well as when modifying
FTWinston Dec 19, 2025
d0c7a4a
Add some more tests
FTWinston Dec 19, 2025
c3e53c3
Add additional tests, including skipped array tests that currently fa…
FTWinston Dec 19, 2025
aa19d76
Adjust array handling to resolve failing test
FTWinston Dec 19, 2025
8a2066d
Update to latest @colyseus/schema
FTWinston Dec 20, 2025
17a6fe2
Rework hook to intercept decoder changes, instead of adding listeners…
FTWinston Dec 23, 2025
bfe780d
Add colyseus.js dependency so useRoomState can be uncommented
FTWinston Dec 23, 2025
9957238
Fix some imports, remove getReactCallbacks
FTWinston Dec 23, 2025
e86dbed
Update src/schema/createSnapshot.ts
FTWinston Dec 23, 2025
c37e790
Re-enable strict mode
FTWinston Dec 23, 2025
ffc9ba1
Ensure inventory won't get two items of the same type
FTWinston Dec 23, 2025
9facdea
move visual tests to 'example' folder. setup package structure for pu…
endel Jan 28, 2026
a634c24
Remove duplicate hook and App files
FTWinston Jan 30, 2026
8709ecb
Track items' parents, and only iterate dirty subtrees when creating a…
FTWinston Jan 30, 2026
664decd
Initial plan
Copilot Feb 5, 2026
623da1e
Add GitHub Actions CI workflow for build and test
Copilot Feb 5, 2026
338f79a
Add permissions block to CI workflow for security
Copilot Feb 5, 2026
487ac6b
Add GitHub Actions CI workflow for build and test
FTWinston Feb 5, 2026
3e54984
Initial plan
Copilot Feb 5, 2026
fe56fd9
Add comprehensive array schema tests and update CI workflow
Copilot Feb 5, 2026
0a8e74e
Add test for modifying last task in array
Copilot Feb 5, 2026
302515c
Merge pull request #5 from colyseus/copilot/sub-pr-3
FTWinston Feb 5, 2026
5cb729d
Update to @colyseus/schema 4 and @colyseus/sdk 0.17
FTWinston Feb 5, 2026
7cc4777
Fix CI build error
FTWinston Feb 5, 2026
4521afc
publish 0.1.0 for testing
endel Feb 10, 2026
f94a387
Add (failing) tests that every change isn't creating new subscription…
FTWinston Feb 10, 2026
1d9148d
Ensure stable callbacks are passed to useSyncExternalStore, so that e…
FTWinston Feb 10, 2026
48e80aa
Move example to examples/visualisation
FTWinston Feb 11, 2026
0fa1f1e
Added demo-client and demo-server projects that reproduce the error t…
FTWinston Feb 11, 2026
65318b2
Remove a fallback in createSnapshot that isn't needed as of @colyseus…
FTWinston Feb 11, 2026
9a96779
myString doesn't update on every state change
FTWinston Feb 11, 2026
7d0bb90
Add missing function
FTWinston Feb 11, 2026
6fb907a
More permissive peer dependencies
FTWinston Feb 12, 2026
9db94bb
Add dedupe rule to demo-client vite config, to resolve error
FTWinston Feb 12, 2026
3b41ce8
Update @colyseus/sdk to resolve seat reservation error when schema is…
FTWinston Feb 12, 2026
47c6d84
useRoomState no longer interferes with with Callbacks.get or any othe…
FTWinston Feb 13, 2026
fc1e953
avoid using 'instanceof' due to duplicated @colyseus/schema package i…
endel Feb 13, 2026
4b2b2a2
fix 'dirty' cleanup
endel Feb 13, 2026
fe3e457
useRoomState and useColyseusState now handle falsy parameters
FTWinston Feb 17, 2026
889ca66
bump version
endel Feb 25, 2026
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
1,933 changes: 1,684 additions & 249 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,30 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"test": "vitest",
"preview": "vite preview"
},
"dependencies": {
"@colyseus/schema": "^3.0.21",
"@colyseus/schema": "^3.0.74",
"buffer": "^6.0.3",
"colyseus.js": "^0.16.22",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@eslint/js": "^9.9.0",
"@testing-library/react": "^16.3.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"eslint": "^9.9.0",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9",
"globals": "^15.9.0",
"happy-dom": "^20.0.11",
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.1",
"vite": "^5.4.1"
"vite": "^5.4.1",
"vitest": "^3.2.4"
}
}
42 changes: 6 additions & 36 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,11 @@
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
text-align: left;
}

.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}

@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}

.card {
padding: 2em;
}

.read-the-docs {
color: #888;
}
.buttons {
display: flex;
gap: 0.5em;
flex-wrap: wrap;
}
114 changes: 93 additions & 21 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
import { StateDisplay } from './display/StateDisplay';
import { Item, MyRoomState, Player } from './schema/MyRoomState'
import { simulateState } from './schema/simulateState';
import { useColyseusState } from './schema/useColyseusState';
import './App.css'

import { Player } from './schema/MyRoomState'
import { simulatePatchState, useRoomState } from './schema/simulate';
const { clientState, decoder, updateState } = simulateState(() => new MyRoomState());

/**
* Update the string in the state.
*/
function simulateUpdateString() {
simulatePatchState((state) => {
state.myString = "Updated! " + Math.random();
updateState((state) => {
state.myString = "Updated! " + Math.round(Math.random() * 1000000) / 1000000;
});
}

/**
* Add a new player to the state.
*/
function simulateAddPlayer() {
simulatePatchState((state) => {
updateState((state) => {
const num = (state.players.size + 1);
state.players.set(`p-${num}`, new Player().assign({ name: "Player " + num }));
});
Expand All @@ -26,32 +29,101 @@ function simulateAddPlayer() {
* Remove a random player from the state.
*/
function simulateRemovePlayer() {
simulatePatchState((state) => {
updateState((state) => {
const randomKey = Array.from(state.players.keys())[Math.floor(Math.random() * state.players.size)];
state.players.delete(randomKey);
});
}

/**
* Adjust the position of a random player in the state.
*/
function simulateRenamePlayer() {
updateState((state) => {
const randomKey = Array.from(state.players.keys())[Math.floor(Math.random() * state.players.size)];
const player = state.players.get(randomKey);
if (player) {
player.name += ' X';
}
});
}

/**
* Adjust the position of a random player in the state.
*/
function simulateMovePlayer() {
updateState((state) => {
const randomKey = Array.from(state.players.keys())[Math.floor(Math.random() * state.players.size)];
const player = state.players.get(randomKey);
if (player) {
player.position.x = Math.floor(Math.random() * 100);
player.position.y = Math.floor(Math.random() * 100);
}
});
}

/**
* Add an item to the inventory of a random player in the state.
*/
function simulateAddItem() {
updateState((state) => {
const randomKey = Array.from(state.players.keys())[Math.floor(Math.random() * state.players.size)];
const player = state.players.get(randomKey);
if (player) {
const itemType = ['Sword', 'Shield', 'Potion', 'Bow'][Math.floor(Math.random() * 4)];
player.inventory.push(new Item(itemType));
}
});
}

/**
* Remove an item from the inventory of a random player in the state.
*/
function simulateRemoveItem() {
updateState((state) => {
const playersWithItems = Array.from(state.players.values()).filter(p => p.inventory.length > 0);
if (playersWithItems.length === 0) {
return; // No players with items to remove from.
}

const player = playersWithItems[Math.floor(Math.random() * playersWithItems.length)];
player.inventory.pop();
});
}

/**
* Increment the quantity of an item in the inventory of a random player in the state.
*/
function simulateIncrementItem() {
updateState((state) => {
const playersWithItems = Array.from(state.players.values()).filter(p => p.inventory.length > 0);
if (playersWithItems.length === 0) {
return; // No players with items to remove from.
}

const player = playersWithItems[Math.floor(Math.random() * playersWithItems.length)];
const itemIndex = Math.floor(Math.random() * player.inventory.length);
player.inventory[itemIndex].quantity += 1;
});
}

function App() {
const state = useRoomState((state) => state);
const players = useRoomState((state) => state.players);
const state = useColyseusState(clientState, decoder);

return (
<>
<h2>State</h2>
<p><strong><code>.myString</code>:</strong> {state.myString}</p>
<button onClick={simulateUpdateString}>Update <code>.myString</code></button>
<hr />

<h2><strong><code>.players</code></strong></h2>

{Array.from(players.keys()).map((key) => (
<p key={key}><code>{key}</code> → <code>{JSON.stringify(players.get(key)?.toJSON())}</code></p>
))}
<StateDisplay state={state} />

<hr />
<button onClick={simulateAddPlayer}>Add player</button>
<button onClick={simulateRemovePlayer}>Remove player</button>
<div className="buttons">
<button onClick={simulateUpdateString}>Update <code>.myString</code></button>
<button onClick={simulateAddPlayer}>Add player</button>
<button onClick={simulateRemovePlayer}>Remove player</button>
<button onClick={simulateMovePlayer}>Move player</button>
<button onClick={simulateRenamePlayer}>Rename player</button>
<button onClick={simulateAddItem}>Add item</button>
<button onClick={simulateIncrementItem}>Increment item</button>
<button onClick={simulateRemoveItem}>Remove item</button>
</div>
</>
)
}
Expand Down
16 changes: 16 additions & 0 deletions src/display/ItemDisplay.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
.item {
border: 1px solid #ccc;
padding: 0.5em 1em;
background-color: #242424;
corner-shape: squircle;
border-radius: 1em;
}

.item-type {
font-weight: bold;
}

.item-quantity {
font-style: italic;
text-align: center;
}
20 changes: 20 additions & 0 deletions src/display/ItemDisplay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { memo } from 'react';
import { Item } from '../schema/MyRoomState';
import { Snapshot } from '../schema/createSnapshot';
import { useRenderHighlight } from './useRenderHighlight';
import './ItemDisplay.css'

type Props = {
item: Snapshot<Item>; // You'd normally pass in the fields rather than the item class itself, but doing it this way lets the component only re-render when the item changes.
}

export const ItemDisplay = memo(({ item }: Props) => {
const highlightRef = useRenderHighlight<HTMLDivElement>();

return (
<div className="item" ref={highlightRef}>
<div className="item-type">{item.type}</div>
<div className="item-quantity">x{item.quantity}</div>
</div>
);
});
11 changes: 11 additions & 0 deletions src/display/ItemsDisplay.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
.items {
border: 1px solid #ccc;
padding: 1em;
min-height: 3em;
display: flex;
gap: 1em;
background-color: #242424;
corner-shape: squircle;
border-radius: 1em;
box-sizing: border-box;
}
23 changes: 23 additions & 0 deletions src/display/ItemsDisplay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { memo } from 'react';
import { ArraySchema } from '@colyseus/schema';
import { Snapshot } from '../schema/createSnapshot';
import { Item } from '../schema/MyRoomState';
import { ItemDisplay } from './ItemDisplay';
import { useRenderHighlight } from './useRenderHighlight';
import './ItemsDisplay.css'

type Props = {
items: Snapshot<ArraySchema<Item>>;
}

export const ItemsDisplay = memo(({ items }: Props) => {
const highlightRef = useRenderHighlight<HTMLDivElement>();

return (
<div className="items" ref={highlightRef}>
{items.map(item => (
<ItemDisplay key={item.type} item={item} />
))}
</div>
);
});
23 changes: 23 additions & 0 deletions src/display/PlayerDisplay.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
.player {
border: 1px solid #ccc;
padding: 1em;
margin: 1em;
background-color: #242424;
display: flex;
flex-direction: column;
gap: 0.5em;
corner-shape: squircle;
border-radius: 1em;
}

.player-field {
display: flex;

& > :first-child {
min-width: 6em;
}

& > :last-child {
flex-grow: 1;
}
}
23 changes: 23 additions & 0 deletions src/display/PlayerDisplay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { memo } from 'react';
import { Player } from '../schema/MyRoomState';
import { Snapshot } from '../schema/createSnapshot';
import { ItemsDisplay } from './ItemsDisplay';
import { PositionDisplay } from './PositionDisplay';
import { useRenderHighlight } from './useRenderHighlight';
import './PlayerDisplay.css'

type Props = {
player: Snapshot<Player>; // You'd normally pass in the fields rather than the player class itself, but doing it this way lets the component only re-render when the item changes.
}

export const PlayerDisplay = memo(({ player }: Props) => {
const highlightRef = useRenderHighlight<HTMLDivElement>();

return (
<div className="player" ref={highlightRef}>
<div className="player-field"><strong>Name:</strong><div>{player.name}</div></div>
<div className="player-field"><strong>Position:</strong><PositionDisplay position={player.position} /></div>
<div className="player-field"><strong>Inventory:</strong><ItemsDisplay items={player.inventory} /></div>
</div>
);
});
8 changes: 8 additions & 0 deletions src/display/PlayersDisplay.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.players {
border: 1px solid #ccc;
padding: 1em;
margin: 1em;
background-color: #242424;
corner-shape: squircle;
border-radius: 1em;
}
27 changes: 27 additions & 0 deletions src/display/PlayersDisplay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { MapSchema } from '@colyseus/schema';
import { memo } from 'react';
import { Player } from '../schema/MyRoomState';
import { Snapshot } from '../schema/createSnapshot';
import { useRenderHighlight } from './useRenderHighlight';
import { PlayerDisplay } from './PlayerDisplay';
import './PlayersDisplay.css'

type Props = {
players: Snapshot<MapSchema<Player>>;
}

export const PlayersDisplay = memo(({ players }: Props) => {
const highlightRef = useRenderHighlight<HTMLDivElement>();

return (
<div className="players" ref={highlightRef}>
<h2>Players</h2>

{Object.entries(players).map(([id, player]) => (
player !== undefined && (
<PlayerDisplay key={id} player={player} />
)
))}
</div>
);
});
Loading