Play connect4.

  • Write agents that play connect4.
  • Battle your agents against other agents.
  • It's fun.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
/* @jsxImportSource https://esm.sh/hono/jsx */
import { track } from "https://esm.town/v/saolsen/plausible?v=3";
import { init, traced_fetch, traced_handler, traced, get_tracer } from "https://esm.town/v/saolsen/tracing?v=135";
import { trace } from "npm:@opentelemetry/api";
init("connect4_site");
const tracer = get_tracer("connect4_site");
import { blob } from "https://esm.town/v/std/blob";
import { customAlphabet } from "npm:nanoid";
import { z } from "npm:zod";
import { Hono } from "npm:hono";
import { html } from "npm:hono/html";
import { jsxRenderer, useRequestContext } from "npm:hono/jsx-renderer";
import * as connect4 from "https://esm.town/v/saolsen/connect4";
// TODO:
// * htmx loading spinners
const _nanoid = customAlphabet("123456789abcdefghijklmnopqrstuvwxyz", 10);
const MatchId = z.string().startsWith("m_");
type MatchId = z.infer<typeof MatchId>;
function matchid(): MatchId {
return `m_${_nanoid()}`;
}
type MePlayer = {
kind: "me";
};
type AgentPlayer = {
kind: "agent";
name: string;
url: string;
};
type Player = MePlayer | AgentPlayer;
type AgentFetchError = {
kind: "agent_fetch";
error_message: string;
};
type AgentHTTPResponseError = {
kind: "agent_http_response";
status: number;
body: string;
};
type AgentInvalidActionJsonError = {
kind: "agent_invalid_json";
body: string;
parse_error: string;
};
type AgentInvalidActionError = {
kind: "agent_invalid_action";
action: connect4.Action;
error: string;
};
type Error =
| AgentFetchError
| AgentHTTPResponseError
| AgentInvalidActionJsonError
| AgentInvalidActionError;
type ErrorStatus = {
status: "error";
error: Error;
};
type StatusOrError = connect4.Status | ErrorStatus;
type Turn = {
number: number;
status: StatusOrError;
player: number | null;
column: number | null;
state: connect4.State;
};
type Match = {
id: MatchId;
players: [Player, Player];
turns: Turn[];
};
async function get_match(id: MatchId): Promise<Match | null> {
const span = tracer.startSpan("get_match");
span.setAttribute("match_id", id);
const match = await blob.getJSON(`connect4_matches/${id}`);
span.end();
if (match === undefined) {
return null;
}
return match;
}

View val changes as a diff.

Go to /v/username/valname/version to see a diff between that version and the previous one. For example https://saolsen-changes.web.val.run/v/saolsen/tracing/108

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
/* @jsxImportSource https://esm.sh/hono/jsx */
import { Hono } from "npm:hono";
import { html } from "npm:hono/html";
import { track } from "https://esm.town/v/saolsen/plausible?v=3";
import {
init,
traced_fetch,
traced_handler,
} from "https://esm.town/v/saolsen/tracing";
init("changes");
const app = new Hono();
app.get("/", (c) => {
return c.html(
<div>
Go to `/v/:user/:val` to see the latest version of a val, or
`/v/:user/:val/:version` to see a specific version. For example,{" "}
<a href="/v/saolsen/tracing/108">/v/saolsen/tracing/108</a>
</div>
);
});
type Val = {
user: string;
val: string;
version: number;
body: string;
};
async function get_val(
user: string,
val: string,
version: number | null = null
): Promise<Val> {
let val_req_url = `https://esm.town/v/${user}/${val}`;
if (version !== null) {
val_req_url = `${val_req_url}?v=${version}`;
}
const val_resp: Response = await traced_fetch(val_req_url);
const val_version = Number(val_resp.url.split("?v=")[1]);
const val_body: string = await val_resp.text();
return {
user,
val,
version: val_version,
body: val_body,
};
}
function show_val(
latest_version: number,
val: Val,
prev_val: Val | null = null
): HtmlEscapedString {
return (
<html>
<head>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
></meta>
<link
rel="stylesheet"
data-name="vs/editor/editor.main"
href="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.20.0/min/vs/editor/editor.main.min.css"
></link>
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.26.1/min/vs/loader.min.js"></script>
</head>
<body>
<div>
{prev_val !== null && (
<a href={`/v/${val.user}/${val.val}/${prev_val.version}`}>
{"<"} v{prev_val.version}
</a>
)}
<strong style="margin: 0 1em">
{val.user}/{val.val} v{val.version}
</strong>
{latest_version !== val.version && (
<a href={`/v/${val.user}/${val.val}/${val.version + 1}`}>
v{val.version + 1} {">"}
</a>
)}
<div>
<div id="container"></div>
</div>
</div>
{html`
<script type="module">
function unescape(str) {
return str.replace(
/&amp;|&lt;|&gt;|&#39;|&quot;/g,
(tag) =>
({
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&#39;": "'",

This is a wrapper of the val town std sqlite library that adds tracing via https://www.val.town/v/saolsen/tracing.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
import { SpanStatusCode } from "https://cdn.skypack.dev/@opentelemetry/api";
import { type ResultSet, type TransactionMode } from "npm:@libsql/client";
import { InStatement, sqlite as std_sqlite } from "https://esm.town/v/std/sqlite?v=4";
import { get_tracer } from "https://esm.town/v/saolsen/tracing?v=136";
async function traced_execute(statement: InStatement): Promise<ResultSet> {
return await get_tracer().startActiveSpan(`sqlite:execute`, async (span) => {
if (span.isRecording()) {
if (typeof statement === "string") {
span.setAttributes({
"sqlite.statement": statement,
"sqlite.args": [],
});
} else {
span.setAttributes({
"sqlite.statement": statement.sql,
"sqlite.args": statement.args,
});
}
}
try {
const result = await std_sqlite.execute(statement);
if (span.isRecording()) {
span.setStatus({ code: SpanStatusCode.OK });
}
return result;
} catch (error) {
if (span.isRecording()) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: error.message,
});
}
throw new Error(error);
} finally {
span.end();
}
});
}
async function traced_batch(statements: InStatement[], mode?: TransactionMode): Promise<ResultSet[]> {
return await get_tracer().startActiveSpan(`sqlite:batch`, async (span) => {
if (span.isRecording()) {
span.setAttributes({
"sqlite.statements": JSON.stringify(statements),
});
}
try {
const result = await std_sqlite.batch(statements, mode);
if (span.isRecording()) {
span.setStatus({ code: SpanStatusCode.OK });
}
return result;
} catch (error) {
if (span.isRecording()) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: error.message,
});
}
throw new Error(error);
} finally {
span.end();
}
});
}
export const sqlite = {
execute: traced_execute,
batch: traced_batch,
};

Connect4 agent that uses Monte-Carlo tree search to simulate 10,000 random games from each possible action and pick the one with the highest win rate. Ported from a version I made in rust.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
import { init, traced_handler } from "https://esm.town/v/saolsen/tracing?v=109";
import { trace } from "npm:@opentelemetry/api";
init("connect4_mcts_agent");
const tracer = trace.getTracer("connect4_mcts_agent");
import * as connect4 from "https://esm.town/v/saolsen/connect4";
import { connect4_agent } from "https://esm.town/v/saolsen/connect4_agent";
const SIMULATIONS = 10000;
function rand_action(state: connect4.State): connect4.Action {
const player = state.next_player;
while (true) {
let column = Math.floor(Math.random() * connect4.COLS);
let action = { player, column };
if (connect4.check_action(state, action) === "ok") {
return action;
}
}
}
function score_action(
current_state: connect4.State,
action: connect4.Action,
): number {
const player = current_state.next_player;
// Create a new match with the action applied.
const next_state = JSON.parse(JSON.stringify(current_state));
connect4.apply_action(next_state, action);
// Simulate random games from this state.
let score = 0;
for (let i = 0; i < SIMULATIONS; i++) {
let sim_state = JSON.parse(JSON.stringify(next_state));
// Play out the rest of the game randomly.
let status = connect4.status(sim_state);
while (status.status === "in_progress") {
let sim_action = rand_action(sim_state);
status = connect4.apply_action(sim_state, sim_action);
}
if (status.result.kind === "winner") {
if (status.result.winner === player) {
score += 1;
} else {
score -= 1;
}
}
}
return score / SIMULATIONS;
}
function agent(state: connect4.State): connect4.Action {
// For each action we could take, simulate multiple random games from the resulting state.
// Keep track of the number of wins for each action.
// Pick the action with the highest win rate.
let max_score = Number.MIN_VALUE;
let best_action = { player: state.next_player, column: 0 };
for (let col = 0; col < connect4.COLS; col++) {
let action = { player: state.next_player, column: col };
let check = connect4.check_action(state, action);
if (check === "ok") {
const score = score_action(state, action);
if (score > max_score) {
max_score = score;
best_action = action;
}
}
}
return best_action;
}
const handler = connect4_agent(agent);
export default traced_handler(handler);

Upstash redis client for vals.

Creates a global redis instance and exposes functions to use it.

To use, create a redis database on Upstash and set the environment variables.

  • UPSTASH_REDIS_REST_URL
  • UPSTASH_REDIS_REST_TOKEN

Val.town runs in ohio so the best region to pick for the redis instance is probably us-east-1.

Calls are also traced via https://www.val.town/v/saolsen/tracing so if you use that library you will see timed spans for any redis calls.

For an example of how to use it see https://www.val.town/v/saolsen/try_redis

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import { SpanStatusCode } from "https://cdn.skypack.dev/@opentelemetry/api";
import { Redis } from "https://deno.land/x/upstash_redis@v1.14.0/mod.ts";
import { get_tracer } from "https://esm.town/v/saolsen/tracing?v=136";
const redis = new Redis({
url: Deno.env.get("UPSTASH_REDIS_REST_URL"),
token: Deno.env.get("UPSTASH_REDIS_REST_TOKEN"),
});
const traced = <F extends (...args: any[]) => any>(
name: string,
fn: F,
) => {
return async (...args: Parameters<F>): Promise<Awaited<ReturnType<F>>> => {
console.log(fn);
const key = args[0];
return await get_tracer().startActiveSpan(`redis:${name}`, async (span) => {
if (span.isRecording()) {
span.setAttributes({
"redis.key": key,
});
}
try {
const result = await fn(...args);
if (span.isRecording()) {
span.setStatus({ code: SpanStatusCode.OK });
}
return result;
} catch (error) {
if (span.isRecording()) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: error.message,
});
}
throw new Error(error);
} finally {
span.end();
}
});
};
};
export const set = traced("set", redis.set);
export const get = traced("get", redis.get);

Upstash qstash client for vals.

Lets you publish and receive qstash messages in vals.

To use, set up qstash on Upstash and set the environment variables.

  • QSTASH_TOKEN
  • QSTASH_CURRENT_SIGNING_KEY
  • QSTASH_NEXT_SIGNING_KEY

Calls are also traced via https://www.val.town/v/saolsen/tracing so if you use that library you will see linked spans between the publish call and the receive call.

For an example of how to use it see https://www.val.town/v/saolsen/try_qstash_publish and https://www.val.town/v/saolsen/try_qstash_receive.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
import {
context,
propagation,
SpanStatusCode,
trace,
} from "https://cdn.skypack.dev/@opentelemetry/api";
import { Client, Receiver } from "npm:@upstash/qstash";
import { get_tracer, traced } from "https://esm.town/v/saolsen/tracing?v=136";
const client = new Client({
token: Deno.env.get("QSTASH_TOKEN")!,
});
export type PublishOpts = { url?: string; topic?: string };
export async function publish(
body: object,
opts: PublishOpts
): Promise<{ messageId: string }> {
if ((opts.url && opts.topic) || (!opts.url && !opts.topic)) {
throw new Error("Must set one of url or topic.");
}
return await get_tracer().startActiveSpan(`qstash:publish`, async (span) => {
try {
const prop_output: { b3: string } = { b3: "" };
propagation.inject(context.active(), prop_output);
const result = await client.publishJSON({
body,
headers: prop_output,
...opts,
});
if (span.isRecording()) {
span.setAttributes({
"qstash:messageId": result.messageId,
});
span.setStatus({ code: SpanStatusCode.OK });
}
return result;
} catch (error) {
if (span.isRecording()) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: error.message,
});
}
throw new Error(error);
} finally {
span.end();
}
});
}
const receiver = new Receiver({
currentSigningKey: Deno.env.get("QSTASH_CURRENT_SIGNING_KEY")!,
nextSigningKey: Deno.env.get("QSTASH_NEXT_SIGNING_KEY")!,
});
export async function receive(
body: string,
headers: Headers
): Promise<object | null> {
const is_valid = await receiver
.verify({
signature: headers.get("upstash-signature")!,
body,
})
.catch((e) => {
console.error(e);
false;
});
if (!is_valid) {
return null;
}
let span = trace.getSpan(context.active());
if (span) {
span.setAttributes({
"qstash:messageId": headers.get("upstash-message-id"),
});
}
return JSON.parse(body);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import {
init,
traced,
traced_handler,
} from "https://esm.town/v/saolsen/tracing?v=126";
import { trace } from "npm:@opentelemetry/api";
init("traced_http_val");
const tracer = trace.getTracer("traced_http_val");
const sleep = (delay: number) =>
new Promise((resolve) => setTimeout(resolve, delay));
function _foo(x: number) {
console.log(x);
}
const foo = traced("foo", _foo);
function _throw() {
throw new Error("errr");
}
const thro = traced("throw", _throw);
const doSomething = traced("do-something", async () => {
await sleep(100);
});
async function afn(x: number): Promise<number> {
return x;
}
const tafn = traced("afn", afn);
async function handler(request: Request): Promise<Response> {
await tracer.startActiveSpan("sub-span", async (span) => {
await doSomething();
await foo();
try {
await thro();
} catch (e) {
console.log("nah");
}
await sleep(100);
span.addEvent("sub event ok");
await tracer.startActiveSpan("sub-span-2", async (span2) => {
await sleep(100);
span2.end();
});
await tracer.startActiveSpan("sub-span-3", async (span3) => {
await sleep(100);
span3.end();
});
span.end();
});
return Response.json({ hello: "world" });
}
export default traced_handler(handler);
1
Next